Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 113 additions & 11 deletions nextiq/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,67 @@
})
_MAX_FIELD_LEN = 500 # max characters per Lead field value

# Map Frappe DocType names (as they appear in "Could not find X: Y" errors) to
# the Lead field name, so _find_bad_field can strip the offending field.
_LINK_DOCTYPE_TO_FIELD = {
"country": "country",
"salutation": "salutation",
"Country": "country",
"Salutation": "salutation",
}

# Maps lowercase field labels (as Frappe uses them in error messages) to Lead field names.
# Lets _find_bad_field identify any field from a ValidationError, not just link fields.
_FIELD_LABEL_TO_NAME = {
"salutation": "salutation",
"first name": "first_name",
"middle name": "middle_name",
"last name": "last_name",
"gender": "gender",
"job title": "job_title",
"email id": "email_id",
"email": "email_id",
"mobile no": "mobile_no",
"mobile": "mobile_no",
"whatsapp no": "whatsapp_no",
"whatsapp": "whatsapp_no",
"phone": "phone",
"phone ext": "phone_ext",
"company name": "company_name",
"company": "company_name",
"website": "website",
"fax": "fax",
"city": "city",
"state": "state",
"country": "country",
}


def _find_bad_field(error_msg, data):
"""
Parse a Frappe ValidationError message and return the Lead field name
that caused it, or None if it cannot be determined.
"""
import re
# "Could not find {DocType}: {value}" — Link field resolution failure
m = re.search(r"Could not find ([\w ]+):", error_msg)
if m:
doctype = m.group(1).strip()
field = _LINK_DOCTYPE_TO_FIELD.get(doctype) or _LINK_DOCTYPE_TO_FIELD.get(doctype.lower())
if field and field in data:
return field
# Check if any field's current value appears verbatim in the error message
for field, value in data.items():
if value and str(value) in error_msg:
return field
# Check if any field label appears in the error message
# (e.g. "Value for Gender must be one of …", "Invalid Email Id")
err_lower = error_msg.lower()
for label, field in _FIELD_LABEL_TO_NAME.items():
if label in err_lower and field in data:
return field
return None


class _QuotaExceededError(Exception):
pass
Expand Down Expand Up @@ -127,6 +188,7 @@ def submit_card_scan(merged_image_base64, filename="business_card.jpg"):
"submitted_at": frappe.utils.now(),
"job_id": job_id,
"cb_secret": cb_secret,
"scanned_by": frappe.session.user,
})
log.insert(ignore_permissions=True)
frappe.db.commit()
Expand Down Expand Up @@ -186,9 +248,13 @@ def scan_callback(job_id, cb_secret, success, data=None, error=None,
if not job_id or not cb_secret:
return {"success": False, "error": "missing_params"}

log_name = frappe.db.get_value("Card Scan Log", {"job_id": job_id}, "name")
if not log_name:
log_data = frappe.db.get_value(
"Card Scan Log", {"job_id": job_id}, ["name", "scanned_by"], as_dict=True
)
if not log_data:
return {"success": False, "error": "invalid_job_id"}
log_name = log_data.name
scanned_by = log_data.scanned_by

# Constant-time secret comparison — prevents timing-based enumeration
stored_secret = frappe.db.get_value("Card Scan Log", log_name, "cb_secret") or ""
Expand Down Expand Up @@ -218,11 +284,51 @@ def scan_callback(job_id, cb_secret, success, data=None, error=None,
if k in _ALLOWED_LEAD_FIELDS and v not in (None, "")
}
if data:
skipped_fields = {}
try:
lead = frappe.get_doc({"doctype": "Lead", **data})
lead.insert(ignore_permissions=True)
frappe.db.commit()
lead_name = lead.name
# Retry loop: on ValidationError, strip the offending field and try again.
# This handles AI values that don't match ERPNext options (e.g. country="BHARAT").
for _attempt in range(len(data) + 1):
try:
lead_doc_data = {"doctype": "Lead", **data}
if scanned_by and scanned_by != "Guest":
lead_doc_data["lead_owner"] = scanned_by
lead = frappe.get_doc(lead_doc_data)
lead.insert(ignore_permissions=True)
frappe.db.commit()
lead_name = lead.name
break
except frappe.exceptions.DuplicateEntryError:
raise
except frappe.ValidationError as e:
frappe.db.rollback()
bad_field = _find_bad_field(str(e), data)
if bad_field:
skipped_fields[bad_field] = data.pop(bad_field)
else:
raise # can't identify which field — propagate
else:
raise frappe.ValidationError("All fields were invalid; no lead could be created.")

# Add a comment listing any skipped fields so the sales rep knows what was dropped
if skipped_fields:
# Null out the skipped fields — without this, Frappe applies doctype
# defaults (e.g. country defaults to "India") when the field is absent.
frappe.db.set_value("Lead", lead_name,
{f: None for f in skipped_fields})
lines = ["<b>NextIQ: the following fields were skipped (invalid values):</b><ul>"]
for f, v in skipped_fields.items():
lines.append(f"<li><b>{f}</b>: {v}</li>")
lines.append("</ul>")
frappe.get_doc({
"doctype": "Comment",
"comment_type": "Info",
"reference_doctype": "Lead",
"reference_name": lead_name,
"content": "".join(lines),
}).insert(ignore_permissions=True)
frappe.db.commit()

except frappe.exceptions.DuplicateEntryError as e:
err_msg = str(e)[:500] or "A lead with this email address already exists."
frappe.db.rollback()
Expand All @@ -236,11 +342,7 @@ def scan_callback(job_id, cb_secret, success, data=None, error=None,
_send_scan_notification(log_name, "duplicate_lead", message=err_msg)
return {"success": False, "error": "duplicate_lead"}
except frappe.ValidationError as e:
# AI processed successfully and returned data, but the data has
# values that Frappe cannot accept (e.g. country="USA" instead of
# "United States", invalid select option, bad link value, etc.).
# Scan is already charged — this is a data quality issue, not a failure.
err_msg = str(e)[:500] or "AI data could not be saved as a Lead — one or more field values were invalid."
err_msg = str(e)[:500] or "AI data could not be saved as a Lead — all field values were invalid."
frappe.db.rollback()
frappe.db.set_value("Card Scan Log", log_name, {
"status": "Invalid Data",
Expand Down
2 changes: 1 addition & 1 deletion nextiq/constants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) 2026, [email protected] and contributors
# For license information, please see license.txt

SERVICE_URL = "https://nextiq.in"
SERVICE_URL = "https://nextiq.in"
9 changes: 9 additions & 0 deletions nextiq/nextiq/doctype/card_scan_log/card_scan_log.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"field_order": [
"status",
"submitted_at",
"scanned_by",
"column_break_pxss",
"merged_image",
"processed_at",
Expand All @@ -22,6 +23,14 @@
"cb_secret"
],
"fields": [
{
"fieldname": "scanned_by",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Scanned By",
"options": "User",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
Expand Down