2424})
2525_MAX_FIELD_LEN = 500 # max characters per Lead field value
2626
27+ # Map Frappe DocType names (as they appear in "Could not find X: Y" errors) to
28+ # the Lead field name, so _find_bad_field can strip the offending field.
29+ _LINK_DOCTYPE_TO_FIELD = {
30+ "country" : "country" ,
31+ "salutation" : "salutation" ,
32+ "Country" : "country" ,
33+ "Salutation" : "salutation" ,
34+ }
35+
36+ # Maps lowercase field labels (as Frappe uses them in error messages) to Lead field names.
37+ # Lets _find_bad_field identify any field from a ValidationError, not just link fields.
38+ _FIELD_LABEL_TO_NAME = {
39+ "salutation" : "salutation" ,
40+ "first name" : "first_name" ,
41+ "middle name" : "middle_name" ,
42+ "last name" : "last_name" ,
43+ "gender" : "gender" ,
44+ "job title" : "job_title" ,
45+ "email id" : "email_id" ,
46+ "email" : "email_id" ,
47+ "mobile no" : "mobile_no" ,
48+ "mobile" : "mobile_no" ,
49+ "whatsapp no" : "whatsapp_no" ,
50+ "whatsapp" : "whatsapp_no" ,
51+ "phone" : "phone" ,
52+ "phone ext" : "phone_ext" ,
53+ "company name" : "company_name" ,
54+ "company" : "company_name" ,
55+ "website" : "website" ,
56+ "fax" : "fax" ,
57+ "city" : "city" ,
58+ "state" : "state" ,
59+ "country" : "country" ,
60+ }
61+
62+
63+ def _find_bad_field (error_msg , data ):
64+ """
65+ Parse a Frappe ValidationError message and return the Lead field name
66+ that caused it, or None if it cannot be determined.
67+ """
68+ import re
69+ # "Could not find {DocType}: {value}" — Link field resolution failure
70+ m = re .search (r"Could not find ([\w ]+):" , error_msg )
71+ if m :
72+ doctype = m .group (1 ).strip ()
73+ field = _LINK_DOCTYPE_TO_FIELD .get (doctype ) or _LINK_DOCTYPE_TO_FIELD .get (doctype .lower ())
74+ if field and field in data :
75+ return field
76+ # Check if any field's current value appears verbatim in the error message
77+ for field , value in data .items ():
78+ if value and str (value ) in error_msg :
79+ return field
80+ # Check if any field label appears in the error message
81+ # (e.g. "Value for Gender must be one of …", "Invalid Email Id")
82+ err_lower = error_msg .lower ()
83+ for label , field in _FIELD_LABEL_TO_NAME .items ():
84+ if label in err_lower and field in data :
85+ return field
86+ return None
87+
2788
2889class _QuotaExceededError (Exception ):
2990 pass
@@ -127,6 +188,7 @@ def submit_card_scan(merged_image_base64, filename="business_card.jpg"):
127188 "submitted_at" : frappe .utils .now (),
128189 "job_id" : job_id ,
129190 "cb_secret" : cb_secret ,
191+ "scanned_by" : frappe .session .user ,
130192 })
131193 log .insert (ignore_permissions = True )
132194 frappe .db .commit ()
@@ -186,9 +248,13 @@ def scan_callback(job_id, cb_secret, success, data=None, error=None,
186248 if not job_id or not cb_secret :
187249 return {"success" : False , "error" : "missing_params" }
188250
189- log_name = frappe .db .get_value ("Card Scan Log" , {"job_id" : job_id }, "name" )
190- if not log_name :
251+ log_data = frappe .db .get_value (
252+ "Card Scan Log" , {"job_id" : job_id }, ["name" , "scanned_by" ], as_dict = True
253+ )
254+ if not log_data :
191255 return {"success" : False , "error" : "invalid_job_id" }
256+ log_name = log_data .name
257+ scanned_by = log_data .scanned_by
192258
193259 # Constant-time secret comparison — prevents timing-based enumeration
194260 stored_secret = frappe .db .get_value ("Card Scan Log" , log_name , "cb_secret" ) or ""
@@ -218,11 +284,51 @@ def scan_callback(job_id, cb_secret, success, data=None, error=None,
218284 if k in _ALLOWED_LEAD_FIELDS and v not in (None , "" )
219285 }
220286 if data :
287+ skipped_fields = {}
221288 try :
222- lead = frappe .get_doc ({"doctype" : "Lead" , ** data })
223- lead .insert (ignore_permissions = True )
224- frappe .db .commit ()
225- lead_name = lead .name
289+ # Retry loop: on ValidationError, strip the offending field and try again.
290+ # This handles AI values that don't match ERPNext options (e.g. country="BHARAT").
291+ for _attempt in range (len (data ) + 1 ):
292+ try :
293+ lead_doc_data = {"doctype" : "Lead" , ** data }
294+ if scanned_by and scanned_by != "Guest" :
295+ lead_doc_data ["lead_owner" ] = scanned_by
296+ lead = frappe .get_doc (lead_doc_data )
297+ lead .insert (ignore_permissions = True )
298+ frappe .db .commit ()
299+ lead_name = lead .name
300+ break
301+ except frappe .exceptions .DuplicateEntryError :
302+ raise
303+ except frappe .ValidationError as e :
304+ frappe .db .rollback ()
305+ bad_field = _find_bad_field (str (e ), data )
306+ if bad_field :
307+ skipped_fields [bad_field ] = data .pop (bad_field )
308+ else :
309+ raise # can't identify which field — propagate
310+ else :
311+ raise frappe .ValidationError ("All fields were invalid; no lead could be created." )
312+
313+ # Add a comment listing any skipped fields so the sales rep knows what was dropped
314+ if skipped_fields :
315+ # Null out the skipped fields — without this, Frappe applies doctype
316+ # defaults (e.g. country defaults to "India") when the field is absent.
317+ frappe .db .set_value ("Lead" , lead_name ,
318+ {f : None for f in skipped_fields })
319+ lines = ["<b>NextIQ: the following fields were skipped (invalid values):</b><ul>" ]
320+ for f , v in skipped_fields .items ():
321+ lines .append (f"<li><b>{ f } </b>: { v } </li>" )
322+ lines .append ("</ul>" )
323+ frappe .get_doc ({
324+ "doctype" : "Comment" ,
325+ "comment_type" : "Info" ,
326+ "reference_doctype" : "Lead" ,
327+ "reference_name" : lead_name ,
328+ "content" : "" .join (lines ),
329+ }).insert (ignore_permissions = True )
330+ frappe .db .commit ()
331+
226332 except frappe .exceptions .DuplicateEntryError as e :
227333 err_msg = str (e )[:500 ] or "A lead with this email address already exists."
228334 frappe .db .rollback ()
@@ -236,11 +342,7 @@ def scan_callback(job_id, cb_secret, success, data=None, error=None,
236342 _send_scan_notification (log_name , "duplicate_lead" , message = err_msg )
237343 return {"success" : False , "error" : "duplicate_lead" }
238344 except frappe .ValidationError as e :
239- # AI processed successfully and returned data, but the data has
240- # values that Frappe cannot accept (e.g. country="USA" instead of
241- # "United States", invalid select option, bad link value, etc.).
242- # Scan is already charged — this is a data quality issue, not a failure.
243- err_msg = str (e )[:500 ] or "AI data could not be saved as a Lead — one or more field values were invalid."
345+ err_msg = str (e )[:500 ] or "AI data could not be saved as a Lead — all field values were invalid."
244346 frappe .db .rollback ()
245347 frappe .db .set_value ("Card Scan Log" , log_name , {
246348 "status" : "Invalid Data" ,
0 commit comments