From 864723530761a825675e21ec4cefec41790435f2 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 10:47:57 -0500 Subject: [PATCH 1/7] wip: quarantine fixes --- .pre-commit-config.yaml | 2 +- .../quarantine_quality_control_workflows.md | 57 ++- inventory_tools/hooks.py | 11 +- .../custom/subcontracting_receipt_item.json | 50 ++ .../inventory_tools/overrides/stock_entry.py | 77 ++- .../overrides/subcontracting_receipt.py | 43 ++ .../js/custom/quality_inspection_custom.js | 24 + inventory_tools/tests/setup.py | 17 + .../tests/test_quarantine_quality_control.py | 443 ++++++++++++++++-- 9 files changed, 643 insertions(+), 81 deletions(-) create mode 100644 inventory_tools/inventory_tools/custom/subcontracting_receipt_item.json create mode 100644 inventory_tools/public/js/custom/quality_inspection_custom.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59139a4..7ff70f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.18.0 + rev: v1.20.1 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/inventory_tools/docs/quarantine_quality_control_workflows.md b/inventory_tools/docs/quarantine_quality_control_workflows.md index b2d03c4..f0f83e9 100644 --- a/inventory_tools/docs/quarantine_quality_control_workflows.md +++ b/inventory_tools/docs/quarantine_quality_control_workflows.md @@ -18,7 +18,11 @@ The Quarantine Quality Control feature allows inventory to be automatically move ### 1. Enable the Feature -Navigate to **Inventory Tools Settings** and enable **Quarantine Quality Control**. +Navigate to **Inventory Tools Settings** and enable **Enable Quarantine Workflow**. + +Optionally, set a **Default Quarantine Warehouse** as a fallback for items whose Quality Inspection Template does not specify a quarantine location. + +To prevent stock from leaving a quarantine warehouse through any means other than an accepted Quality Inspection, also enable **Block Issue from Quarantine**. See [Blocking Manual Removals](#blocking-manual-removals) below. ### 2. Configure Quality Inspection Templates @@ -29,44 +33,63 @@ For each template requiring quarantine workflow: 3. Configure inspection parameters as needed 4. Save the template -### 3. Configure Items (Item Default) +### 3. Configure Items + +Quality Inspection Template assignment and inspection requirements are configured at two levels: -Inspection requirements are **company-scoped** via **Item Default**, not on the Item master. Item-level inspection fields are hidden. +- **Quality Inspection Template**: Set on the **Item** master. This determines which inspection parameters apply and which quarantine warehouse to use (overriding the default). +- **Inspection Required flags**: Set on **Item Default** (company-scoped). Item-level inspection fields are hidden. On **Item Default** for each company and item: -1. Assign the appropriate **Quality Inspection Template** (from the Item) -2. Check **Inspection Required Before Purchase** (for incoming materials) -3. Check **Inspection Required Before Delivery** (for finished goods) -4. Check **Inspection Required Before Manufacture** (for raw materials before manufacturing) +1. Check **Inspection Required Before Purchase** (for incoming materials via Purchase Receipt or Subcontracting Receipt) +2. Check **Inspection Required Before Delivery** (for outgoing goods) +3. Check **Inspection Required Before Manufacture** (for raw materials transferred to production) ## Workflow -### Receiving Items (Purchase) +### Receiving Items (Purchase Receipt) 1. Create and submit **Purchase Receipt** 2. System automatically routes items with `inspection_required_before_purchase` (Item Default) to the quarantine warehouse 3. Items remain in quarantine until inspection is complete +### Receiving Subcontracted Items (Subcontracting Receipt) + +Subcontracting Receipts follow the same routing logic as Purchase Receipts. Items with `inspection_required_before_purchase` set on their Item Default are automatically redirected to the quarantine warehouse on submit. + ### Material Transfer for Manufacture -For Stock Entry type **Material Transfer for Manufacture**, items with `inspection_required_before_manufacture` (Item Default) are routed to quarantine before use in production. +For Stock Entry type **Material Transfer for Manufacture**, items with `inspection_required_before_manufacture` (Item Default) are routed to the quarantine warehouse instead of the production warehouse. The original target warehouse is saved as `Intended Warehouse` on the Stock Entry row and used when releasing the stock after a passed inspection. ### Inspecting Items -1. Open the **Quality Inspection** record +1. Open the **Quality Inspection** record linked to the Purchase Receipt, Subcontracting Receipt, or Stock Entry 2. Record test results and observations -3. Mark as **Accepted** or **Rejected** +3. Set status to **Accepted** or **Rejected** and submit ### Releasing from Quarantine -1. On submit of Quality Inspection with status **Accepted**, system automatically creates **Stock Entry** (Material Transfer) -2. Source: Quarantine warehouse -3. Target: Final destination warehouse (from PR/SE `intended_warehouse`) -4. **Full quantity** is transferred (the received quantity), not the `sample_size` -5. Stock Entry company matches the reference document's company +Once a Quality Inspection has been submitted with status **Accepted**, a **Release from Quarantine** button appears on the form. Clicking it creates a draft **Stock Entry** (Material Transfer) for review: + +- **Source**: The quarantine warehouse (determined from the originating Stock Ledger Entry) +- **Target**: The final destination warehouse (the `intended_warehouse` recorded on the reference document's item row) +- **Quantity**: Full received/transferred quantity — not the inspection `sample_size` +- **Company**: Inherited from the reference document + +Review the draft Stock Entry and submit it to complete the transfer. The draft carries the Quality Inspection as a reference on each item row, which is how **Block Issue from Quarantine** (see below) knows to allow the transfer through. + +If no `intended_warehouse` is recorded on the reference document (e.g. stock was moved to quarantine manually without going through the standard receipt workflow), clicking the button will raise an error. Create the transfer from quarantine manually in that case. + +## Blocking Manual Removals + +Enable **Block Issue from Quarantine** in **Inventory Tools Settings** to prevent stock from being manually removed from any configured quarantine warehouse outside of the QI-driven release workflow. + +When this setting is active, any Stock Entry submission where the source warehouse is a quarantine warehouse will be blocked — **unless** the Stock Entry row carries a Quality Inspection reference (i.e. it was auto-generated by an accepted QI). This allows the system-created release transfers to proceed while blocking ad-hoc issues and transfers. -**Note**: Items cannot be removed from quarantine if inspection is pending or rejected. +Quarantine warehouses are identified by comparing against: +- The **Default Quarantine Warehouse** on Inventory Tools Settings +- The **Quarantine Warehouse** field on any Quality Inspection Template ## Use Cases diff --git a/inventory_tools/hooks.py b/inventory_tools/hooks.py index 0dda94d..b65aa2f 100644 --- a/inventory_tools/hooks.py +++ b/inventory_tools/hooks.py @@ -48,6 +48,7 @@ "Pick List": "public/js/custom/pick_list_custom.js", "Purchase Invoice": "public/js/custom/purchase_invoice_custom.js", "Purchase Order": "public/js/custom/purchase_order_custom.js", + "Quality Inspection": "public/js/custom/quality_inspection_custom.js", "Stock Entry": "public/js/custom/stock_entry_custom.js", "Work Order": "public/js/custom/work_order_custom.js", "Workstation": "public/js/custom/workstation_custom.js", @@ -179,17 +180,21 @@ "inventory_tools.inventory_tools.overrides.purchase_receipt.handle_pr_quarantine", ], }, - "Quality Inspection": { - "on_submit": ["inventory_tools.inventory_tools.overrides.stock_entry.release_from_quarantine"], - }, + "Quality Inspection": {}, "Stock Entry": { "before_submit": [ "inventory_tools.inventory_tools.overrides.stock_entry.handle_se_quarantine", + "inventory_tools.inventory_tools.overrides.stock_entry.validate_block_issue_from_quarantine", ], "on_submit": [ "inventory_tools.cartonization.run_cartonization", ], }, + "Subcontracting Receipt": { + "before_submit": [ + "inventory_tools.inventory_tools.overrides.subcontracting_receipt.handle_scr_quarantine", + ], + }, "Warehouse": { "validate": ["inventory_tools.inventory_tools.overrides.warehouse.update_warehouse_path"] }, diff --git a/inventory_tools/inventory_tools/custom/subcontracting_receipt_item.json b/inventory_tools/inventory_tools/custom/subcontracting_receipt_item.json new file mode 100644 index 0000000..fcfc445 --- /dev/null +++ b/inventory_tools/inventory_tools/custom/subcontracting_receipt_item.json @@ -0,0 +1,50 @@ +{ + "custom_fields": [ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "dt": "Subcontracting Receipt Item", + "fetch_if_empty": 0, + "fieldname": "intended_warehouse", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "warehouse", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Intended Warehouse", + "length": 0, + "module": "Inventory Tools", + "name": "Subcontracting Receipt Item-intended_warehouse", + "no_copy": 0, + "non_negative": 0, + "options": "Warehouse", + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0 + } + ], + "doctype": "Subcontracting Receipt Item", + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/inventory_tools/inventory_tools/overrides/stock_entry.py b/inventory_tools/inventory_tools/overrides/stock_entry.py index e1e62d7..65184d6 100644 --- a/inventory_tools/inventory_tools/overrides/stock_entry.py +++ b/inventory_tools/inventory_tools/overrides/stock_entry.py @@ -349,43 +349,104 @@ def get_production_item_if_work_orders_for_required_item_exists(stock_entry_name return "" -def release_from_quarantine(doc, method): - if doc.status != "Accepted": +def _get_quarantine_warehouses(company): + """Return all configured quarantine warehouses visible to a company.""" + settings = frappe.get_cached_doc("Inventory Tools Settings", company) + warehouses = set() + if settings.default_quarantine_warehouse: + warehouses.add(settings.default_quarantine_warehouse) + template_whs = frappe.get_all( + "Quality Inspection Template", + filters={"quarantine_warehouse": ["!=", ""]}, + pluck="quarantine_warehouse", + ) + warehouses.update(w for w in template_whs if w) + return warehouses + + +def validate_block_issue_from_quarantine(doc, method): + """Block manual stock issues from quarantine warehouses when the setting is enabled.""" + settings = frappe.get_doc("Inventory Tools Settings", doc.company) + if not settings.block_issue_from_quarantine: return - if not doc.reference_type or not doc.reference_name: + quarantine_warehouses = _get_quarantine_warehouses(doc.company) + if not quarantine_warehouses: return + for row in doc.items: + if row.get("s_warehouse") in quarantine_warehouses: + # Allow transfers created via make_quarantine_release_stock_entry (carry a QI reference) + if row.get("reference_doctype") == "Quality Inspection" and row.get("reference_name"): + continue + frappe.throw( + frappe._( + "Cannot issue stock directly from Quarantine Warehouse {0}. " + "Release inventory via an accepted Quality Inspection." + ).format(row.s_warehouse) + ) + + +@frappe.whitelist() +def make_quarantine_release_stock_entry(quality_inspection_name): + """Create a draft Material Transfer to release stock from quarantine. + + Called from the Quality Inspection form button after the QI is accepted. + Returns the new Stock Entry name so the browser can open it for review. + """ + doc = frappe.get_doc("Quality Inspection", quality_inspection_name) + + if doc.status != "Accepted" or doc.docstatus != 1: + frappe.throw( + frappe._("Quality Inspection must be submitted and Accepted before releasing from quarantine.") + ) + + if not doc.reference_type or not doc.reference_name: + frappe.throw(frappe._("Quality Inspection has no reference document.")) + ref_doc = frappe.get_doc(doc.reference_type, doc.reference_name) settings = frappe.get_doc("Inventory Tools Settings", ref_doc.company) + if not settings.enable_quarantine_workflow: - return + frappe.throw(frappe._("Quarantine workflow is not enabled for {0}.").format(ref_doc.company)) target_wh = None - for row in ref_doc.items: if row.item_code == doc.item_code: target_wh = row.intended_warehouse break if not target_wh: - frappe.throw("Target warehouse not found for release") + frappe.throw( + frappe._( + "No intended warehouse found on {0} {1} for item {2}. " + "Please create the transfer from quarantine manually." + ).format(doc.reference_type, doc.reference_name, doc.item_code) + ) # Use full quantity from reference doc, not sample_size (inspection sample) release_qty = sum(flt(row.qty) for row in ref_doc.items if row.item_code == doc.item_code) + # actual_qty > 0 selects the warehouse where stock ARRIVED (the quarantine warehouse), + # not the source warehouse that stock LEFT (which has a negative actual_qty entry). quarantine_wh = frappe.db.get_value( "Stock Ledger Entry", { "voucher_type": doc.reference_type, "voucher_no": doc.reference_name, "item_code": doc.item_code, + "actual_qty": [">", 0], }, "warehouse", ) if not quarantine_wh: - frappe.throw("Quarantine warehouse not found in Stock Ledger") + frappe.throw( + frappe._( + "Originating quarantine warehouse could not be found in the Stock Ledger " + "for {0} {1}. Please create the transfer from quarantine manually." + ).format(doc.reference_type, doc.reference_name) + ) se = frappe.new_doc("Stock Entry") se.stock_entry_type = "Material Transfer" @@ -404,7 +465,7 @@ def release_from_quarantine(doc, method): ) se.save() - se.submit() + return se.name def handle_se_quarantine(doc, method): diff --git a/inventory_tools/inventory_tools/overrides/subcontracting_receipt.py b/inventory_tools/inventory_tools/overrides/subcontracting_receipt.py index 37b9baa..a0b8bd6 100644 --- a/inventory_tools/inventory_tools/overrides/subcontracting_receipt.py +++ b/inventory_tools/inventory_tools/overrides/subcontracting_receipt.py @@ -1,15 +1,58 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt +import frappe from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( SubcontractingReceipt, ) from inventory_tools.inventory_tools.overrides.inspection import ( + get_inspection_required, validate_inspection_with_company_scope, ) class InventoryToolsSubcontractingReceipt(SubcontractingReceipt): + def validate_qi_presence(self, row): + settings = frappe.get_doc("Inventory Tools Settings", self.company) + if settings.enable_quarantine_workflow: + return + super().validate_qi_presence(row) + + def validate_qi_submission(self, row): + settings = frappe.get_doc("Inventory Tools Settings", self.company) + if settings.enable_quarantine_workflow: + return + super().validate_qi_submission(row) + def validate_inspection(self): validate_inspection_with_company_scope(self) + + +def handle_scr_quarantine(doc, method): + settings = frappe.get_doc("Inventory Tools Settings", doc.company) + + if not settings.enable_quarantine_workflow: + return + + for row in doc.items: + if get_inspection_required(row.item_code, doc.company, "inspection_required_before_purchase"): + if not row.intended_warehouse: + row.intended_warehouse = row.warehouse + + qi_template = frappe.db.get_value("Item", row.item_code, "quality_inspection_template") + + quarantine_wh = None + + if qi_template: + quarantine_wh = frappe.db.get_value( + "Quality Inspection Template", qi_template, "quarantine_warehouse" + ) + + quarantine_wh = quarantine_wh or settings.default_quarantine_warehouse + + if not quarantine_wh: + frappe.throw(f"No Quarantine Warehouse configured for Item {row.item_code}") + + row.warehouse = quarantine_wh + row.quality_inspection = None diff --git a/inventory_tools/public/js/custom/quality_inspection_custom.js b/inventory_tools/public/js/custom/quality_inspection_custom.js new file mode 100644 index 0000000..e0919fe --- /dev/null +++ b/inventory_tools/public/js/custom/quality_inspection_custom.js @@ -0,0 +1,24 @@ +// Copyright (c) 2026, AgriTheory and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Quality Inspection', { + refresh: frm => { + if (frm.doc.docstatus !== 1 || frm.doc.status !== 'Accepted') { + return + } + frappe.db.get_value('Inventory Tools Settings', frm.doc.company || '', 'enable_quarantine_workflow').then(r => { + if (!r || !r.message || !r.message.enable_quarantine_workflow) { + return + } + frm.add_custom_button(__('Release from Quarantine'), () => { + frappe + .xcall('inventory_tools.inventory_tools.overrides.stock_entry.make_quarantine_release_stock_entry', { + quality_inspection_name: frm.doc.name, + }) + .then(se_name => { + frappe.set_route('Form', 'Stock Entry', se_name) + }) + }) + }) + }, +}) diff --git a/inventory_tools/tests/setup.py b/inventory_tools/tests/setup.py index 338e2c9..2b66870 100644 --- a/inventory_tools/tests/setup.py +++ b/inventory_tools/tests/setup.py @@ -726,6 +726,23 @@ def create_quarantine_quality_control_data(settings): ) bayberry.save() + flour = frappe.get_doc("Item", "Flour") + flour.quality_inspection_template = "Ingredient QC" + flour.save() + apc_default = next((d for d in flour.item_defaults if d.company == "Ambrosia Pie Company"), None) + if apc_default: + apc_default.inspection_required_before_manufacture = 1 + else: + flour.append( + "item_defaults", + { + "company": "Ambrosia Pie Company", + "default_warehouse": "Storeroom - APC", + "inspection_required_before_manufacture": 1, + }, + ) + flour.save() + for company, wh in [ ("Chelsea Fruit Co", "Quarantine - CFC"), (settings.company, "Quarantine - APC"), diff --git a/inventory_tools/tests/test_quarantine_quality_control.py b/inventory_tools/tests/test_quarantine_quality_control.py index 9b12b53..2831ae5 100644 --- a/inventory_tools/tests/test_quarantine_quality_control.py +++ b/inventory_tools/tests/test_quarantine_quality_control.py @@ -3,10 +3,16 @@ import frappe import pytest -from frappe.utils import getdate +from frappe.utils import flt, getdate from erpnext.controllers.stock_controller import QualityInspectionRequiredError +from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt +from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from inventory_tools.tests.setup import create_quarantine_quality_control_data +from inventory_tools.inventory_tools.overrides.stock_entry import ( + make_quarantine_release_stock_entry, +) @pytest.fixture(scope="module", autouse=True) @@ -16,10 +22,6 @@ def quarantine_qc_data(): create_quarantine_quality_control_data(settings) -from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt -from erpnext.stock.doctype.material_request.material_request import make_purchase_order - - def create_bayberry_po_pr(submit_pr=True): """Create MR -> PO -> PR for Bayberry. Returns (po, pr).""" mr = frappe.new_doc("Material Request") @@ -112,30 +114,18 @@ def test_release_from_quarantine_on_quality_inspection_accept(): qa = create_quality_inspection(pr.name, sample_size=5, status="Accepted") qa.submit() - # reference_doctype is on Stock Entry Detail (child), not Stock Entry - parent_names = frappe.get_all( - "Stock Entry Detail", - filters={ - "reference_doctype": "Quality Inspection", - "reference_name": qa.name, - }, - pluck="parent", - ) - transfers = [ - n - for n in set(parent_names) - if frappe.db.get_value("Stock Entry", n, "stock_entry_type") == "Material Transfer" - ] - assert len(transfers) == 1 + # Button calls make_quarantine_release_stock_entry; verify draft SE is created correctly + se_name = make_quarantine_release_stock_entry(qa.name) + se = frappe.get_doc("Stock Entry", se_name) - se = frappe.get_doc("Stock Entry", transfers[0]) + assert se.docstatus == 0 # Draft — user must review and submit assert se.items[0].s_warehouse == "Quarantine - CFC" assert se.items[0].t_warehouse == "Stores - CFC" assert se.items[0].qty == full_qty # full PR qty, not sample_size @pytest.mark.order(14) -def test_release_from_quarantine_skipped_when_not_accepted(): +def test_release_from_quarantine_blocked_when_not_accepted(): settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") settings.enable_quarantine_workflow = 1 settings.save() @@ -145,20 +135,9 @@ def test_release_from_quarantine_skipped_when_not_accepted(): qa = create_quality_inspection(pr.name, status="Rejected") qa.submit() - parent_names = frappe.get_all( - "Stock Entry Detail", - filters={ - "reference_doctype": "Quality Inspection", - "reference_name": qa.name, - }, - pluck="parent", - ) - transfers = [ - n - for n in set(parent_names or []) - if frappe.db.get_value("Stock Entry", n, "stock_entry_type") == "Material Transfer" - ] - assert len(transfers) == 0 + # Button should raise when QI is not Accepted + with pytest.raises(frappe.ValidationError): + make_quarantine_release_stock_entry(qa.name) qty_in_quarantine = frappe.db.sql( """ @@ -171,7 +150,7 @@ def test_release_from_quarantine_skipped_when_not_accepted(): @pytest.mark.order(15) -def test_release_from_quarantine_skipped_when_workflow_disabled(): +def test_release_from_quarantine_blocked_when_workflow_disabled(): settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") settings.enable_quarantine_workflow = 1 settings.save() @@ -179,25 +158,17 @@ def test_release_from_quarantine_skipped_when_workflow_disabled(): _po, pr = create_bayberry_po_pr(submit_pr=True) qa = create_quality_inspection(pr.name, status="Accepted") + qa.submit() + settings.enable_quarantine_workflow = 0 settings.save() - qa.submit() + # Button should raise when quarantine workflow is disabled + with pytest.raises(frappe.ValidationError): + make_quarantine_release_stock_entry(qa.name) - parent_names = frappe.get_all( - "Stock Entry Detail", - filters={ - "reference_doctype": "Quality Inspection", - "reference_name": qa.name, - }, - pluck="parent", - ) - transfers = [ - n - for n in set(parent_names or []) - if frappe.db.get_value("Stock Entry", n, "stock_entry_type") == "Material Transfer" - ] - assert len(transfers) == 0 + settings.enable_quarantine_workflow = 1 + settings.save() @pytest.mark.order(16) @@ -277,3 +248,371 @@ def test_missing_quarantine_warehouse_throws(): cocoplum.save() settings.default_quarantine_warehouse = "Quarantine - CFC" settings.save() + + +# --------------------------------------------------------------------------- +# Helpers — APC manufacture path +# --------------------------------------------------------------------------- + + +def create_flour_manufacture_se(submit_se=True): + """Create Material Transfer for Manufacture SE for Flour in APC. + + Returns an unsaved in-memory doc when submit_se=False (mirrors create_bayberry_po_pr). + """ + se = frappe.new_doc("Stock Entry") + se.company = "Ambrosia Pie Company" + se.stock_entry_type = "Material Transfer for Manufacture" + se.append( + "items", + { + "item_code": "Flour", + "qty": 10, + "s_warehouse": "Storeroom - APC", + "t_warehouse": "Kitchen - APC", + "uom": "Pound", + }, + ) + if submit_se: + se.submit() + return se + + +def create_se_quality_inspection(se_name, item_code="Flour", sample_size=1, status="Accepted"): + """Create QI for a Stock Entry reference (manufacture path).""" + qa = frappe.new_doc("Quality Inspection") + qa.report_date = getdate() + qa.inspection_type = "In Process" + qa.reference_type = "Stock Entry" + qa.reference_name = se_name + qa.item_code = item_code + qa.sample_size = sample_size + qa.quality_inspection_template = "Ingredient QC" + qa.inspected_by = frappe.session.user + qa.status = status + reading_val = "999" if status == "Rejected" else "50" + qa.append( + "readings", + {"specification": "Weight", "min_value": 0, "max_value": 100, "reading_1": reading_val}, + ) + qa.save() + return qa + + +def get_release_transfers_for_qi(qi_name): + """Return Material Transfer SE names created by make_quarantine_release_stock_entry for a QI.""" + parent_names = frappe.get_all( + "Stock Entry Detail", + filters={"reference_doctype": "Quality Inspection", "reference_name": qi_name}, + pluck="parent", + ) + return [ + n + for n in set(parent_names) + if frappe.db.get_value("Stock Entry", n, "stock_entry_type") == "Material Transfer" + ] + + +# --------------------------------------------------------------------------- +# Tests — manufacture (SE) quarantine path +# --------------------------------------------------------------------------- + + +@pytest.mark.order(17) +def test_manufacture_se_routes_to_quarantine(): + apc_settings = frappe.get_doc("Inventory Tools Settings", "Ambrosia Pie Company") + apc_settings.enable_quarantine_workflow = 1 + apc_settings.save() + + se = create_flour_manufacture_se(submit_se=True) + + flour_row = next(r for r in se.items if r.item_code == "Flour") + assert flour_row.t_warehouse == "Quarantine - APC" + assert flour_row.intended_warehouse == "Kitchen - APC" + + sle_wh = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Stock Entry", + "voucher_no": se.name, + "item_code": "Flour", + "actual_qty": [">", 0], + }, + "warehouse", + ) + assert sle_wh == "Quarantine - APC" + + +@pytest.mark.order(18) +def test_manufacture_se_bypasses_quarantine_when_disabled(): + apc_settings = frappe.get_doc("Inventory Tools Settings", "Ambrosia Pie Company") + apc_settings.enable_quarantine_workflow = 0 + apc_settings.save() + + se = create_flour_manufacture_se(submit_se=False) + + with pytest.raises((QualityInspectionRequiredError, frappe.ValidationError)): + se.submit() + + +@pytest.mark.order(19) +def test_release_from_quarantine_on_se_accepted_qi(): + apc_settings = frappe.get_doc("Inventory Tools Settings", "Ambrosia Pie Company") + apc_settings.enable_quarantine_workflow = 1 + apc_settings.save() + + se = create_flour_manufacture_se(submit_se=True) + flour_row = next(r for r in se.items if r.item_code == "Flour") + full_qty = flour_row.qty + + qa = create_se_quality_inspection(se.name, status="Accepted") + qa.submit() + + release_se_name = make_quarantine_release_stock_entry(qa.name) + release_se = frappe.get_doc("Stock Entry", release_se_name) + + assert release_se.docstatus == 0 # Draft for user review + assert release_se.items[0].s_warehouse == "Quarantine - APC" + assert release_se.items[0].t_warehouse == "Kitchen - APC" + assert release_se.items[0].qty == full_qty + + +@pytest.mark.order(20) +def test_release_from_quarantine_blocked_on_se_rejected_qi(): + apc_settings = frappe.get_doc("Inventory Tools Settings", "Ambrosia Pie Company") + apc_settings.enable_quarantine_workflow = 1 + apc_settings.save() + + se = create_flour_manufacture_se(submit_se=True) + + qa = create_se_quality_inspection(se.name, status="Rejected") + qa.submit() + + with pytest.raises(frappe.ValidationError): + make_quarantine_release_stock_entry(qa.name) + + qty_in_quarantine = frappe.db.sql( + """ + SELECT sum(actual_qty) FROM tabBin + WHERE warehouse = 'Quarantine - APC' AND item_code = 'Flour' + """, + as_dict=False, + )[0][0] + assert qty_in_quarantine > 0 + + +# --------------------------------------------------------------------------- +# Test — make_quarantine_release_stock_entry produces correct draft +# --------------------------------------------------------------------------- + + +@pytest.mark.order(21) +def test_release_se_is_draft_with_qi_reference(): + """Draft SE from the Release button carries QI reference so block_issue_from_quarantine allows it.""" + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + settings.enable_quarantine_workflow = 1 + settings.save() + + _po, pr = create_bayberry_po_pr(submit_pr=True) + + qa = create_quality_inspection(pr.name, status="Accepted") + qa.submit() + + se_name = make_quarantine_release_stock_entry(qa.name) + se = frappe.get_doc("Stock Entry", se_name) + + assert se.docstatus == 0 + assert se.items[0].reference_doctype == "Quality Inspection" + assert se.items[0].reference_name == qa.name + + +# --------------------------------------------------------------------------- +# Test — PR cancellation while items are in quarantine +# --------------------------------------------------------------------------- + + +@pytest.mark.order(22) +def test_cancel_pr_while_in_quarantine(): + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + settings.enable_quarantine_workflow = 1 + settings.save() + + _po, pr = create_bayberry_po_pr(submit_pr=True) + + bayberry_row = next(r for r in pr.items if r.item_code == "Bayberry") + assert bayberry_row.warehouse == "Quarantine - CFC" + received_qty = bayberry_row.qty + + qty_before = flt( + frappe.db.sql( + "SELECT sum(actual_qty) FROM `tabBin` WHERE warehouse=%s AND item_code=%s", + ("Quarantine - CFC", "Bayberry"), + as_dict=False, + )[0][0] + or 0 + ) + + pr.reload() + pr.cancel() + + qty_after = flt( + frappe.db.sql( + "SELECT sum(actual_qty) FROM `tabBin` WHERE warehouse=%s AND item_code=%s", + ("Quarantine - CFC", "Bayberry"), + as_dict=False, + )[0][0] + or 0 + ) + assert qty_after == qty_before - received_qty + + +# --------------------------------------------------------------------------- +# Tests — block_issue_from_quarantine +# --------------------------------------------------------------------------- + + +@pytest.mark.order(23) +def test_block_issue_from_quarantine_prevents_manual_se(): + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + settings.enable_quarantine_workflow = 1 + settings.block_issue_from_quarantine = 1 + settings.save() + + se = frappe.new_doc("Stock Entry") + se.company = "Chelsea Fruit Co" + se.stock_entry_type = "Material Issue" + se.append( + "items", + { + "item_code": "Bayberry", + "qty": 1, + "s_warehouse": "Quarantine - CFC", + "uom": "Pound", + "basic_rate": 1.0, + }, + ) + se.save() + + with pytest.raises(frappe.ValidationError): + se.submit() + + settings.block_issue_from_quarantine = 0 + settings.save() + + +@pytest.mark.order(24) +def test_block_issue_allows_qi_referenced_release(): + """block_issue_from_quarantine must not prevent submitting a release SE created by the button.""" + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + settings.enable_quarantine_workflow = 1 + settings.block_issue_from_quarantine = 1 + settings.save() + + _po, pr = create_bayberry_po_pr(submit_pr=True) + + qa = create_quality_inspection(pr.name, status="Accepted") + qa.submit() + + se_name = make_quarantine_release_stock_entry(qa.name) + se = frappe.get_doc("Stock Entry", se_name) + + # User reviews the draft, then submits — block_issue_from_quarantine must allow it through + se.submit() + assert frappe.db.get_value("Stock Entry", se_name, "docstatus") == 1 + + settings.block_issue_from_quarantine = 0 + settings.save() + + +# --------------------------------------------------------------------------- +# Test — multi-item PR with mixed quarantine/non-quarantine items +# --------------------------------------------------------------------------- + + +@pytest.mark.order(25) +def test_multi_item_pr_partial_quarantine_routing(): + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + settings.enable_quarantine_workflow = 1 + settings.save() + + # Cocoplum has no inspection_required_before_purchase for CFC after test 16 cleanup + mr = frappe.new_doc("Material Request") + mr.company = "Chelsea Fruit Co" + mr.material_request_type = "Purchase" + mr.transaction_date = getdate() + mr.schedule_date = getdate() + mr.append( + "items", {"item_code": "Bayberry", "qty": 50, "warehouse": "Stores - CFC", "uom": "Pound"} + ) + mr.append( + "items", {"item_code": "Cocoplum", "qty": 20, "warehouse": "Stores - CFC", "uom": "Pound"} + ) + mr.submit() + + po = make_purchase_order(mr.name) + po.supplier = "Southern Fruit Supply" + po.save() + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + bayberry_row = next(r for r in pr.items if r.item_code == "Bayberry") + cocoplum_row = next(r for r in pr.items if r.item_code == "Cocoplum") + + assert bayberry_row.warehouse == "Quarantine - CFC" + assert bayberry_row.intended_warehouse == "Stores - CFC" + + assert cocoplum_row.warehouse == "Stores - CFC" + assert not cocoplum_row.intended_warehouse + + +# --------------------------------------------------------------------------- +# Test — graceful degradation when intended_warehouse is absent +# --------------------------------------------------------------------------- + + +@pytest.mark.order(26) +def test_release_no_intended_warehouse_raises(): + """When intended_warehouse is absent on the reference doc, make_quarantine_release_stock_entry raises.""" + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + settings.enable_quarantine_workflow = 1 + settings.save() + + # Create a plain Material Transfer (not via handle_pr/se_quarantine) that lands Bayberry + # in the quarantine warehouse — no intended_warehouse is set on the row. + se = frappe.new_doc("Stock Entry") + se.company = "Chelsea Fruit Co" + se.stock_entry_type = "Material Transfer" + se.append( + "items", + { + "item_code": "Bayberry", + "qty": 5, + "s_warehouse": "Stores - CFC", + "t_warehouse": "Quarantine - CFC", + "uom": "Pound", + }, + ) + se.save() + se.submit() + + qa = frappe.new_doc("Quality Inspection") + qa.report_date = getdate() + qa.inspection_type = "In Process" + qa.reference_type = "Stock Entry" + qa.reference_name = se.name + qa.item_code = "Bayberry" + qa.sample_size = 1 + qa.quality_inspection_template = "Fruit QC" + qa.inspected_by = frappe.session.user + qa.status = "Accepted" + qa.append( + "readings", + {"specification": "Weight", "min_value": 0, "max_value": 100, "reading_1": "50"}, + ) + qa.save() + qa.submit() + + with pytest.raises(frappe.ValidationError): + make_quarantine_release_stock_entry(qa.name) From dd2c3f879074e7cd7cf23e45bff7c8ecfb279682 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 14:18:03 -0500 Subject: [PATCH 2/7] fix: fix quarantine handlers, update docs --- inventory_tools/docs/alternate_workstation.md | 2 +- inventory_tools/docs/cartonization.md | 2 +- inventory_tools/docs/exampledata.md | 2 +- inventory_tools/docs/faceted_search.md | 2 +- inventory_tools/docs/index.md | 2 +- inventory_tools/docs/landed_costing.md | 2 +- .../docs/manufacturing_capacity.md | 2 +- inventory_tools/docs/material_demand.md | 2 +- .../docs/multi_company_sales_order.md | 2 +- .../docs/overproduction_allowance.md | 2 +- .../quarantine_quality_control_workflows.md | 4 +- inventory_tools/docs/quotation_demand.md | 2 +- inventory_tools/docs/warehouse_path.md | 2 +- inventory_tools/docs/warehouse_plan.md | 2 +- inventory_tools/docs/wo_subcontracting.md | 2 +- .../docs/work_order_subcontracting.md | 2 +- .../docs/workstation_operating_cost.md | 2 +- inventory_tools/tests/setup.py | 92 ++++--------------- .../tests/test_quarantine_quality_control.py | 41 ++++++++- 19 files changed, 74 insertions(+), 95 deletions(-) diff --git a/inventory_tools/docs/alternate_workstation.md b/inventory_tools/docs/alternate_workstation.md index 6f7037c..520553d 100644 --- a/inventory_tools/docs/alternate_workstation.md +++ b/inventory_tools/docs/alternate_workstation.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Alternative Workstation Functionality diff --git a/inventory_tools/docs/cartonization.md b/inventory_tools/docs/cartonization.md index e030688..6439d87 100644 --- a/inventory_tools/docs/cartonization.md +++ b/inventory_tools/docs/cartonization.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Cartonization Configuration & Validation Guide diff --git a/inventory_tools/docs/exampledata.md b/inventory_tools/docs/exampledata.md index f963e33..c4fdbe1 100644 --- a/inventory_tools/docs/exampledata.md +++ b/inventory_tools/docs/exampledata.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Using the Example Data to Experiment with Inventory Tools diff --git a/inventory_tools/docs/faceted_search.md b/inventory_tools/docs/faceted_search.md index 0152ee3..b9f175a 100644 --- a/inventory_tools/docs/faceted_search.md +++ b/inventory_tools/docs/faceted_search.md @@ -5,7 +5,7 @@ For license information, please see license.txt--> # Faceted Search diff --git a/inventory_tools/docs/index.md b/inventory_tools/docs/index.md index ecb723a..53cce80 100644 --- a/inventory_tools/docs/index.md +++ b/inventory_tools/docs/index.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Inventory Tools Documentation diff --git a/inventory_tools/docs/landed_costing.md b/inventory_tools/docs/landed_costing.md index 26aad15..9729dd4 100644 --- a/inventory_tools/docs/landed_costing.md +++ b/inventory_tools/docs/landed_costing.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Inline Landed Costing diff --git a/inventory_tools/docs/manufacturing_capacity.md b/inventory_tools/docs/manufacturing_capacity.md index 4e05437..d01b982 100644 --- a/inventory_tools/docs/manufacturing_capacity.md +++ b/inventory_tools/docs/manufacturing_capacity.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Manufacturing Capacity Report diff --git a/inventory_tools/docs/material_demand.md b/inventory_tools/docs/material_demand.md index 236487b..e53714c 100644 --- a/inventory_tools/docs/material_demand.md +++ b/inventory_tools/docs/material_demand.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Material Demand diff --git a/inventory_tools/docs/multi_company_sales_order.md b/inventory_tools/docs/multi_company_sales_order.md index 6961c99..48891a2 100644 --- a/inventory_tools/docs/multi_company_sales_order.md +++ b/inventory_tools/docs/multi_company_sales_order.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Multi-Company Sales Order diff --git a/inventory_tools/docs/overproduction_allowance.md b/inventory_tools/docs/overproduction_allowance.md index 2aee0bc..48977e9 100644 --- a/inventory_tools/docs/overproduction_allowance.md +++ b/inventory_tools/docs/overproduction_allowance.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Overproduction Allowance diff --git a/inventory_tools/docs/quarantine_quality_control_workflows.md b/inventory_tools/docs/quarantine_quality_control_workflows.md index f0f83e9..8e51f94 100644 --- a/inventory_tools/docs/quarantine_quality_control_workflows.md +++ b/inventory_tools/docs/quarantine_quality_control_workflows.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Quarantine Quality Control @@ -85,7 +85,7 @@ If no `intended_warehouse` is recorded on the reference document (e.g. stock was Enable **Block Issue from Quarantine** in **Inventory Tools Settings** to prevent stock from being manually removed from any configured quarantine warehouse outside of the QI-driven release workflow. -When this setting is active, any Stock Entry submission where the source warehouse is a quarantine warehouse will be blocked — **unless** the Stock Entry row carries a Quality Inspection reference (i.e. it was auto-generated by an accepted QI). This allows the system-created release transfers to proceed while blocking ad-hoc issues and transfers. +When this setting is active, any Stock Entry submission where the source warehouse is a quarantine warehouse will be blocked — **unless** the Stock Entry row carries a Quality Inspection reference (i.e. it was created via the **Release from Quarantine** button on an accepted QI). This allows those release transfers to proceed while blocking ad-hoc issues and transfers. Quarantine warehouses are identified by comparing against: - The **Default Quarantine Warehouse** on Inventory Tools Settings diff --git a/inventory_tools/docs/quotation_demand.md b/inventory_tools/docs/quotation_demand.md index 217556f..100abcb 100644 --- a/inventory_tools/docs/quotation_demand.md +++ b/inventory_tools/docs/quotation_demand.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Quotation Demand diff --git a/inventory_tools/docs/warehouse_path.md b/inventory_tools/docs/warehouse_path.md index 281d32d..d4d647a 100644 --- a/inventory_tools/docs/warehouse_path.md +++ b/inventory_tools/docs/warehouse_path.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Warehouse Path ERPNext allows its user to construct hierarchial abstractions for their physical facilities. This can make it difficult to know when you are selecting a Warehouse if it is "Bin A" in the "Storage Closet" or if is "Bin A" from the "Repair Supplies" Warehouse. diff --git a/inventory_tools/docs/warehouse_plan.md b/inventory_tools/docs/warehouse_plan.md index 952154f..6f4fa7a 100644 --- a/inventory_tools/docs/warehouse_plan.md +++ b/inventory_tools/docs/warehouse_plan.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Warehouse Plan diff --git a/inventory_tools/docs/wo_subcontracting.md b/inventory_tools/docs/wo_subcontracting.md index a7e6d8a..f8869c2 100644 --- a/inventory_tools/docs/wo_subcontracting.md +++ b/inventory_tools/docs/wo_subcontracting.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Subcontracting Workflow via Work Order diff --git a/inventory_tools/docs/work_order_subcontracting.md b/inventory_tools/docs/work_order_subcontracting.md index 27e6aed..d3b1e5c 100644 --- a/inventory_tools/docs/work_order_subcontracting.md +++ b/inventory_tools/docs/work_order_subcontracting.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Work Order Subcontracting diff --git a/inventory_tools/docs/workstation_operating_cost.md b/inventory_tools/docs/workstation_operating_cost.md index c3203fb..6e0dfe9 100644 --- a/inventory_tools/docs/workstation_operating_cost.md +++ b/inventory_tools/docs/workstation_operating_cost.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Workstation Operating Cost diff --git a/inventory_tools/tests/setup.py b/inventory_tools/tests/setup.py index 2b66870..6ccc2b9 100644 --- a/inventory_tools/tests/setup.py +++ b/inventory_tools/tests/setup.py @@ -622,66 +622,15 @@ def create_warehouse_locations(): warehouse.save() -def _get_or_create_quarantine_account(company): - """Dedicated quarantine Stock account per company (create if missing).""" - accounts = frappe.get_all( - "Account", - filters={"company": company, "account_type": "Stock", "is_group": 0}, - or_filters=[ - ["account_name", "like", "Quarantine%"], - ["name", "like", "%Quarantine%"], # numbered chart: "1430 - Quarantine - APC" - ], - pluck="name", - limit=1, - ) - if accounts: - return accounts[0] - parent = frappe.get_value( - "Account", - {"company": company, "account_type": "Stock", "is_group": 1}, - "name", - ) - if not parent: - return None - a = frappe.new_doc("Account") - a.account_name = "Quarantine" - a.account_number = "1430" - a.is_group = 0 - a.company = company - a.root_type = "Asset" - a.report_type = "Balance Sheet" - a.account_currency = frappe.get_value("Company", company, "default_currency") - a.parent_account = parent - a.account_type = "Stock" - a.insert() - return a.name - - -def _link_quarantine_warehouses_to_account(settings): - """Set dedicated quarantine account on warehouses (required for Stock Entry GL entries).""" - for wh_name, company in [ - ("Quarantine - APC", settings.company), - ("Quarantine - CFC", "Chelsea Fruit Co"), - ]: - if not frappe.db.exists("Warehouse", wh_name): - continue - account = _get_or_create_quarantine_account(company) - if account: - wh = frappe.get_doc("Warehouse", wh_name) - wh.account = account - wh.save() - - def create_quarantine_quality_control_data(settings): """Quarantine warehouses, QC templates, item config for quarantine quality control tests.""" - # Quarantine warehouses (test_utils creates warehouse; we ensure dedicated account is linked) for company in [settings.company, "Chelsea Fruit Co"]: - create_quarantine_warehouse( - settings=frappe._dict({"company": company}), - wh_name="Quarantine", - is_default_scrap_wh=False, - ) - _link_quarantine_warehouses_to_account(settings) + if not frappe.db.get_value("Inventory Tools Settings", company, "default_quarantine_warehouse"): + create_quarantine_warehouse( + settings=frappe._dict({"company": company}), + wh_name="Quarantine", + is_default_scrap_wh=False, + ) if not frappe.db.exists("Quality Inspection Parameter", "Weight"): frappe.get_doc( @@ -726,23 +675,6 @@ def create_quarantine_quality_control_data(settings): ) bayberry.save() - flour = frappe.get_doc("Item", "Flour") - flour.quality_inspection_template = "Ingredient QC" - flour.save() - apc_default = next((d for d in flour.item_defaults if d.company == "Ambrosia Pie Company"), None) - if apc_default: - apc_default.inspection_required_before_manufacture = 1 - else: - flour.append( - "item_defaults", - { - "company": "Ambrosia Pie Company", - "default_warehouse": "Storeroom - APC", - "inspection_required_before_manufacture": 1, - }, - ) - flour.save() - for company, wh in [ ("Chelsea Fruit Co", "Quarantine - CFC"), (settings.company, "Quarantine - APC"), @@ -752,7 +684,6 @@ def create_quarantine_quality_control_data(settings): settings_doc.enable_quarantine_workflow = 0 settings_doc.save() - _link_quarantine_warehouses_to_account(settings) frappe.db.commit() receive_qc_workflow() @@ -779,7 +710,11 @@ def receive_qc_workflow(): return mr = frappe.get_doc("Material Request", fruit_mr_name) bayberry_row = next((r for r in mr.items if r.item_code == "Bayberry"), None) - if not bayberry_row or bayberry_row.received_qty >= bayberry_row.stock_qty: + if ( + not bayberry_row + or bayberry_row.received_qty >= bayberry_row.stock_qty + or bayberry_row.ordered_qty >= bayberry_row.stock_qty + ): return # PO for Bayberry only (minimal change vs material demand tests) @@ -787,6 +722,11 @@ def receive_qc_workflow(): fruit_mr_name, target_doc=None, args={"filtered_children": [bayberry_mri]} ) po.supplier = "Southern Fruit Supply" + po.buying_price_list = "Bakery Buying" + + if not po.items: + return + po.save() po.submit() diff --git a/inventory_tools/tests/test_quarantine_quality_control.py b/inventory_tools/tests/test_quarantine_quality_control.py index 2831ae5..36d0f7b 100644 --- a/inventory_tools/tests/test_quarantine_quality_control.py +++ b/inventory_tools/tests/test_quarantine_quality_control.py @@ -17,10 +17,47 @@ @pytest.fixture(scope="module", autouse=True) def quarantine_qc_data(): - """Install quarantine warehouses, QC templates, and receive workflow (fixtured in QC tests only).""" + """Install quarantine warehouses, QC templates, and receive workflow (fixtured in QC tests only). + + Flour's manufacture-inspection configuration is scoped here (not in setup.py) so it doesn't + bleed into other test modules (test_wo_subcontracting, test_operating_costs, etc.). + """ settings = frappe._dict({"company": "Ambrosia Pie Company"}) create_quarantine_quality_control_data(settings) + # Configure Flour for manufacture inspection — scoped to this module only + flour = frappe.get_doc("Item", "Flour") + original_qi_template = flour.quality_inspection_template + flour.quality_inspection_template = "Ingredient QC" + flour.save() + apc_default = next((d for d in flour.item_defaults if d.company == "Ambrosia Pie Company"), None) + original_manufacture_flag = ( + apc_default.inspection_required_before_manufacture if apc_default else 0 + ) + if apc_default: + apc_default.inspection_required_before_manufacture = 1 + else: + flour.append( + "item_defaults", + { + "company": "Ambrosia Pie Company", + "default_warehouse": "Storeroom - APC", + "inspection_required_before_manufacture": 1, + }, + ) + flour.save() + + yield + + # Restore Flour to its pre-test state so other modules are not affected + flour.reload() + flour.quality_inspection_template = original_qi_template + apc_default = next((d for d in flour.item_defaults if d.company == "Ambrosia Pie Company"), None) + if apc_default: + apc_default.inspection_required_before_manufacture = original_manufacture_flag + flour.save() + frappe.db.commit() + def create_bayberry_po_pr(submit_pr=True): """Create MR -> PO -> PR for Bayberry. Returns (po, pr).""" @@ -36,6 +73,7 @@ def create_bayberry_po_pr(submit_pr=True): po = make_purchase_order(mr.name) po.supplier = "Southern Fruit Supply" + po.buying_price_list = "Bakery Buying" po.save() po.submit() @@ -551,6 +589,7 @@ def test_multi_item_pr_partial_quarantine_routing(): po = make_purchase_order(mr.name) po.supplier = "Southern Fruit Supply" + po.buying_price_list = "Bakery Buying" po.save() po.submit() From 181b77478ed7e070879314cc3b34fd85bb3c8bbc Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 14:27:38 -0500 Subject: [PATCH 3/7] chore: better function syntax --- inventory_tools/inventory_tools/overrides/inspection.py | 4 ++-- inventory_tools/inventory_tools/overrides/stock_entry.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/inventory_tools/inventory_tools/overrides/inspection.py b/inventory_tools/inventory_tools/overrides/inspection.py index e097257..f50aa81 100644 --- a/inventory_tools/inventory_tools/overrides/inspection.py +++ b/inventory_tools/inventory_tools/overrides/inspection.py @@ -57,7 +57,7 @@ def validate_inspection_with_company_scope(doc) -> None: or ( doc.doctype == "Stock Entry" and not getattr(doc, "inspection_required", False) - and not _has_item_with_manufacture_inspection(doc) + and not has_item_with_manufacture_inspection(doc) ) or ( doc.doctype in ["Sales Invoice", "Purchase Invoice"] and not getattr(doc, "update_stock", False) @@ -86,7 +86,7 @@ def validate_inspection_with_company_scope(doc) -> None: doc.validate_qi_rejection(row) -def _has_item_with_manufacture_inspection(doc) -> bool: +def has_item_with_manufacture_inspection(doc) -> bool: """Check if any item in doc requires manufacture inspection (for Stock Entry early-exit).""" for row in doc.get("items") or []: if row.get("t_warehouse") and get_inspection_required( diff --git a/inventory_tools/inventory_tools/overrides/stock_entry.py b/inventory_tools/inventory_tools/overrides/stock_entry.py index 65184d6..1ce6cb8 100644 --- a/inventory_tools/inventory_tools/overrides/stock_entry.py +++ b/inventory_tools/inventory_tools/overrides/stock_entry.py @@ -349,7 +349,7 @@ def get_production_item_if_work_orders_for_required_item_exists(stock_entry_name return "" -def _get_quarantine_warehouses(company): +def get_quarantine_warehouses(company): """Return all configured quarantine warehouses visible to a company.""" settings = frappe.get_cached_doc("Inventory Tools Settings", company) warehouses = set() @@ -370,7 +370,7 @@ def validate_block_issue_from_quarantine(doc, method): if not settings.block_issue_from_quarantine: return - quarantine_warehouses = _get_quarantine_warehouses(doc.company) + quarantine_warehouses = get_quarantine_warehouses(doc.company) if not quarantine_warehouses: return From 0a42cbab2c227ea8a59a919ac3d942ea9b66cbe6 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 14:44:10 -0500 Subject: [PATCH 4/7] chore: update linter --- .github/workflows/lint.yaml | 114 ++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 65 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index ca94bb4..5ba59d7 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -11,91 +11,97 @@ env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate JSON + run: | + python3 - <<'EOF' + import json, sys + from pathlib import Path + errors = [] + for f in Path("./inventory_tools/inventory_tools/").rglob("*.json"): + try: + json.loads(f.read_text()) + except json.JSONDecodeError as e: + errors.append(f"{f}: {e}") + if errors: + print("\n".join(errors)) + sys.exit(1) + EOF + + - name: Compile + run: python3 -m compileall -q ./ + + - name: Check merge conflicts + run: | + if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"; then + echo "Found merge conflicts" + exit 1 + fi + mypy: - needs: [ py_json_merge ] + needs: [validate] runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 2 + - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: '3.10' - name: Install mypy run: pip install mypy - - name: Install mypy types + - name: Install mypy stubs run: mypy ./inventory_tools/. --install-types --non-interactive - name: Run mypy - uses: sasanquaneuf/mypy-github-action@releases/v1 - with: - checkName: 'mypy' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mypy ./inventory_tools/. black: - needs: [ py_json_merge ] + needs: [validate] runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 2 + - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: '3.10' - name: Install Black (Frappe) run: pip install git+https://github.com/frappe/black.git - - name: Run Black (Frappe) + - name: Run Black run: black --check . prettier: - needs: [ py_json_merge ] + needs: [validate] runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 2 + - uses: actions/checkout@v4 - - name: Prettify code - uses: rutajdash/prettier-cli-action@v1.0.0 + - uses: actions/setup-node@v4 with: - config_path: ./.prettierrc.js - ignore_path: ./.prettierignore + node-version: '20' - - name: Prettier Output - if: ${{ failure() }} - shell: bash - run: | - echo "The following files are not formatted:" - echo "${{steps.prettier-run.outputs.prettier_output}}" >> $GITHUB_OUTPUT + - name: Run Prettier + run: npx --yes prettier --check --config ./.prettierrc.js --ignore-path ./.prettierignore . json_diff: - needs: [ py_json_merge ] + needs: [validate] runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: ref: ${{ github.ref }} fetch-depth: 2 - name: Find JSON changes id: changed-json - uses: tj-actions/changed-files@v43 + uses: tj-actions/changed-files@v45 with: files: | **/*.json @@ -138,25 +144,3 @@ jobs: for file in ${{ steps.changed-json.outputs.deleted_files }}; do echo "D,${file}" >> base/mrd.txt done - - - py_json_merge: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Fetch validator - run: git clone --depth 1 https://gist.github.com/f1bf2c11f78331b2417189c385022c28.git validate_json - - - name: Validate JSON - run: python3 validate_json/validate_json.py ./inventory_tools/inventory_tools/ - - - name: Compile - run: python3 -m compileall -q ./ - - - name: Check merge - run: | - if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" - then echo "Found merge conflicts" - exit 1 - fi From 4310d0cf87117879a6c669a1a9b7b166c4c925f7 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 14:54:51 -0500 Subject: [PATCH 5/7] chore: fix prettier path --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 5ba59d7..3962255 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -88,7 +88,7 @@ jobs: node-version: '20' - name: Run Prettier - run: npx --yes prettier --check --config ./.prettierrc.js --ignore-path ./.prettierignore . + run: npx --yes prettier --check --config ./.prettierrc.cjs --ignore-path ./.prettierignore . json_diff: needs: [validate] From d6b4477b8bec57609a18c3dd78a05799dc2cd80c Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 15:04:28 -0500 Subject: [PATCH 6/7] chore: update prettier config --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 3962255..759bbe3 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -88,7 +88,7 @@ jobs: node-version: '20' - name: Run Prettier - run: npx --yes prettier --check --config ./.prettierrc.cjs --ignore-path ./.prettierignore . + run: npx --yes prettier@3.1.0 --check --config ./.prettierrc.cjs --ignore-path ./.prettierignore "**/*.js" "**/*.vue" "**/*.scss" json_diff: needs: [validate] From dda4c7c95a2049390ae753e5e8bbe23c0c48c7fc Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Tue, 3 Mar 2026 18:51:24 -0500 Subject: [PATCH 7/7] chore: update precommit, fix errors --- .github/workflows/lint.yaml | 2 +- .github/workflows/static-analysis.yml | 29 +++++++++++++++++++ .pre-commit-config.yaml | 12 ++++---- .../inventory_tools/overrides/pick_list.py | 11 +++---- .../overrides/purchase_invoice.py | 6 ++-- 5 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/static-analysis.yml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 759bbe3..e2b21bc 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -88,7 +88,7 @@ jobs: node-version: '20' - name: Run Prettier - run: npx --yes prettier@3.1.0 --check --config ./.prettierrc.cjs --ignore-path ./.prettierignore "**/*.js" "**/*.vue" "**/*.scss" + run: npx --yes prettier@3.1.0 --check --config ./.prettierrc.cjs --ignore-path ./.prettierignore "**/*.js" "**/*.vue" json_diff: needs: [validate] diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..a6a9748 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,29 @@ +name: Static Analysis + +on: + push: + branches: ["*"] + pull_request: + branches: ["*"] + +permissions: + contents: read + +jobs: + static-analysis: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install test_utils with vulture + run: pip install git+https://github.com/agritheory/test_utils.git vulture + + - name: Run static analysis + run: static_analysis . --no-hooks --no-frontend --no-python-calls --no-jinja diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ff70f5..9a2a1c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.20.1 + rev: v1.20.2 hooks: - id: update_pre_commit_config - id: validate_frappe_project @@ -63,18 +63,16 @@ repos: args: ['--directory', '.', '--app', 'inventory_tools', '--base-branch', 'version-15'] - id: check_code_duplication args: ['--max-clones', '60', '--max-percentage', '5.0'] + - id: static_analysis + args: ['.'] - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier + args: ['--config', '.prettierrc.cjs', '--ignore-path', '.prettierignore'] types_or: [javascript, vue, scss] - exclude: | - (?x)^( - .*node_modules.*| - inventory_tools/public/dist/.*| - inventory_tools/public/js/lib/.* - )$ + exclude: 'inventory_tools/public/js/lib/.*' ci: autoupdate_schedule: weekly diff --git a/inventory_tools/inventory_tools/overrides/pick_list.py b/inventory_tools/inventory_tools/overrides/pick_list.py index 8d35c21..fc5cb54 100644 --- a/inventory_tools/inventory_tools/overrides/pick_list.py +++ b/inventory_tools/inventory_tools/overrides/pick_list.py @@ -1,18 +1,15 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -from typing import TYPE_CHECKING - import frappe from frappe.utils import safe_json_loads from frappe.utils.data import nowdate import numpy as np -from inventory_tools.inventory_tools.doctype.warehouse_plan.warehouse_plan import Grid_TSP +from erpnext.stock.doctype.pick_list.pick_list import PickList +from erpnext.stock.doctype.pick_list_item.pick_list_item import PickListItem -if TYPE_CHECKING: - from erpnext.stock.doctype.pick_list_item.pick_list_item import PickListItem - from erpnext.stock.doctype.pick_list.pick_list import PickList +from inventory_tools.inventory_tools.doctype.warehouse_plan.warehouse_plan import Grid_TSP class PathFinder: @@ -163,7 +160,7 @@ def optimize_route_picklist(item_whs: list, root_warehouse: str) -> list: @frappe.whitelist() -def optimize_path(doc: "PickList", strategy: str) -> list["PickListItem"]: +def optimize_path(doc: PickList, strategy: str) -> list[PickListItem]: """Optimize the picklist route based on the specified strategy. Parameters: diff --git a/inventory_tools/inventory_tools/overrides/purchase_invoice.py b/inventory_tools/inventory_tools/overrides/purchase_invoice.py index 8bcd0a7..26b9b51 100644 --- a/inventory_tools/inventory_tools/overrides/purchase_invoice.py +++ b/inventory_tools/inventory_tools/overrides/purchase_invoice.py @@ -2,6 +2,7 @@ # See license.txt import datetime +import json import frappe from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice @@ -114,9 +115,8 @@ def on_cancel_revert_se_paid_qty(self): @frappe.whitelist() def get_stock_entries(purchase_orders, from_date=None, to_date=None): - # # Commented code is useful if having PO and attaching WOs to them is enforced - # if isinstance(purchase_orders, str): - # purchase_orders = json.loads(purchase_orders) + if isinstance(purchase_orders, str): + purchase_orders = json.loads(purchase_orders) if not from_date: from_date = datetime.date(1900, 1, 1)