From ca5097662feddf99b4ad04676912f5993bdc9513 Mon Sep 17 00:00:00 2001 From: Robert Duncan Date: Fri, 17 Apr 2026 02:41:45 +0000 Subject: [PATCH 1/2] 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/2] 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",