From ca5097662feddf99b4ad04676912f5993bdc9513 Mon Sep 17 00:00:00 2001 From: Robert Duncan Date: Fri, 17 Apr 2026 02:41:45 +0000 Subject: [PATCH 1/5] feat inv dim propagation --- .pre-commit-config.yaml | 2 +- beam/beam/custom/inventory_dimension.json | 50 +++ beam/beam/handling_unit.py | 14 +- beam/beam/inventory_dimension.py | 99 +++++ beam/beam/scan/__init__.py | 39 +- beam/hooks.py | 5 + beam/install.py | 41 +- beam/tests/setup.py | 8 +- beam/tests/test_inventory_dimension.py | 435 ++++++++++++++++++++++ 9 files changed, 646 insertions(+), 47 deletions(-) create mode 100644 beam/beam/custom/inventory_dimension.json create mode 100644 beam/beam/inventory_dimension.py create mode 100644 beam/tests/test_inventory_dimension.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6548f25..56cafdd0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.20.2 + rev: v1.22.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/beam/beam/custom/inventory_dimension.json b/beam/beam/custom/inventory_dimension.json new file mode 100644 index 00000000..8b8f57ca --- /dev/null +++ b/beam/beam/custom/inventory_dimension.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": "Inventory Dimension", + "fetch_if_empty": 0, + "fieldname": "custom_carry_forward", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 11, + "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": "apply_to_all_doctypes", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Carry Forward", + "length": 0, + "module": "BEAM", + "name": "Inventory Dimension-custom_carry_forward", + "no_copy": 0, + "non_negative": 0, + "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": "Inventory Dimension", + "property_setters": [], + "sync_on_migrate": 1 +} \ No newline at end of file diff --git a/beam/beam/handling_unit.py b/beam/beam/handling_unit.py index b73182fe..460fc505 100644 --- a/beam/beam/handling_unit.py +++ b/beam/beam/handling_unit.py @@ -4,6 +4,7 @@ import frappe from erpnext.stock.stock_ledger import NegativeStockError +from frappe.utils import flt from beam.beam.doctype.beam_settings.beam_settings import create_beam_settings from beam.beam.scan import get_handling_unit @@ -44,9 +45,16 @@ def generate_handling_units(doc, method=None): in ("Material Transfer", "Send to Subcontractor", "Material Transfer for Manufacture") and row.handling_unit ): - handling_unit = frappe.new_doc("Handling Unit") - handling_unit.save() - row.to_handling_unit = handling_unit.name + if not row.to_handling_unit: + handling_unit = frappe.new_doc("Handling Unit") + handling_unit.save() + row.to_handling_unit = handling_unit.name + elif row.to_handling_unit == row.handling_unit: + hu = get_handling_unit(row.handling_unit) + if hu and flt(hu.stock_qty) != flt(row.transfer_qty): + handling_unit = frappe.new_doc("Handling Unit") + handling_unit.save() + row.to_handling_unit = handling_unit.name continue if doc.doctype == "Subcontracting Receipt" and not row.handling_unit: diff --git a/beam/beam/inventory_dimension.py b/beam/beam/inventory_dimension.py new file mode 100644 index 00000000..468a056e --- /dev/null +++ b/beam/beam/inventory_dimension.py @@ -0,0 +1,99 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +import frappe + +from beam.beam.scan.config import get_scan_doctypes + + +def setup_inventory_dimensions(inv_dim_dict_list: list[dict]) -> None: + """Create Inventory Dimensions and perform supporting setup. + + Each dictionary in the list should contain the following keys: + dimension_name - required + reference_document - required + target_fieldname - optional + apply_to_all_doctypes - optional, defaults to 1 + custom_carry_forward - optional, defaults to 0 + + Side-effects per dimension: + - Relabels ``Source {Name}`` custom fields to ``{Name}``. + - Hides / marks read-only ``Target {Name}`` custom fields (keeps Purchase Invoice Item + variant visible and relabeled). + - Sets ``no_copy`` on all generated custom fields and marks them read-only on doctypes that + aren't scannable form targets (based on BEAM's ``get_scan_doctypes``). + """ + frappe.flags.in_test = True + for inv_dim_dict in inv_dim_dict_list: + name = inv_dim_dict["dimension_name"] + print(f"Setting up {name} Inventory Dimension") + if frappe.db.exists("Inventory Dimension", name): + continue + + inv_dim_dict.setdefault("apply_to_all_doctypes", 1) + inv_dim = frappe.new_doc("Inventory Dimension") + inv_dim.update(inv_dim_dict) + inv_dim.save() + + for custom_field in frappe.get_all("Custom Field", {"label": f"Source {name}"}): + frappe.set_value("Custom Field", custom_field, "label", name) + + for custom_field in frappe.get_all("Custom Field", {"label": f"Target {name}"}, ["name", "dt"]): + if custom_field.dt == "Purchase Invoice Item": + frappe.set_value("Custom Field", custom_field, "label", name) + else: + frappe.set_value("Custom Field", custom_field, "read_only", 1) + frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) + + frm_doctypes = get_scan_doctypes()["frm"] + + for custom_field in frappe.get_all("Custom Field", {"label": name}, ["name", "dt"]): + frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) + + if ( + custom_field["dt"] not in frm_doctypes + and custom_field["dt"].replace(" Item", "").replace(" Detail", "") not in frm_doctypes + ): + frappe.set_value("Custom Field", custom_field["name"], "read_only", 1) + frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) + + +_CARRY_FORWARD_CACHE_KEY = "beam:inv_dim_carry_forward" + + +def get_carry_forward_dims() -> list[dict]: + """Cached list of Inventory Dimensions with carry_forward enabled. Cleared alongside + the scan cache when an Inventory Dimension is updated or deleted.""" + + def _fetch(): + return [ + d + for d in frappe.get_all( + "Inventory Dimension", + filters={"custom_carry_forward": 1}, + fields=["source_fieldname", "target_fieldname"], + ) + if d.source_fieldname and d.target_fieldname + ] + + return frappe.cache().get_value(_CARRY_FORWARD_CACHE_KEY, generator=_fetch) + + +def propagate_inventory_dimensions(doc, method=None): + """before_submit hook for Stock Entry: copy each Inventory Dimension's source field to its + target field on rows where target is empty. Only fires for dimensions flagged with + `custom_carry_forward`, and only on rows that represent a real transfer (both s_warehouse + and t_warehouse set, row has a stock item code). + + App-specific flows that explicitly set target (including explicit `None`) are unaffected + as long as their dimension is not flagged to carry forward.""" + dims = get_carry_forward_dims() + if not dims: + return + + for row in doc.items or []: + if not row.item_code or not row.s_warehouse or not row.t_warehouse: + continue + for dim in dims: + if row.get(dim.source_fieldname) and not row.get(dim.target_fieldname): + row.set(dim.target_fieldname, row.get(dim.source_fieldname)) diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index ad382736..2e67cb82 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -13,6 +13,30 @@ from frappe.query_builder.functions import Coalesce +_INV_DIM_CACHE_KEY = "beam:inv_dim_source_fieldnames" + + +def get_inv_dim_source_fieldnames() -> list[str]: + """Cached list of Inventory Dimension source_fieldnames (excluding Handling Unit). Cleared + whenever an Inventory Dimension is updated or deleted via `clear_inv_dim_cache` hook.""" + + def _fetch(): + return frappe.get_all( + "Inventory Dimension", + filters={"name": ["!=", "Handling Unit"]}, + pluck="source_fieldname", + ) + + return frappe.cache().get_value(_INV_DIM_CACHE_KEY, generator=_fetch) + + +def clear_inv_dim_cache(doc=None, method=None) -> None: + from beam.beam.inventory_dimension import _CARRY_FORWARD_CACHE_KEY + + frappe.cache().delete_value(_INV_DIM_CACHE_KEY) + frappe.cache().delete_value(_CARRY_FORWARD_CACHE_KEY) + + @frappe.whitelist() def scan( barcode: str, @@ -93,13 +117,15 @@ def get_barcode_context(barcode: str) -> frappe._dict | None: return None -def get_handling_unit(handling_unit: str, parent_doctype: str | None = None) -> frappe._dict: +def get_handling_unit( + handling_unit: str, parent_doctype: str | None = None, inv_dims: list = None +) -> frappe._dict: sl_entries = frappe.get_all( "Stock Ledger Entry", filters={"handling_unit": handling_unit, "is_cancelled": 0}, fields=[ "item_code", - "SUM(actual_qty) AS stock_qty", + {"SUM": "actual_qty", "as": "stock_qty"}, "company", "handling_unit", "voucher_no", @@ -109,7 +135,8 @@ def get_handling_unit(handling_unit: str, parent_doctype: str | None = None) -> "voucher_type", "voucher_detail_no", "warehouse", - ], + ] + + (inv_dims if inv_dims else []), group_by="handling_unit", order_by="posting_date DESC", limit=1, @@ -166,7 +193,7 @@ def get_stock_entry_item_details(doc: dict, item_code: str) -> frappe._dict: if not stock_entry.stock_entry_type: stock_entry.purpose = "Material Transfer" stock_entry.set_stock_entry_type() - target = stock_entry.get_item_details({"item_code": item_code}) + target = stock_entry.get_item_details(frappe._dict(item_code=item_code)) target.item_code = item_code target.qty = 1 # only required for first scan, since quantity by default is zero return target @@ -225,7 +252,8 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di ) if barcode_doc.doc.doctype == "Handling Unit": - hu_details = get_handling_unit(barcode_doc.doc.name, context.frm) + inv_dims = get_inv_dim_source_fieldnames() + hu_details = get_handling_unit(barcode_doc.doc.name, context.frm, inv_dims) if context.frm == "Stock Entry": target = get_stock_entry_item_details(context.doc, hu_details.item_code) target.warehouse = hu_details.warehouse @@ -265,6 +293,7 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di "posting_datetime": hu_details.posting_datetime, "dn_detail": hu_details.dn_detail, } + | {i: hu_details[i] for i in inv_dims} ) elif barcode_doc.doc.doctype == "Item": if context.frm == "Stock Entry": diff --git a/beam/hooks.py b/beam/hooks.py index e369589a..3bda258c 100644 --- a/beam/hooks.py +++ b/beam/hooks.py @@ -127,6 +127,10 @@ # Hook on document methods and events doc_events = { + "Inventory Dimension": { + "on_update": "beam.beam.scan.clear_inv_dim_cache", + "on_trash": "beam.beam.scan.clear_inv_dim_cache", + }, "Item": { "validate": [ "beam.beam.barcodes.create_beam_barcode", @@ -155,6 +159,7 @@ # "beam.beam.handling_unit.validate_handling_unit_overconsumption", ], "before_submit": [ + "beam.beam.inventory_dimension.propagate_inventory_dimensions", "beam.beam.handling_unit.generate_handling_units", "beam.beam.overrides.stock_entry.validate_items_with_handling_unit", ], diff --git a/beam/install.py b/beam/install.py index 12e282c7..437a3e92 100644 --- a/beam/install.py +++ b/beam/install.py @@ -1,43 +1,10 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt -import frappe - -from beam.beam.scan.config import get_scan_doctypes +from beam.beam.inventory_dimension import setup_inventory_dimensions def after_install(): - print("Setting up Handling Unit Inventory Dimension") - if frappe.db.exists("Inventory Dimension", "Handling Unit"): - return - huid = frappe.new_doc("Inventory Dimension") - huid.dimension_name = "Handling Unit" - huid.reference_document = "Handling Unit" - huid.apply_to_all_doctypes = 1 - huid.save() - - # re-label - for custom_field in frappe.get_all("Custom Field", {"label": "Source Handling Unit"}): - frappe.set_value("Custom Field", custom_field, "label", "Handling Unit") - - # hide target fields - for custom_field in frappe.get_all( - "Custom Field", {"label": "Target Handling Unit"}, ["name", "dt"] - ): - if custom_field.dt == "Purchase Invoice Item": - frappe.set_value("Custom Field", custom_field, "label", "Handling Unit") - else: - frappe.set_value("Custom Field", custom_field, "read_only", 1) - frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) - - frm_doctypes = get_scan_doctypes()["frm"] - - for custom_field in frappe.get_all("Custom Field", {"label": "Handling Unit"}, ["name", "dt"]): - frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) - - if ( - custom_field["dt"] not in frm_doctypes - and custom_field["dt"].replace(" Item", "").replace(" Detail", "") not in frm_doctypes - ): - frappe.set_value("Custom Field", custom_field["name"], "read_only", 1) - frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) + setup_inventory_dimensions( + [{"dimension_name": "Handling Unit", "reference_document": "Handling Unit"}] + ) diff --git a/beam/tests/setup.py b/beam/tests/setup.py index 4cf7dacb..6f57495e 100644 --- a/beam/tests/setup.py +++ b/beam/tests/setup.py @@ -477,10 +477,11 @@ def create_production_plan(settings, prod_plan_from_doc): pp.get_mr_items() for item in pp.po_items: item.planned_start_date = settings.day + pp.for_warehouse = "Storeroom - APC" + pp.sub_assembly_warehouse = "Kitchen - APC" pp.get_sub_assembly_items() for item in pp.sub_assembly_items: item.schedule_date = settings.day - pp.for_warehouse = "Storeroom - APC" raw_materials = get_items_for_material_requests( pp.as_dict(), warehouses=None, get_parent_warehouse_data=None ) @@ -551,6 +552,11 @@ def create_production_plan(settings, prod_plan_from_doc): wo.required_items = sorted(wo.required_items, key=lambda x: x.get("item_code")) for idx, w in enumerate(wo.required_items, start=1): w.idx = idx + default_warehouse = frappe.db.get_value( + "Item Default", {"parent": w.item_code}, "default_warehouse" + ) + if default_warehouse: + w.source_warehouse = default_warehouse wo.save() wo.submit() frappe.db.set_value("Work Order", wo.name, "creation", start_time) diff --git a/beam/tests/test_inventory_dimension.py b/beam/tests/test_inventory_dimension.py new file mode 100644 index 00000000..52eb18ae --- /dev/null +++ b/beam/tests/test_inventory_dimension.py @@ -0,0 +1,435 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +import pytest + +from beam.beam.inventory_dimension import ( + propagate_inventory_dimensions, + setup_inventory_dimensions, +) +from beam.beam.scan import clear_inv_dim_cache, get_inv_dim_source_fieldnames + + +# --- Inventory Dimension cache ----------------------------------------------- + + +def test_inv_dim_cache_returns_list(): + clear_inv_dim_cache() + result = get_inv_dim_source_fieldnames() + assert isinstance(result, list) + + +def test_inv_dim_cache_excludes_handling_unit(): + clear_inv_dim_cache() + result = get_inv_dim_source_fieldnames() + assert "handling_unit" not in result + + +def test_inv_dim_cache_invalidation_refetches(): + result_before = get_inv_dim_source_fieldnames() + clear_inv_dim_cache() + result_after = get_inv_dim_source_fieldnames() + assert result_before == result_after + + +# --- setup_inventory_dimensions ---------------------------------------------- + + +def test_setup_skips_existing_dimension(): + """Calling setup for an already-existing dimension should be a no-op.""" + assert frappe.db.exists("Inventory Dimension", "Handling Unit") + setup_inventory_dimensions( + [{"dimension_name": "Handling Unit", "reference_document": "Handling Unit"}] + ) + assert frappe.db.exists("Inventory Dimension", "Handling Unit") + + +def test_setup_applies_default_apply_to_all(monkeypatch): + """When apply_to_all_doctypes is not specified, setup should default it to 1 in the input dict.""" + input_dict = {"dimension_name": "__Test Beam Dim", "reference_document": "Supplier"} + + class FakeInvDim: + def update(self, d): + pass + + def save(self): + raise RuntimeError("stop before persistence") + + monkeypatch.setattr("frappe.new_doc", lambda _dt: FakeInvDim()) + try: + setup_inventory_dimensions([input_dict]) + except RuntimeError: + pass + assert input_dict.get("apply_to_all_doctypes") == 1 + + +# --- propagate_inventory_dimensions ------------------------------------------ + + +def submit_all_purchase_receipts(): + for pi in frappe.get_all("Purchase Invoice", {"docstatus": 0}): + frappe.get_doc("Purchase Invoice", pi).submit() + for pr in frappe.get_all("Purchase Receipt", {"docstatus": 0}): + frappe.get_doc("Purchase Receipt", pr).submit() + + +def test_propagate_skips_rows_without_both_warehouses(): + """Rows missing s_warehouse or t_warehouse should not be touched.""" + company = frappe.defaults.get_defaults().get("company") + beam_settings = frappe.get_doc("BEAM Settings", {"company": company}) + if not beam_settings.enable_handling_units: + pytest.skip("Handling units not enabled") + + original = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) + clear_inv_dim_cache() + + doc = frappe.new_doc("Stock Entry") + doc.append( + "items", + { + "item_code": "Ambrosia Pie", + "s_warehouse": "Baked Goods - APC", + "t_warehouse": None, + "handling_unit": "HU-TEST-1", + "to_handling_unit": None, + }, + ) + doc.append( + "items", + { + "item_code": "Ambrosia Pie", + "s_warehouse": None, + "t_warehouse": "Kitchen - APC", + "handling_unit": "HU-TEST-2", + "to_handling_unit": None, + }, + ) + propagate_inventory_dimensions(doc) + assert not doc.items[0].to_handling_unit + assert not doc.items[1].to_handling_unit + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original or 0 + ) + clear_inv_dim_cache() + + +def test_propagate_copies_source_to_empty_target(): + """When carry_forward is on and target is empty, source should be copied to target.""" + company = frappe.defaults.get_defaults().get("company") + beam_settings = frappe.get_doc("BEAM Settings", {"company": company}) + if not beam_settings.enable_handling_units: + pytest.skip("Handling units not enabled") + + original_cf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + original_tf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "target_fieldname") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "target_fieldname", "to_handling_unit" + ) + clear_inv_dim_cache() + + doc = frappe.new_doc("Stock Entry") + doc.append( + "items", + { + "item_code": "Ambrosia Pie", + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": "HU-TEST-CF", + "to_handling_unit": None, + }, + ) + propagate_inventory_dimensions(doc) + assert doc.items[0].to_handling_unit == "HU-TEST-CF" + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original_cf or 0 + ) + frappe.db.set_value("Inventory Dimension", "Handling Unit", "target_fieldname", original_tf) + clear_inv_dim_cache() + + +def test_propagate_does_not_overwrite_existing_target(): + """When target is already set, it should not be overwritten.""" + company = frappe.defaults.get_defaults().get("company") + beam_settings = frappe.get_doc("BEAM Settings", {"company": company}) + if not beam_settings.enable_handling_units: + pytest.skip("Handling units not enabled") + + original = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) + clear_inv_dim_cache() + + doc = frappe.new_doc("Stock Entry") + doc.append( + "items", + { + "item_code": "Ambrosia Pie", + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": "HU-SOURCE", + "to_handling_unit": "HU-EXISTING-TARGET", + }, + ) + propagate_inventory_dimensions(doc) + assert doc.items[0].to_handling_unit == "HU-EXISTING-TARGET" + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original or 0 + ) + clear_inv_dim_cache() + + +def test_propagate_skips_dim_without_carry_forward(): + """Dimensions with carry_forward off should not propagate.""" + original = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 0) + clear_inv_dim_cache() + + doc = frappe.new_doc("Stock Entry") + doc.append( + "items", + { + "item_code": "Ambrosia Pie", + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": "HU-TEST-NOCF", + "to_handling_unit": None, + }, + ) + propagate_inventory_dimensions(doc) + assert not doc.items[0].to_handling_unit + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original or 0 + ) + clear_inv_dim_cache() + + +def test_propagate_skips_rows_without_item_code(): + """Rows without item_code (blank starter rows) should be skipped.""" + original = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) + clear_inv_dim_cache() + + doc = frappe.new_doc("Stock Entry") + doc.append( + "items", + { + "item_code": None, + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": "HU-BLANK", + "to_handling_unit": None, + }, + ) + propagate_inventory_dimensions(doc) + assert not doc.items[0].to_handling_unit + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original or 0 + ) + clear_inv_dim_cache() + + +# --- HU carry-forward integration tests -------------------------------------- +# +# With carry_forward enabled, propagate_inventory_dimensions copies handling_unit → +# to_handling_unit before BEAM's generate_handling_units runs. BEAM now respects a +# pre-filled to_handling_unit and skips creating a new one, preserving the HU label +# across warehouse transfers (the HU is a physical sticker, not a container that changes). + + +@pytest.mark.order(after="test_stock_entry_material_receipt") +def test_hu_carry_forward_partial_transfer_creates_new_hu(): + """Partial qty transfer with carry_forward: propagate copies source → target, but + BEAM detects qty mismatch and overrides with a new HU (physical split).""" + submit_all_purchase_receipts() + + se_receipt = frappe.new_doc("Stock Entry") + se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt" + se_receipt.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 50, + "t_warehouse": "Baked Goods - APC", + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_receipt.save() + se_receipt.submit() + source_hu = se_receipt.items[0].handling_unit + assert source_hu + + original = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) + clear_inv_dim_cache() + + se_transfer = frappe.new_doc("Stock Entry") + se_transfer.stock_entry_type = se_transfer.purpose = "Material Transfer" + se_transfer.company = frappe.defaults.get_defaults().get("company") + se_transfer.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 20, + "transfer_qty": 20, + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": source_hu, + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_transfer.save() + se_transfer.submit() + + row = se_transfer.items[0] + # qty 20 != HU stock 50 → BEAM overrides carry-forward with new HU + assert row.to_handling_unit + assert row.to_handling_unit != source_hu + + from beam.beam.scan import get_handling_unit + + # Source HU retains remainder + hu_source = get_handling_unit(source_hu) + assert hu_source.stock_qty == 30 + + # New HU has split portion + hu_target = get_handling_unit(row.to_handling_unit) + assert hu_target.stock_qty == 20 + + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original or 0 + ) + clear_inv_dim_cache() + + +@pytest.mark.order(after="test_stock_entry_material_receipt") +def test_hu_carry_forward_full_transfer_conserves_hu(): + """Full qty transfer: HU label follows the stock to the new warehouse.""" + submit_all_purchase_receipts() + + se_receipt = frappe.new_doc("Stock Entry") + se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt" + se_receipt.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 25, + "t_warehouse": "Baked Goods - APC", + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_receipt.save() + se_receipt.submit() + source_hu = se_receipt.items[0].handling_unit + + original_cf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + original_tf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "target_fieldname") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "target_fieldname", "to_handling_unit" + ) + clear_inv_dim_cache() + + se_transfer = frappe.new_doc("Stock Entry") + se_transfer.stock_entry_type = se_transfer.purpose = "Material Transfer" + se_transfer.company = frappe.defaults.get_defaults().get("company") + se_transfer.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 25, + "transfer_qty": 25, + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": source_hu, + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_transfer.save() + se_transfer.submit() + + row = se_transfer.items[0] + # Same HU carried forward + assert row.to_handling_unit == source_hu + + from beam.beam.scan import get_handling_unit + + # HU retains the transferred qty + hu = get_handling_unit(source_hu) + assert hu.stock_qty == 25 + + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original_cf or 0 + ) + frappe.db.set_value("Inventory Dimension", "Handling Unit", "target_fieldname", original_tf) + clear_inv_dim_cache() + + +@pytest.mark.order(after="test_stock_entry_material_receipt") +def test_hu_carry_forward_off_creates_new_hu(): + """Without carry_forward, BEAM's default behavior creates a new to_handling_unit.""" + submit_all_purchase_receipts() + + se_receipt = frappe.new_doc("Stock Entry") + se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt" + se_receipt.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 30, + "t_warehouse": "Baked Goods - APC", + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_receipt.save() + se_receipt.submit() + source_hu = se_receipt.items[0].handling_unit + + original = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") + try: + frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 0) + clear_inv_dim_cache() + + se_transfer = frappe.new_doc("Stock Entry") + se_transfer.stock_entry_type = se_transfer.purpose = "Material Transfer" + se_transfer.company = frappe.defaults.get_defaults().get("company") + se_transfer.append( + "items", + { + "item_code": "Ambrosia Pie", + "qty": 10, + "transfer_qty": 10, + "s_warehouse": "Baked Goods - APC", + "t_warehouse": "Kitchen - APC", + "handling_unit": source_hu, + "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"), + }, + ) + se_transfer.save() + se_transfer.submit() + + row = se_transfer.items[0] + # Default BEAM: new HU on target + assert row.to_handling_unit + assert row.to_handling_unit != source_hu + + finally: + frappe.db.set_value( + "Inventory Dimension", "Handling Unit", "custom_carry_forward", original or 0 + ) + clear_inv_dim_cache() From 93eeefe502182aff43c75a7275b80eb4ce8e8aa8 Mon Sep 17 00:00:00 2001 From: Robert Duncan Date: Sat, 18 Apr 2026 02:21:11 +0000 Subject: [PATCH 2/5] fix: mypy --- beam/beam/scan/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index 2e67cb82..519b3e23 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -118,7 +118,7 @@ def get_barcode_context(barcode: str) -> frappe._dict | None: def get_handling_unit( - handling_unit: str, parent_doctype: str | None = None, inv_dims: list = None + handling_unit: str, parent_doctype: str | None = None, inv_dims: list | None = None ) -> frappe._dict: sl_entries = frappe.get_all( "Stock Ledger Entry", From 5d02bb188ec8faba15b26aefc1427ecefa94c4a3 Mon Sep 17 00:00:00 2001 From: Robert Duncan Date: Sun, 19 Jul 2026 23:50:12 +0000 Subject: [PATCH 3/5] fix: ignore classes --- .pre-commit-config.yaml | 2 +- beam/public/js/scan/scan.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56cafdd0..b3111b61 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.22.0 + rev: v1.28.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/beam/public/js/scan/scan.js b/beam/public/js/scan/scan.js index f2e683f9..588d8a42 100644 --- a/beam/public/js/scan/scan.js +++ b/beam/public/js/scan/scan.js @@ -41,7 +41,7 @@ class ScanHandler { onScan: async function (sCode, iQty) { await me.get_scanned_context(sCode, iQty) }, - ignoreIfFocusOn: '.frappe-input', + ignoreIfFocusOn: 'input, textarea, select, [contenteditable]', }) window.scanHandler = me } From 081a7d692ea874d2d7b8c988948a27100f13b247 Mon Sep 17 00:00:00 2001 From: Robert Duncan Date: Thu, 23 Jul 2026 23:24:58 +0000 Subject: [PATCH 4/5] fix: ERPNext v16 compatibility for scrap items, carry-forward, and scan aggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes so BEAM submits cleanly on ERPNext v16 while staying v15-compatible: - Scrap-item field rename: v16 removed `is_scrap_item` from Stock Entry Detail (scrap outputs are now `type == "Scrap"`, with the old flag preserved as `is_legacy_scrap_item`). Add an `is_scrap_item(row)` helper and route all handling_unit / stock_entry references through it, fixing the AttributeError on Stock Entry submit. - Inventory-dimension carry-forward: write the carried value to `to_` — the field ERPNext's stock controller reads for the incoming leg of a transfer — instead of `target_fieldname`, which names the SLE column and defaults to the source field (making the old write a no-op). Drop the unused manufacture carry-forward field selection. - Scan aggregate: select the stock_qty sum via the dict form on v16 (which rejects string-form SQL functions in SELECT) and the string form on v15 (which has no dict-function support), chosen by Frappe major version. Tests: route the test suite's scrap-item checks through the helper, and drop the `target_fieldname` override hack now that carry-forward writes to the correct field — aligning these tests with the sibling carry-forward tests. --- beam/beam/handling_unit.py | 12 ++++++++--- beam/beam/inventory_dimension.py | 28 ++++++++++++++++---------- beam/beam/overrides/stock_entry.py | 5 +++-- beam/beam/scan/__init__.py | 13 +++++++++++- beam/tests/test_handling_unit.py | 9 +++++---- beam/tests/test_inventory_dimension.py | 10 --------- 6 files changed, 46 insertions(+), 31 deletions(-) diff --git a/beam/beam/handling_unit.py b/beam/beam/handling_unit.py index 460fc505..86624c3b 100644 --- a/beam/beam/handling_unit.py +++ b/beam/beam/handling_unit.py @@ -14,6 +14,12 @@ """ +def is_scrap_item(row): + # ERPNext v16 removed `is_scrap_item` from Stock Entry Detail: scrap outputs are now + # marked via `type == "Scrap"`, with the old flag preserved as `is_legacy_scrap_item`. + return bool(row.get("is_legacy_scrap_item") or row.get("type") == "Scrap") + + @frappe.whitelist() def generate_handling_units(doc, method=None): company = doc.get("company") or frappe.defaults.get_defaults().company @@ -62,7 +68,7 @@ def generate_handling_units(doc, method=None): handling_unit.save() row.handling_unit = handling_unit.name - if doc.doctype == "Stock Entry" and doc.purpose == "Manufacture" and row.is_scrap_item: + if doc.doctype == "Stock Entry" and doc.purpose == "Manufacture" and is_scrap_item(row): create_handling_unit = frappe.get_value( "BOM Scrap Item", {"item_code": row.item_code, "parent": doc.bom_no}, "create_handling_unit" ) @@ -76,7 +82,7 @@ def generate_handling_units(doc, method=None): continue if doc.doctype == "Stock Entry" and not ( - any([row.is_finished_item, doc.purpose == "Material Receipt", row.is_scrap_item]) + any([row.is_finished_item, doc.purpose == "Material Receipt", is_scrap_item(row)]) ): continue @@ -127,7 +133,7 @@ def validate_handling_unit_overconsumption(doc, method=None): if ( abs(hu.stock_qty - row.get(qty_field)) > 0.0 and (hu.stock_qty - row.get(qty_field) > precision_denominator) - and not row.is_scrap_item + and not is_scrap_item(row) ): error = True else: # incoming and transfer / same warehouse diff --git a/beam/beam/inventory_dimension.py b/beam/beam/inventory_dimension.py index 468a056e..819a1b86 100644 --- a/beam/beam/inventory_dimension.py +++ b/beam/beam/inventory_dimension.py @@ -71,22 +71,27 @@ def _fetch(): for d in frappe.get_all( "Inventory Dimension", filters={"custom_carry_forward": 1}, - fields=["source_fieldname", "target_fieldname"], + fields=["source_fieldname"], ) - if d.source_fieldname and d.target_fieldname + if d.source_fieldname ] return frappe.cache().get_value(_CARRY_FORWARD_CACHE_KEY, generator=_fetch) def propagate_inventory_dimensions(doc, method=None): - """before_submit hook for Stock Entry: copy each Inventory Dimension's source field to its - target field on rows where target is empty. Only fires for dimensions flagged with - `custom_carry_forward`, and only on rows that represent a real transfer (both s_warehouse - and t_warehouse set, row has a stock item code). - - App-specific flows that explicitly set target (including explicit `None`) are unaffected - as long as their dimension is not flagged to carry forward.""" + """before_submit hook for Stock Entry: copy each carry-forward Inventory Dimension's source + field into its transfer field (``to_``) on rows where that transfer field is + empty. Only fires for dimensions flagged with `custom_carry_forward`, and only on rows that + represent a real transfer (both s_warehouse and t_warehouse set, row has a stock item code). + + We write to ``to_`` -- the field ERPNext's stock controller reads for the + incoming leg of a transfer -- rather than to ``target_fieldname``. ``target_fieldname`` names + the Stock Ledger Entry column (which must stay ```` so the SLE dimension keeps + its expected name), so the two must not be conflated. + + App-specific flows that explicitly set the transfer field (including explicit `None`) are + unaffected as long as their dimension is not flagged to carry forward.""" dims = get_carry_forward_dims() if not dims: return @@ -95,5 +100,6 @@ def propagate_inventory_dimensions(doc, method=None): if not row.item_code or not row.s_warehouse or not row.t_warehouse: continue for dim in dims: - if row.get(dim.source_fieldname) and not row.get(dim.target_fieldname): - row.set(dim.target_fieldname, row.get(dim.source_fieldname)) + target = f"to_{dim.source_fieldname}" + if row.get(dim.source_fieldname) and not row.get(target): + row.set(target, row.get(dim.source_fieldname)) diff --git a/beam/beam/overrides/stock_entry.py b/beam/beam/overrides/stock_entry.py index b4627ff5..84928625 100644 --- a/beam/beam/overrides/stock_entry.py +++ b/beam/beam/overrides/stock_entry.py @@ -6,6 +6,7 @@ from frappe.utils import cstr, flt from beam.beam.doctype.beam_settings.beam_settings import create_beam_settings +from beam.beam.handling_unit import is_scrap_item class BEAMStockEntry(StockEntry): @@ -172,7 +173,7 @@ def validate_items_with_handling_unit(doc, method=None): for row in doc.items: if not frappe.get_value("Item", row.item_code, "enable_handling_unit"): continue - elif row.is_scrap_item and not frappe.get_value( + elif is_scrap_item(row) and not frappe.get_value( "BOM Scrap Item", {"item_code": row.item_code, "parent": doc.get("bom_no")}, "create_handling_unit", @@ -180,7 +181,7 @@ def validate_items_with_handling_unit(doc, method=None): continue elif ( doc.stock_entry_type in ("Repack", "Manufacture") - and not (row.t_warehouse or row.is_finished_item or row.is_scrap_item) + and not (row.t_warehouse or row.is_finished_item or is_scrap_item(row)) and not row.handling_unit ): frappe.throw(frappe._(f"Row #{row.idx}: Handling Unit is missing for item {row.item_code}")) diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py index b8318c40..cf98b3b0 100644 --- a/beam/beam/scan/__init__.py +++ b/beam/beam/scan/__init__.py @@ -13,6 +13,17 @@ from frappe.query_builder.functions import Coalesce +# Frappe v16 rejects string-form SQL functions in SELECT and requires the dict form; v15 has no +# dict-function support and requires the string form. The two are mutually exclusive, so the +# aggregate field for get_handling_unit must be chosen by version to keep BEAM v15/v16 compatible. +_FRAPPE_MAJOR = int(frappe.__version__.split(".")[0]) +_STOCK_QTY_FIELD = ( + {"SUM": "actual_qty", "as": "stock_qty"} + if _FRAPPE_MAJOR >= 16 + else "SUM(actual_qty) as stock_qty" +) + + _INV_DIM_CACHE_KEY = "beam:inv_dim_source_fieldnames" @@ -125,7 +136,7 @@ def get_handling_unit( filters={"handling_unit": handling_unit, "is_cancelled": 0}, fields=[ "item_code", - {"SUM": "actual_qty", "as": "stock_qty"}, + _STOCK_QTY_FIELD, "company", "handling_unit", "voucher_no", diff --git a/beam/tests/test_handling_unit.py b/beam/tests/test_handling_unit.py index 76b5d9b0..bdd5be71 100644 --- a/beam/tests/test_handling_unit.py +++ b/beam/tests/test_handling_unit.py @@ -9,6 +9,7 @@ make_subcontracting_receipt, ) +from beam.beam.handling_unit import is_scrap_item from beam.beam.scan import get_handling_unit @@ -293,8 +294,8 @@ def test_stock_entry_for_manufacture(): "Item", row.item_code, "enable_handling_unit" ): continue - if ( - row.is_finished_item or row.is_scrap_item + if row.is_finished_item or is_scrap_item( + row ): # finished and scrap items' handling units will be generated and wouldn't be scanned continue hu = frappe.get_value( @@ -321,12 +322,12 @@ def test_stock_entry_for_manufacture(): sle = frappe.get_doc( "Stock Ledger Entry", {"voucher_detail_no": row.name, "handling_unit": row.handling_unit} ) - if not row.is_finished_item and not row.is_scrap_item: + if not row.is_finished_item and not is_scrap_item(row): assert row.transfer_qty == -(sle.actual_qty) assert row.item_code == sle.item_code assert row.s_warehouse == sle.warehouse # source/ warehouse assert sle.handling_unit == row.handling_unit - elif row.is_scrap_item: + elif is_scrap_item(row): assert row.transfer_qty == sle.actual_qty assert row.item_code == sle.item_code create_handling_unit = frappe.get_value( diff --git a/beam/tests/test_inventory_dimension.py b/beam/tests/test_inventory_dimension.py index 52eb18ae..d00af6ef 100644 --- a/beam/tests/test_inventory_dimension.py +++ b/beam/tests/test_inventory_dimension.py @@ -125,12 +125,8 @@ def test_propagate_copies_source_to_empty_target(): pytest.skip("Handling units not enabled") original_cf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") - original_tf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "target_fieldname") try: frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) - frappe.db.set_value( - "Inventory Dimension", "Handling Unit", "target_fieldname", "to_handling_unit" - ) clear_inv_dim_cache() doc = frappe.new_doc("Stock Entry") @@ -150,7 +146,6 @@ def test_propagate_copies_source_to_empty_target(): frappe.db.set_value( "Inventory Dimension", "Handling Unit", "custom_carry_forward", original_cf or 0 ) - frappe.db.set_value("Inventory Dimension", "Handling Unit", "target_fieldname", original_tf) clear_inv_dim_cache() @@ -336,12 +331,8 @@ def test_hu_carry_forward_full_transfer_conserves_hu(): source_hu = se_receipt.items[0].handling_unit original_cf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "custom_carry_forward") - original_tf = frappe.db.get_value("Inventory Dimension", "Handling Unit", "target_fieldname") try: frappe.db.set_value("Inventory Dimension", "Handling Unit", "custom_carry_forward", 1) - frappe.db.set_value( - "Inventory Dimension", "Handling Unit", "target_fieldname", "to_handling_unit" - ) clear_inv_dim_cache() se_transfer = frappe.new_doc("Stock Entry") @@ -376,7 +367,6 @@ def test_hu_carry_forward_full_transfer_conserves_hu(): frappe.db.set_value( "Inventory Dimension", "Handling Unit", "custom_carry_forward", original_cf or 0 ) - frappe.db.set_value("Inventory Dimension", "Handling Unit", "target_fieldname", original_tf) clear_inv_dim_cache() From 2845b39b4b1ce02d9ffda27fd4c8281bd0190d98 Mon Sep 17 00:00:00 2001 From: Robert Duncan Date: Fri, 24 Jul 2026 01:36:23 +0000 Subject: [PATCH 5/5] fix: backwards compatibility for is_scrap_item --- beam/beam/handling_unit.py | 11 ++++++++--- beam/install.py | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/beam/beam/handling_unit.py b/beam/beam/handling_unit.py index 86624c3b..a32b7aaf 100644 --- a/beam/beam/handling_unit.py +++ b/beam/beam/handling_unit.py @@ -15,9 +15,14 @@ def is_scrap_item(row): - # ERPNext v16 removed `is_scrap_item` from Stock Entry Detail: scrap outputs are now - # marked via `type == "Scrap"`, with the old flag preserved as `is_legacy_scrap_item`. - return bool(row.get("is_legacy_scrap_item") or row.get("type") == "Scrap") + # ERPNext is migrating the Stock Entry Detail scrap marker across versions, so check every + # form: v15 (and transitional v16 builds that still ship `BOM Scrap Item`) populate the + # `is_scrap_item` field; later v16 removed that field and marks scrap outputs via + # `type == "Scrap"`, preserving the old flag as `is_legacy_scrap_item`. `row.get` returns + # None for fields absent on the running version, so this stays safe everywhere. + return bool( + row.get("is_scrap_item") or row.get("is_legacy_scrap_item") or row.get("type") == "Scrap" + ) @frappe.whitelist() diff --git a/beam/install.py b/beam/install.py index 6fb8c644..1eb7a329 100644 --- a/beam/install.py +++ b/beam/install.py @@ -1,6 +1,8 @@ # Copyright (c) 2025, AgriTheory and contributors # For license information, please see license.txt +import frappe + from beam.beam.inventory_dimension import setup_inventory_dimensions