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
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:
0
crm/integrations/erpnext/__init__.py
Normal file
0
crm/integrations/erpnext/__init__.py
Normal file
82
crm/integrations/erpnext/doc_share.py
Normal file
82
crm/integrations/erpnext/doc_share.py
Normal 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)
|
||||
102
crm/integrations/erpnext/item.py
Normal file
102
crm/integrations/erpnext/item.py
Normal 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
|
||||
126
crm/integrations/erpnext/mirror_sync.py
Normal file
126
crm/integrations/erpnext/mirror_sync.py
Normal 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)
|
||||
138
crm/integrations/erpnext/test_utils.py
Normal file
138
crm/integrations/erpnext/test_utils.py
Normal 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
|
||||
87
crm/integrations/erpnext/user_permission.py
Normal file
87
crm/integrations/erpnext/user_permission.py
Normal 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)
|
||||
120
crm/integrations/erpnext/utils.py
Normal file
120
crm/integrations/erpnext/utils.py
Normal 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()
|
||||
Reference in New Issue
Block a user