Preserve the pre-existing contract: create_customer returned None when contact/address linking failed.
The savepoint fix kept the Customer (good) but started returning its name in that case, so a CRM caller
treating a non-None return as full success could skip its retry/error handling. Return None on a linking
failure while still keeping the Customer. (greptile #56683)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
create_customer wrapped customer.insert() + create_contacts() + create_address() in one try whose except
did a full frappe.db.rollback(), so a failure while linking contacts/address discarded the Customer just
created (MariaDB kept it pre-migration). Split the try: the customer insert keeps its full rollback (safe
-- nothing precedes it), and contact/address linking runs under a savepoint so its failure rolls back only
the links, preserving the Customer and healing the Postgres txn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
create_address is a helper called by create_prospect/create_customer AFTER they insert the Prospect/Customer. Its full frappe.db.rollback() on an address-save failure rolled back the caller's just-inserted parent doc, then swallowed the exception, so the caller returned a Prospect/Customer name that no longer existed. Scope the rollback to savepoint('crm_create_address') so only the address work is undone; the parent doc survives and the failed address is just logged.
send_mail (called per campaign schedule in a loop) inserts a Communication via make(); on failure the except calls frappe.log_error with no rollback, raising InFailedSqlTransaction on Postgres and poisoning subsequent sends. Savepoint before make() + rollback(save_point=) before log_error. No-op on MariaDB.
create_prospect/create_address/create_customer insert docs and on failure call frappe.log_error with no rollback; on Postgres (untrusted external CRM webhook input) a failed insert poisons the txn so log_error raises InFailedSqlTransaction. Full frappe.db.rollback() before each log_error. No-op on MariaDB.
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:
- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings
Part of #53976.
create_prospect_against_crm_deal and create_customer read `contacts`
from form_dict (json.loads(doc.contacts) / customer_data.get("contacts")),
which arrives as a native list under JSON body mode. Use frappe.parse_json.
The Lead Details report concatenated address_line1 and address_line2 with
CONCAT_WS. An unfilled optional Data field is stored as '' on MariaDB but as
NULL on PostgreSQL; CONCAT_WS keeps the empty string (leaving a trailing
", ") on MariaDB while Postgres drops the NULL, so the same lead rendered a
different address on each engine.
Wrap both parts in NULLIF(part, '') so empty values are treated as NULL on
both engines: the report now produces the same clean address (no trailing
separator) everywhere.
set_lead_name fell through to email_id.split('@') when a lead had no name,
company or email but ignore_mandatory was set (e.g. data import), raising
AttributeError on a None email. Only derive from email when one exists; the
lead name is then left blank, as intended for that path.
auto_close_opportunity fell back to 15 days in code when the CRM Setting was
blank (and its docstring still said 7). The field already defaults to 15, so
read the value straight from CRM Settings and add a patch to backfill 15 for
existing sites that left it blank, keeping the same auto-close schedule.
Add tests for get_lead_details and the Lead <-> Prospect lifecycle: editing a
lead syncs into its Prospect Lead row, and deleting the only lead of a
prospect removes the prospect. Lead controller coverage 65% -> 74%.
Add tests for get_item_details, auto_close_opportunity (a stale Replied
opportunity is closed, a recent one is not) and the Opportunity -> Prospect
opportunity sync. Opportunity controller coverage 62% -> 80%.
declare_enquiry_lost had almost no coverage. Add tests that marking an
Opportunity as lost records the lost reasons, competitors and detailed
reason and sets status to Lost, and that it is blocked when an active
(submitted) Quotation exists.
- Fix N+1: the per-lead conversion issued 4 queries per lead (Opportunity, Quotation,
Issue, Communication). Collect the reference documents for all leads in 3 bulk
queries, then one Communication query per lead -> ~N+3 round-trips instead of 4N,
matching the original single-query-per-lead cost.
- Constrain reference_doctype in the Communication lookup (names are unique only within
a doctype), closing a latent cross-doctype name-collision gap the original also had.
Both-engine test still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the remaining raw frappe.db.sql in the CRM module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:
- opportunity.py, doctype/utils.py (get_last_interaction)
- reports: campaign_efficiency, first_response_time_for_opportunity (GROUP BY on the
grouped Date(creation) + Avg -- Postgres-valid), lead_conversion_time,
prospects_engaged_but_not_converted
lead_conversion_time also keeps the IS NOT NULL communication-date guard (forward-port
of the fix already on develop). Also drops invalid backtick notation from two get_all
order_by clauses in doctype/utils.py (order_by="`creation` DESC"), which frappe's
query engine rejects -- a latent failure on both engines, surfaced by the new test.
Tests: existing opportunity suite plus new both-engine tests for the four previously
untested reports/utils. All green on MariaDB and PostgreSQL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review (#56105): when there's no matching communication, first_contact is
None and date_diff(invoice_date, None) treats None as today, giving a wrong
(negative) duration. Skip the entry instead, mirroring the communication_count guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review (#56105): the IS NOT NULL guard can return no rows (the count above
filters on sender, this query on recipients), so [0][0] would raise IndexError.
Fall back to None when empty, matching the prior behaviour for that case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parity sweep findings in queries already merged in develop:
- pos_invoice.py: serial_no return-match matched case-sensitively on Postgres (case-insensitive
on MariaDB). Replaced the get_all or_filters lookup with a qb query that case-folds both sides
via Lower() (no-op on MariaDB).
- controllers/queries.py + pick_list.py: the Locate()-based relevance ranking in link-query
ORDER BY is case-sensitive on Postgres (Strpos) vs case-insensitive on MariaDB (Locate), so
autocomplete order differed. Lower() both arguments so the ranking matches on both engines.
- crm/lead_conversion_time.py: "first contact" used ORDER BY communication_date LIMIT 1 read by
index; a NULL date sorts first on MariaDB but last on Postgres, changing the result. Added
`communication_date IS NOT NULL` so both engines return the earliest real contact date.
Verified on MariaDB and Postgres: test_pick_list 40/40, test_pos_invoice 26/26 on both engines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Repoint all JS method strings and Python imports for mapper functions
across 18 doctypes from the doctype module to its mapper module, and
remove the now-unused re-export shims from each doctype file (keeping
only names used internally).