diff --git a/npd_project_module/hooks.py b/npd_project_module/hooks.py index e820aaf..78067b8 100644 --- a/npd_project_module/hooks.py +++ b/npd_project_module/hooks.py @@ -88,6 +88,13 @@ # before_install = "npd_project_module.install.before_install" after_install = "npd_project_module.install.after_install.after_install" +# Migration +# ------------ + +# Re-asserts the Project → NPD Tooling connection, which an ERPNext upgrade that +# re-imports project.json would otherwise drop. See install/after_migrate.py. +after_migrate = "npd_project_module.install.after_migrate.after_migrate" + # Uninstallation # ------------ @@ -190,6 +197,10 @@ # override_doctype_dashboards = { # "Task": "npd_project_module.task.get_dashboard_data" # } +# +# The Project → NPD Tooling connection is added as a custom DocType Link in +# install/after_install.py (add_project_tooling_connection) instead of via this hook, +# and re-asserted by the after_migrate hook above. # exempt linked doctypes from being automatically cancelled # diff --git a/npd_project_module/install/after_install.py b/npd_project_module/install/after_install.py index 62f092b..215dfba 100644 --- a/npd_project_module/install/after_install.py +++ b/npd_project_module/install/after_install.py @@ -4,6 +4,12 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +# Identity of the Project → NPD Tooling connection (a DocType Link row on Project). +# Shared by the install guard, the uninstall cleanup and the tests so the three can +# never drift apart. Deliberately does not pin `custom` — see +# add_project_tooling_connection. +PROJECT_TOOLING_LINK = {"parent": "Project", "link_doctype": "NPD Tooling"} + def after_install(): """ @@ -15,6 +21,7 @@ def after_install(): create_item_custom_fields() create_npd_template() create_tooling_setup() + add_project_tooling_connection() frappe.db.commit() print("NPD Project Module: Custom fields and configurations created successfully") @@ -44,6 +51,40 @@ def create_tooling_setup(): print(f" ✓ Item Group '{item_group}' already exists") +def add_project_tooling_connection(): + """Surface NPD Tooling in the Project form's Connections via a custom DocType Link. + + NPD Tooling links to Project through its `project` field, matching the Project + dashboard's default fieldname, so the connection count resolves automatically. + + The guard matches on parent + link_doctype only, not on `custom=1`: a site that + ships the same connection through fixtures or `export-customizations` has a + standard (custom=0) row, and adding a custom one alongside it would list NPD + Tooling twice in Connections. + + Idempotent, and re-asserted on every `bench migrate` (see install/after_migrate.py) + because re-importing `project.json` drops every DocType Link row on Project, + custom ones included. + """ + if frappe.db.exists("DocType Link", PROJECT_TOOLING_LINK): + print(" ✓ Project → NPD Tooling connection already present") + return + + frappe.get_doc( + { + "doctype": "DocType Link", + **PROJECT_TOOLING_LINK, + "parenttype": "DocType", + "parentfield": "links", + "link_fieldname": "project", + "group": "Tooling", + "custom": 1, + } + ).insert(ignore_permissions=True) + frappe.clear_cache(doctype="Project") + print(" ✓ Project → NPD Tooling connection added") + + def create_task_custom_fields(): """ Create custom fields for Task doctype to support part-based iteration management. diff --git a/npd_project_module/install/after_migrate.py b/npd_project_module/install/after_migrate.py new file mode 100644 index 0000000..ac465b8 --- /dev/null +++ b/npd_project_module/install/after_migrate.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Guru107 and contributors +# For license information, please see license.txt + +from npd_project_module.install.after_install import add_project_tooling_connection + + +def after_migrate(): + """ + Re-assert this app's customizations on the core Project doctype after every migrate. + + The Project → NPD Tooling connection is a DocType Link row parented to Project. + `custom=1` protects it from Customize Form rewrites, but not from a doctype + re-import: when `project.json` changes (any ERPNext upgrade that touches Project), + Frappe reloads it via `delete_doc(..., for_reload=True)`, which drops *every* + DocType Link row on Project, custom ones included. A one-shot patch cannot recover + from that, so the connection is re-created here on each `bench migrate` instead. + + Runs after doctype sync and after patches, and the helper is idempotent, so this is + a no-op on the migrates where nothing was dropped. No manual commit: migrate calls + these hooks from its `@atomic` post_schema_updates, which commits on success and + rolls back if anything in the phase raises. + """ + add_project_tooling_connection() diff --git a/npd_project_module/tests/test_npd_tooling.py b/npd_project_module/tests/test_npd_tooling.py index 339444f..c72453e 100644 --- a/npd_project_module/tests/test_npd_tooling.py +++ b/npd_project_module/tests/test_npd_tooling.py @@ -2,7 +2,8 @@ # For license information, please see license.txt """ -Unit tests for the NPD Tooling order doctype and Tooling Recovery Register report. +Unit tests for the NPD Tooling order doctype, the Tooling Recovery Register report, and +the Project → NPD Tooling connection this app adds to the Project form. An NPD Tooling record is a customer-PO tooling order with child tool lines (a part can need several tools, each possibly from a different supplier). Recovery combines money paid @@ -10,12 +11,17 @@ invoice), versus the total tooling amount, so it counts even before a tax invoice exists and never double-counts. The double-count-free combination is unit-tested purely; order totals, ageing, and manual-allocation recovery are tested end-to-end, with the Payment -Entry helper skipping gracefully if the site lacks the accounting fixtures. +Entry helper skipping gracefully if the site lacks the accounting fixtures. The Project +connection is checked through the Project dashboard data the form actually renders. """ import frappe from frappe.utils import add_days, getdate, today +from npd_project_module.install.after_install import ( + PROJECT_TOOLING_LINK, + add_project_tooling_connection, +) from npd_project_module.npd_project_module.doctype.npd_tooling.npd_tooling import ( compute_recovered, compute_recovery_status, @@ -539,3 +545,43 @@ def test_invoice_fully_paid(self): def test_unpaid_invoice_contributes_zero(self): # A fully-outstanding invoice means nothing has been recovered through it. self.assertEqual(compute_recovered([(1000, 1000)], [], set()), 0) + + +class TestProjectDashboardConnection(NPDProjectModuleTestSuite): + """The Project form's Connections should surface NPD Tooling via a custom DocType Link. + + These tests write to the core Project doctype, so setUp records the links already on + the site and tearDown deletes only what the test itself added — the site is left + exactly as it was found, whether or not the app was already installed on it. + """ + + def setUp(self): + super().setUp() + self.preexisting_links = set(self._tooling_links()) + + def tearDown(self): + for name in set(self._tooling_links()) - self.preexisting_links: + frappe.delete_doc("DocType Link", name, force=True, ignore_permissions=True) + frappe.db.commit() + frappe.clear_cache(doctype="Project") + super().tearDown() + + def _tooling_links(self): + """Names of every DocType Link connecting Project to NPD Tooling.""" + return frappe.get_all("DocType Link", filters=PROJECT_TOOLING_LINK, pluck="name") + + def _tooling_in_connections(self): + """Whether NPD Tooling shows up in the dashboard data the Project form renders.""" + transactions = frappe.get_meta("Project").get_dashboard_data().get("transactions", []) + return any("NPD Tooling" in (group.get("items") or []) for group in transactions) + + def test_connection_present(self): + add_project_tooling_connection() + frappe.clear_cache(doctype="Project") + self.assertTrue(self._tooling_links()) + self.assertTrue(self._tooling_in_connections()) + + def test_idempotent(self): + add_project_tooling_connection() + add_project_tooling_connection() # second call must not duplicate + self.assertEqual(len(self._tooling_links()), 1) diff --git a/npd_project_module/uninstall/before_uninstall.py b/npd_project_module/uninstall/before_uninstall.py index 6ed7ebb..ae9bf10 100644 --- a/npd_project_module/uninstall/before_uninstall.py +++ b/npd_project_module/uninstall/before_uninstall.py @@ -3,6 +3,8 @@ import frappe +from npd_project_module.install.after_install import PROJECT_TOOLING_LINK + def before_uninstall(): """ @@ -15,6 +17,7 @@ def before_uninstall(): remove_npd_template() remove_custom_report() remove_tooling_setup() + remove_project_tooling_connection() remove_custom_doctype() frappe.db.commit() @@ -125,6 +128,29 @@ def remove_tooling_setup(): print(f" ✗ Error removing Item Group {item_group}: {e!s}") +def remove_project_tooling_connection(): + """ + Remove the Project → NPD Tooling connection from the Project form's Connections. + + Only the custom (custom=1) DocType Link rows this app creates are deleted; a + standard link that reached the site through its own fixtures or customizations is + left in place, since this app did not put it there. + """ + try: + links = frappe.get_all( + "DocType Link", + filters={**PROJECT_TOOLING_LINK, "custom": 1}, + pluck="name", + ) + for name in links: + frappe.delete_doc("DocType Link", name, force=True, ignore_permissions=True) + if links: + frappe.clear_cache(doctype="Project") + print(" ✓ Removed Project → NPD Tooling connection") + except Exception as e: + print(f" ✗ Error removing Project → NPD Tooling connection: {e!s}") + + def remove_custom_doctype(): """ Remove the Project Part Number child table doctype created by this module.