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
11 changes: 11 additions & 0 deletions npd_project_module/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------

Expand Down Expand Up @@ -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
#
Expand Down
41 changes: 41 additions & 0 deletions npd_project_module/install/after_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand All @@ -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")

Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions npd_project_module/install/after_migrate.py
Original file line number Diff line number Diff line change
@@ -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()
50 changes: 48 additions & 2 deletions npd_project_module/tests/test_npd_tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@
# 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
against linked Sales Invoices with manual advance allocations (for receipts not yet on an
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,
Expand Down Expand Up @@ -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)
26 changes: 26 additions & 0 deletions npd_project_module/uninstall/before_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import frappe

from npd_project_module.install.after_install import PROJECT_TOOLING_LINK


def before_uninstall():
"""
Expand All @@ -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()

Expand Down Expand Up @@ -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.
Expand Down
Loading