snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

View File

View File

@@ -0,0 +1,82 @@
import frappe
from crm.integrations.erpnext.mirror_sync import MIRROR_FLAG, MirrorSync
from crm.integrations.erpnext.utils import should_sync
def _sync(doc) -> MirrorSync:
return MirrorSync(
doc,
"share_doctype",
"share_name",
extra_mirror_flags={"ignore_share_permission": True},
)
def before_validate(doc, method=None):
sync = _sync(doc)
old = doc.get_doc_before_save()
if old and sync.has_data_updated(old) and sync.sync_active():
sync.delete_mirror_for(old)
def after_insert(doc, method=None):
sync = _sync(doc)
if sync.should_mirror():
sync.create_mirror()
def on_update(doc, method=None):
sync = _sync(doc)
if not sync.should_mirror():
return
old = doc.get_doc_before_save()
if old and sync.has_data_updated(old):
sync.create_mirror()
else:
sync.sync_state_to_mirror()
def on_trash(doc, method=None):
sync = _sync(doc)
if not sync.should_mirror():
return
mirror = sync.find_mirror()
if not mirror:
return
sync.set_mirror_flags(mirror)
mirror.delete(ignore_permissions=True)
def sync_shared_docs():
if not should_sync():
return
product_shares = frappe.get_list("DocShare", filters={"share_doctype": "CRM Product"}, fields=["*"])
item_shares = frappe.get_list("DocShare", filters={"share_doctype": "Item"}, fields=["*"])
existing_product = {(s.user, s.share_name) for s in product_shares}
existing_item = {(s.user, s.share_name) for s in item_shares}
for share in item_shares:
product = frappe.db.get_value("Item", share.share_name, "crm_product_code")
if not product or (share.user, product) in existing_product:
continue
_mirror_share(share, "CRM Product", product)
existing_product.add((share.user, product))
for share in product_shares:
item = frappe.db.get_value("CRM Product", share.share_name, "erpnext_item_code")
if not item or (share.user, item) in existing_item:
continue
_mirror_share(share, "Item", item)
existing_item.add((share.user, item))
def _mirror_share(src, target_doctype, target_value):
data = dict(src)
for k in ("name", "owner", "creation", "modified", "modified_by", "idx"):
data.pop(k, None)
data.update({"doctype": "DocShare", "share_doctype": target_doctype, "share_name": target_value})
doc = frappe.get_doc(data)
doc.flags[MIRROR_FLAG] = True
doc.flags.ignore_share_permission = True
doc.insert(ignore_permissions=True)

View File

@@ -0,0 +1,102 @@
import frappe
from crm.fcrm.doctype.crm_product.sync_utils import payload_differs
from crm.integrations.erpnext.utils import (
CASCADE_FLAG,
cascade_rename,
in_cascade,
set_links,
should_sync,
validate_rename_conflict,
)
CATALOGUE_FIELDS = ("standard_rate", "image", "disabled", "description")
def _should_sync(doc) -> bool:
return should_sync() and not doc.flags.get("ignore_crm_sync")
def get_item_price_rate(item_code: str, uom: str | None = None):
from erpnext.stock.get_item_details import get_item_price
price_list = frappe.db.get_single_value("Selling Settings", "selling_price_list")
if not price_list:
return None
pctx = frappe._dict({"price_list": price_list, "uom": uom, "transaction_date": frappe.utils.nowdate()})
rows = get_item_price(pctx, item_code)
if not rows:
return None
row = rows[0]
return row.price_list_rate if isinstance(row, dict) else row[1]
def _catalogue_data(doc) -> dict:
data = {f: doc.get(f) for f in CATALOGUE_FIELDS}
# Give preference to Item Price over standard rate
# keep standard_rate as fallback
rate = get_item_price_rate(doc.name, doc.get("stock_uom"))
if rate is not None:
data["standard_rate"] = rate
# standard_rate is a NOT NULL Currency column and frappe.db.set_value writes
# raw values (no flt coercion, unlike doc.insert), so never pass None through
data["standard_rate"] = frappe.utils.flt(data["standard_rate"])
return data
def after_insert(doc, method=None):
if not _should_sync(doc):
return
if frappe.db.get_value("CRM Product", {"erpnext_item_code": doc.name}):
return
product = frappe.get_doc(
{
"doctype": "CRM Product",
"product_code": doc.item_code,
"product_name": doc.item_name,
**_catalogue_data(doc),
}
)
product.flags.ignore_erpnext_sync = True
product.insert(ignore_permissions=True)
set_links(doc.name, product.name)
def on_update(doc, method=None):
if not _should_sync(doc):
return
product_name = frappe.db.get_value("CRM Product", {"erpnext_item_code": doc.name})
if not product_name:
return
data = {
"product_name": doc.item_name,
**_catalogue_data(doc),
}
current = frappe.db.get_value("CRM Product", product_name, list(data.keys()), as_dict=True) or {}
if not payload_differs(data, current):
return
frappe.db.set_value("CRM Product", product_name, data)
def before_rename(doc, method=None, olddn=None, newdn=None, merge=False):
validate_rename_conflict("Item", olddn, newdn, merge)
def after_rename(doc, method=None, olddn=None, newdn=None, merge=False):
cascade_rename("Item", olddn, newdn, merge)
def on_trash(doc, method=None):
if in_cascade() or not _should_sync(doc):
return
product = frappe.db.get_value("CRM Product", {"erpnext_item_code": doc.name})
if not product:
return
# Clear back link first so CRMProduct.on_trash won't try to delete this Item
frappe.db.set_value("CRM Product", product, "erpnext_item_code", None)
frappe.flags[CASCADE_FLAG] = True
try:
frappe.delete_doc("CRM Product", product, ignore_permissions=True)
finally:
frappe.flags[CASCADE_FLAG] = False

View File

@@ -0,0 +1,126 @@
import frappe
from crm.integrations.erpnext.utils import ALLOWED_DOCTYPES, find_target_for, should_sync
MIRROR_FLAG = "ignore_erpnext_sync"
_SKIP_PAYLOAD_FIELDS = {
"name",
"owner",
"creation",
"modified",
"modified_by",
"idx",
"docstatus",
"parent",
"parentfield",
"parenttype",
"doctype",
}
class MirrorSync:
"""Generic mirror sync for lifecycle-bound docs (User Permission, DocShare)
between Item and CRM Product. Wraps a document and is driven by doc_events
handlers. Configure the link fields via doctype_field + value_field.
extra_dedup: optional callable(doc) -> dict, merged into the dedup filter.
extra_mirror_flags: optional dict of flags set on every mirror document.
"""
def __init__(self, doc, doctype_field, value_field, extra_dedup=None, extra_mirror_flags=None):
self.doc = doc
self.DOCTYPE_FIELD = doctype_field
self.VALUE_FIELD = value_field
self._extra_dedup = extra_dedup
self._extra_mirror_flags = extra_mirror_flags or {}
def sync_active(self) -> bool:
return should_sync()
def should_mirror(self) -> bool:
if self.doc.flags.get(MIRROR_FLAG):
return False
if not self.sync_active():
return False
return self.doc.get(self.DOCTYPE_FIELD) in ALLOWED_DOCTYPES
def has_data_updated(self, old) -> bool:
return self.doc.get(self.DOCTYPE_FIELD) != old.get(self.DOCTYPE_FIELD) or self.doc.get(
self.VALUE_FIELD
) != old.get(self.VALUE_FIELD)
def _target(self):
return find_target_for(self.doc.get(self.DOCTYPE_FIELD), self.doc.get(self.VALUE_FIELD))
def dedup_filter(self, target_doctype: str, target_value: str) -> dict:
filters = {
self.DOCTYPE_FIELD: target_doctype,
self.VALUE_FIELD: target_value,
"user": self.doc.user,
}
if self._extra_dedup:
filters.update(self._extra_dedup(self.doc))
return filters
def find_mirror(self):
target = self._target()
if not target:
return None
target_doctype, target_value = target
filters = self.dedup_filter(target_doctype, target_value)
name = frappe.db.get_value(self.doc.doctype, filters, "name")
return frappe.get_doc(self.doc.doctype, name) if name else None
def set_mirror_flags(self, mirror):
mirror.flags[MIRROR_FLAG] = True
for flag, value in self._extra_mirror_flags.items():
mirror.flags[flag] = value
def _mirror_payload(self, target_doctype, target_value):
data = self.doc.as_dict()
for k in _SKIP_PAYLOAD_FIELDS:
data.pop(k, None)
data[self.DOCTYPE_FIELD] = target_doctype
data[self.VALUE_FIELD] = target_value
data["doctype"] = self.doc.doctype
return data
def create_mirror(self):
target = self._target()
if not target:
return
target_doctype, target_value = target
if frappe.db.exists(self.doc.doctype, self.dedup_filter(target_doctype, target_value)):
return
mirror = frappe.get_doc(self._mirror_payload(target_doctype, target_value))
self.set_mirror_flags(mirror)
mirror.insert(ignore_permissions=True)
def sync_state_to_mirror(self):
mirror = self.find_mirror()
if not mirror:
return
changed = False
for field, value in self.doc.as_dict().items():
if field in _SKIP_PAYLOAD_FIELDS or field in (self.DOCTYPE_FIELD, self.VALUE_FIELD):
continue
if mirror.get(field) != value:
mirror.set(field, value)
changed = True
if not changed:
return
self.set_mirror_flags(mirror)
mirror.save(ignore_permissions=True)
def delete_mirror_for(self, old):
old_target = find_target_for(old.get(self.DOCTYPE_FIELD), old.get(self.VALUE_FIELD))
if not old_target:
return
target_doctype, target_value = old_target
filters = self.dedup_filter(target_doctype, target_value)
name = frappe.db.get_value(self.doc.doctype, filters, "name")
if not name:
return
mirror = frappe.get_doc(self.doc.doctype, name)
self.set_mirror_flags(mirror)
mirror.delete(ignore_permissions=True)

View File

@@ -0,0 +1,138 @@
import unittest
from importlib.util import find_spec
import frappe
from frappe.tests.utils import FrappeTestCase
# Skip tests that import erpnext when it isn't installed. CI only installs
# ERPNext when integration code under crm/integrations/erpnext/** is touched.
ERPNEXT_INSTALLED = find_spec("erpnext") is not None
# Doctypes whose sync behaviour moved from override_doctype_class to doc_events
SYNC_DOCTYPES = ("Item", "User Permission", "DocShare")
class TestSyncHookWiring(FrappeTestCase):
def test_sync_doctypes_not_class_overridden(self):
from crm.hooks import override_doctype_class
for doctype in SYNC_DOCTYPES:
self.assertNotIn(doctype, override_doctype_class)
def test_doc_event_handlers_are_importable(self):
from crm.hooks import doc_events
for doctype in SYNC_DOCTYPES:
for handlers in doc_events[doctype].values():
for path in handlers:
self.assertTrue(callable(frappe.get_attr(path)), path)
class TestFindTargetFor(FrappeTestCase):
def test_returns_none_for_unknown_doctype(self):
from crm.integrations.erpnext.utils import find_target_for
self.assertIsNone(find_target_for("Some Other DocType", "X"))
def test_returns_none_when_inputs_missing(self):
from crm.integrations.erpnext.utils import find_target_for
self.assertIsNone(find_target_for(None, "X"))
self.assertIsNone(find_target_for("Item", None))
class TestItemRateFallback(FrappeTestCase):
def test_item_price_takes_precedence_over_standard_rate(self):
from crm.integrations.erpnext import item
doc = frappe._dict(
name="ITM-A", stock_uom="Nos", standard_rate=50, image=None, disabled=0, description="d"
)
original = item.get_item_price_rate
item.get_item_price_rate = lambda code, uom=None: 999
try:
data = item._catalogue_data(doc)
finally:
item.get_item_price_rate = original
self.assertEqual(data["standard_rate"], 999)
def test_falls_back_to_standard_rate_when_no_item_price(self):
from crm.integrations.erpnext import item
doc = frappe._dict(
name="ITM-B", stock_uom="Nos", standard_rate=50, image=None, disabled=0, description="d"
)
original = item.get_item_price_rate
item.get_item_price_rate = lambda code, uom=None: None
try:
data = item._catalogue_data(doc)
finally:
item.get_item_price_rate = original
self.assertEqual(data["standard_rate"], 50)
def test_none_rate_coerces_to_zero_when_no_item_price(self):
from crm.integrations.erpnext import item
doc = frappe._dict(
name="ITM-D", stock_uom="Nos", standard_rate=None, image=None, disabled=0, description="d"
)
original = item.get_item_price_rate
item.get_item_price_rate = lambda code, uom=None: None
try:
data = item._catalogue_data(doc)
finally:
item.get_item_price_rate = original
self.assertEqual(data["standard_rate"], 0)
@unittest.skipUnless(ERPNEXT_INSTALLED, "erpnext not installed")
def test_get_item_price_rate_uses_default_price_list_and_general_party(self):
from erpnext.stock import get_item_details
from crm.integrations.erpnext import item
captured = {}
orig_single = frappe.db.get_single_value
orig_get_item_price = get_item_details.get_item_price
frappe.db.get_single_value = lambda doctype, field: "Standard Selling"
def fake_get_item_price(pctx, item_code, **kwargs):
captured.update(pctx=dict(pctx), item_code=item_code, kwargs=kwargs)
return [frappe._dict(name="IP-1", price_list_rate=77, uom="Nos")]
get_item_details.get_item_price = fake_get_item_price
try:
rate = item.get_item_price_rate("ITM-C", "Nos")
finally:
frappe.db.get_single_value = orig_single
get_item_details.get_item_price = orig_get_item_price
self.assertEqual(rate, 77)
self.assertEqual(captured["item_code"], "ITM-C")
self.assertEqual(captured["pctx"]["price_list"], "Standard Selling")
self.assertEqual(captured["pctx"]["uom"], "Nos")
self.assertIn("transaction_date", captured["pctx"])
# No customer/supplier and ignore_party unset -> ERPNext selects general prices only
self.assertNotIn("customer", captured["pctx"])
self.assertFalse(captured["kwargs"].get("ignore_party"))
@unittest.skipUnless(ERPNEXT_INSTALLED, "erpnext not installed")
def test_get_item_price_rate_returns_none_without_default_price_list(self):
from crm.integrations.erpnext import item
original = frappe.db.get_single_value
frappe.db.get_single_value = lambda doctype, field: None
try:
self.assertIsNone(item.get_item_price_rate("ITM-X"))
finally:
frappe.db.get_single_value = original
class TestCascadeFlag(FrappeTestCase):
def test_flag_round_trip(self):
from crm.integrations.erpnext.utils import CASCADE_FLAG, in_cascade
self.assertFalse(in_cascade())
frappe.flags[CASCADE_FLAG] = True
try:
self.assertTrue(in_cascade())
finally:
frappe.flags[CASCADE_FLAG] = False

View File

@@ -0,0 +1,87 @@
import frappe
from crm.integrations.erpnext.mirror_sync import MIRROR_FLAG, MirrorSync
from crm.integrations.erpnext.utils import should_sync
def _dedup_extra(doc) -> dict:
# Keep a blank applicable_for as None so dedup/find filters match the stored
# NULL value (an empty string would never match a NULL column).
return {
"applicable_for": doc.applicable_for or None,
"apply_to_all_doctypes": doc.apply_to_all_doctypes,
}
def _sync(doc) -> MirrorSync:
return MirrorSync(doc, "allow", "for_value", extra_dedup=_dedup_extra)
def before_validate(doc, method=None):
sync = _sync(doc)
old = doc.get_doc_before_save()
if old and sync.has_data_updated(old) and sync.sync_active():
sync.delete_mirror_for(old)
def after_insert(doc, method=None):
sync = _sync(doc)
if sync.should_mirror():
sync.create_mirror()
def on_update(doc, method=None):
sync = _sync(doc)
if not sync.should_mirror():
return
old = doc.get_doc_before_save()
if old and sync.has_data_updated(old):
sync.create_mirror()
else:
sync.sync_state_to_mirror()
def on_trash(doc, method=None):
sync = _sync(doc)
if not sync.should_mirror():
return
mirror = sync.find_mirror()
if not mirror:
return
sync.set_mirror_flags(mirror)
mirror.delete(ignore_permissions=True)
def sync_user_permissions():
"""Bulk-sync User Permissions between Item and CRM Product. Idempotent."""
if not should_sync():
return
product_perms = frappe.get_list("User Permission", filters={"allow": "CRM Product"}, fields=["*"])
item_perms = frappe.get_list("User Permission", filters={"allow": "Item"}, fields=["*"])
existing_product = {(p.user, p.for_value) for p in product_perms}
existing_item = {(p.user, p.for_value) for p in item_perms}
for perm in item_perms:
product = frappe.db.get_value("Item", perm.for_value, "crm_product_code")
if not product or (perm.user, product) in existing_product:
continue
_mirror_permission(perm, "CRM Product", product)
existing_product.add((perm.user, product))
for perm in product_perms:
item = frappe.db.get_value("CRM Product", perm.for_value, "erpnext_item_code")
if not item or (perm.user, item) in existing_item:
continue
_mirror_permission(perm, "Item", item)
existing_item.add((perm.user, item))
def _mirror_permission(src, target_doctype, target_value):
data = dict(src)
for k in ("name", "owner", "creation", "modified", "modified_by", "idx"):
data.pop(k, None)
data.update({"doctype": "User Permission", "allow": target_doctype, "for_value": target_value})
doc = frappe.get_doc(data)
doc.flags[MIRROR_FLAG] = True
doc.insert(ignore_permissions=True)

View File

@@ -0,0 +1,120 @@
import frappe
from frappe import _
from frappe.model.rename_doc import rename_doc
CASCADE_FLAG = "crm_product_item_cascade_in_progress"
ALLOWED_DOCTYPES = ("CRM Product", "Item")
def should_sync() -> bool:
"""ERPNext Item to CRM Product sync runs when same-site integration is enabled."""
if "erpnext" not in frappe.get_installed_apps():
return False
settings = frappe.get_cached_doc("ERPNext CRM Settings")
return bool(settings.enabled) and not settings.is_erpnext_in_different_site
def should_push_to_erpnext() -> bool:
"""CRM Product to ERPNext Item sync is optional until CRM supports ERPNext tax details."""
if not should_sync():
return False
settings = frappe.get_cached_doc("ERPNext CRM Settings")
return bool(settings.sync_products)
def in_cascade() -> bool:
return bool(frappe.flags.get(CASCADE_FLAG))
def set_links(item_code: str, crm_product_name: str) -> None:
"""Permission-bypassing link bookkeeping. Mirrors HD's set_links."""
frappe.db.set_value(
"CRM Product", crm_product_name, "erpnext_item_code", item_code, update_modified=False
)
frappe.db.set_value("Item", item_code, "crm_product_code", crm_product_name, update_modified=False)
def _other_side(self_doctype: str) -> tuple[str, str]:
if self_doctype == "CRM Product":
return "Item", "crm_product_code"
return "CRM Product", "erpnext_item_code"
def find_target_for(doctype: str | None, value: str | None) -> tuple[str, str] | None:
if not doctype or not value:
return None
if doctype == "CRM Product":
item = frappe.db.get_value("Item", {"crm_product_code": value}, "name")
return ("Item", item) if item else None
if doctype == "Item":
prod = frappe.db.get_value("CRM Product", {"erpnext_item_code": value}, "name")
return ("CRM Product", prod) if prod else None
return None
def validate_rename_conflict(self_doctype, olddn, newdn, merge):
if in_cascade() or not _should_sync_direction(self_doctype):
return
other_doctype, other_link_field = _other_side(self_doctype)
linked = frappe.db.get_value(other_doctype, {other_link_field: olddn}, "name")
if not linked:
return
if merge:
surviving = frappe.db.get_value(other_doctype, {other_link_field: newdn}, "name")
if surviving:
return
if linked == newdn:
return
if frappe.db.exists(other_doctype, newdn):
existing_link = frappe.db.get_value(other_doctype, newdn, other_link_field)
if existing_link != olddn:
frappe.throw(
_(
"Cannot rename: an unrelated {0} '{1}' already exists on the "
"other side. Resolve manually first."
).format(other_doctype, newdn)
)
def cascade_rename(self_doctype, olddn, newdn, merge):
if in_cascade() or not _should_sync_direction(self_doctype):
return
other_doctype, other_link_field = _other_side(self_doctype)
linked = frappe.db.get_value(other_doctype, {other_link_field: olddn}, "name")
if not linked:
return
frappe.flags[CASCADE_FLAG] = True
try:
if merge:
surviving = frappe.db.get_value(other_doctype, {other_link_field: newdn}, "name")
if surviving and surviving != linked:
rename_doc(other_doctype, linked, surviving, merge=True, ignore_permissions=True)
target = surviving
else:
if linked != newdn:
rename_doc(other_doctype, linked, newdn, ignore_permissions=True)
target = newdn
else:
if linked != newdn:
rename_doc(other_doctype, linked, newdn, ignore_permissions=True)
target = newdn
_resync_links(self_doctype, newdn, target)
finally:
frappe.flags[CASCADE_FLAG] = False
def _resync_links(self_doctype, self_name, other_name):
if self_doctype == "CRM Product":
prod, item = self_name, other_name
else:
prod, item = other_name, self_name
if frappe.db.exists("CRM Product", prod):
frappe.db.set_value("CRM Product", prod, "erpnext_item_code", item, update_modified=False)
if frappe.db.exists("Item", item):
frappe.db.set_value("Item", item, "crm_product_code", prod, update_modified=False)
def _should_sync_direction(self_doctype):
if self_doctype == "CRM Product":
return should_push_to_erpnext()
return should_sync()