From 700a7fdad3f32b2f5485eccbb83c35c7f1890623 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 14:45:21 +0530 Subject: [PATCH 01/21] test(stock): add ledger characterization snapshots Phase 0 safety net for the stock_controller service refactor. Captures the combined GL + Stock Ledger output of representative stock vouchers (DN, Stock Entry, Stock Reconciliation, Purchase Receipt incl. returns/taxes) as golden snapshots, so later phases can prove ledger behaviour stays byte-identical while stock_controller is split into services. Run: bench --site run-tests --module erpnext.stock.test_ledger_characterization Regenerate goldens: REGEN_LEDGER_SNAPSHOTS=1 (after intentional changes only). --- erpnext/stock/ledger_snapshot.py | 170 +++++++++++++++++ erpnext/stock/ledger_snapshots/dn_basic.json | 46 +++++ erpnext/stock/ledger_snapshots/dn_return.json | 46 +++++ erpnext/stock/ledger_snapshots/pr_basic.json | 46 +++++ erpnext/stock/ledger_snapshots/pr_return.json | 46 +++++ .../stock/ledger_snapshots/pr_with_taxes.json | 74 ++++++++ .../ledger_snapshots/se_material_issue.json | 46 +++++ .../ledger_snapshots/se_material_receipt.json | 46 +++++ .../se_material_transfer.json | 29 +++ erpnext/stock/ledger_snapshots/sr_basic.json | 46 +++++ erpnext/stock/test_ledger_characterization.py | 178 ++++++++++++++++++ 11 files changed, 773 insertions(+) create mode 100644 erpnext/stock/ledger_snapshot.py create mode 100644 erpnext/stock/ledger_snapshots/dn_basic.json create mode 100644 erpnext/stock/ledger_snapshots/dn_return.json create mode 100644 erpnext/stock/ledger_snapshots/pr_basic.json create mode 100644 erpnext/stock/ledger_snapshots/pr_return.json create mode 100644 erpnext/stock/ledger_snapshots/pr_with_taxes.json create mode 100644 erpnext/stock/ledger_snapshots/se_material_issue.json create mode 100644 erpnext/stock/ledger_snapshots/se_material_receipt.json create mode 100644 erpnext/stock/ledger_snapshots/se_material_transfer.json create mode 100644 erpnext/stock/ledger_snapshots/sr_basic.json create mode 100644 erpnext/stock/test_ledger_characterization.py diff --git a/erpnext/stock/ledger_snapshot.py b/erpnext/stock/ledger_snapshot.py new file mode 100644 index 00000000000..977ede74ed0 --- /dev/null +++ b/erpnext/stock/ledger_snapshot.py @@ -0,0 +1,170 @@ +"""Golden-master snapshot harness for ledger characterization tests. + +Captures the General Ledger *and* Stock Ledger entries produced by a submitted +voucher in a normalized, deterministic form and compares them against a stored +golden snapshot. Volatile fields (name, creation, voucher number, serial/batch +bundle id) are stripped so the snapshot is stable across runs. + +This is the Phase 0 safety net for the stock_controller refactor: every later +phase must keep these snapshots byte-identical. Regenerate goldens with:: + + REGEN_LEDGER_SNAPSHOTS=1 bench run-tests --site test-erpnext-v17 \\ + --module erpnext.stock.test_ledger_characterization +""" + +import json +import os +from pathlib import Path + +import frappe +from frappe.utils import flt + +SNAPSHOT_DIR = Path(__file__).parent / "ledger_snapshots" +REGEN_ENV = "REGEN_LEDGER_SNAPSHOTS" +GL_PRECISION = 2 +QTY_PRECISION = 6 +RATE_PRECISION = 4 + + +class GLSnapshot: + """Normalized, order-stable view of a voucher's GL entries.""" + + def __init__(self, voucher_type: str, voucher_no: str) -> None: + self.voucher_type = voucher_type + self.voucher_no = voucher_no + + def capture(self) -> list[dict]: + rows = [self._normalize(row) for row in self._fetch_rows()] + # Sort on the full normalized row so ordering never depends on the DB's + # return order. + return sorted(rows, key=lambda row: json.dumps(row, sort_keys=True)) + + def _fetch_rows(self) -> list[dict]: + gl = frappe.qb.DocType("GL Entry") + query = ( + frappe.qb.from_(gl) + .select( + gl.account, + gl.party_type, + gl.party, + gl.debit, + gl.credit, + gl.debit_in_account_currency, + gl.credit_in_account_currency, + gl.account_currency, + gl.against, + gl.cost_center, + gl.is_opening, + gl.posting_date, + ) + .where( + (gl.voucher_type == self.voucher_type) + & (gl.voucher_no == self.voucher_no) + & (gl.is_cancelled == 0) + ) + .orderby(gl.account, gl.party, gl.debit, gl.credit) + ) + return query.run(as_dict=True) + + def _normalize(self, row: dict) -> dict: + return { + "account": row.account, + "party_type": row.party_type or None, + "party": row.party or None, + "debit": flt(row.debit, GL_PRECISION), + "credit": flt(row.credit, GL_PRECISION), + "debit_in_account_currency": flt(row.debit_in_account_currency, GL_PRECISION), + "credit_in_account_currency": flt(row.credit_in_account_currency, GL_PRECISION), + "account_currency": row.account_currency, + "against": self._normalize_against(row.against), + "cost_center": row.cost_center, + "is_opening": row.is_opening, + "posting_date": str(row.posting_date), + } + + def _normalize_against(self, against: str | None) -> str | None: + """`against` is a comma-joined account list whose order is not stable.""" + if not against: + return None + return ", ".join(sorted(part.strip() for part in against.split(","))) + + +class SLSnapshot: + """Normalized, order-stable view of a voucher's Stock Ledger entries.""" + + def __init__(self, voucher_type: str, voucher_no: str) -> None: + self.voucher_type = voucher_type + self.voucher_no = voucher_no + + def capture(self) -> list[dict]: + rows = [self._normalize(row) for row in self._fetch_rows()] + return sorted(rows, key=lambda row: json.dumps(row, sort_keys=True)) + + def _fetch_rows(self) -> list[dict]: + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select( + sle.item_code, + sle.warehouse, + sle.stock_uom, + sle.actual_qty, + sle.qty_after_transaction, + sle.incoming_rate, + sle.valuation_rate, + sle.stock_value, + sle.stock_value_difference, + sle.posting_date, + ) + .where( + (sle.voucher_type == self.voucher_type) + & (sle.voucher_no == self.voucher_no) + & (sle.is_cancelled == 0) + ) + .orderby(sle.item_code, sle.warehouse, sle.actual_qty) + ) + return query.run(as_dict=True) + + def _normalize(self, row: dict) -> dict: + return { + "item_code": row.item_code, + "warehouse": row.warehouse, + "stock_uom": row.stock_uom, + "actual_qty": flt(row.actual_qty, QTY_PRECISION), + "qty_after_transaction": flt(row.qty_after_transaction, QTY_PRECISION), + "incoming_rate": flt(row.incoming_rate, RATE_PRECISION), + "valuation_rate": flt(row.valuation_rate, RATE_PRECISION), + "stock_value": flt(row.stock_value, RATE_PRECISION), + "stock_value_difference": flt(row.stock_value_difference, RATE_PRECISION), + "posting_date": str(row.posting_date), + } + + +def capture_ledger_snapshot(voucher_type: str, voucher_no: str) -> dict: + """Combined GL + SLE snapshot for a single voucher.""" + return { + "gl": GLSnapshot(voucher_type, voucher_no).capture(), + "sle": SLSnapshot(voucher_type, voucher_no).capture(), + } + + +def assert_ledger_snapshot(test_case, name: str, voucher_type: str, voucher_no: str) -> None: + """Compare a voucher's GL + SLE entries against the golden snapshot ``name``. + + In regen mode (``REGEN_LEDGER_SNAPSHOTS`` set) the golden file is written + instead of asserted, so the same scenarios both produce and verify the goldens. + """ + actual = capture_ledger_snapshot(voucher_type, voucher_no) + path = SNAPSHOT_DIR / f"{name}.json" + + if os.environ.get(REGEN_ENV): + SNAPSHOT_DIR.mkdir(exist_ok=True) + path.write_text(json.dumps(actual, indent="\t", sort_keys=True) + "\n") + return + + test_case.assertTrue( + path.exists(), + f"Golden snapshot {path} missing. Run with {REGEN_ENV}=1 to create it.", + ) + expected = json.loads(path.read_text()) + test_case.assertEqual(expected, actual, f"Ledger snapshot mismatch for '{name}'") diff --git a/erpnext/stock/ledger_snapshots/dn_basic.json b/erpnext/stock/ledger_snapshots/dn_basic.json new file mode 100644 index 00000000000..ec5d8c1c1ee --- /dev/null +++ b/erpnext/stock/ledger_snapshots/dn_basic.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock Delivered But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Delivered But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": -5.0, + "incoming_rate": 0.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": -5.0, + "stock_uom": "_Test UOM", + "stock_value": -500.0, + "stock_value_difference": -500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/dn_return.json b/erpnext/stock/ledger_snapshots/dn_return.json new file mode 100644 index 00000000000..e5dbe2be296 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/dn_return.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock Delivered But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Delivered But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 5.0, + "incoming_rate": 100.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": -5.0, + "stock_uom": "_Test UOM", + "stock_value": -500.0, + "stock_value_difference": 500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/pr_basic.json b/erpnext/stock/ledger_snapshots/pr_basic.json new file mode 100644 index 00000000000..8cbc6763694 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/pr_basic.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Received But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock Received But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 5.0, + "incoming_rate": 100.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 0.0, + "stock_uom": "_Test UOM", + "stock_value": 0.0, + "stock_value_difference": 500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/pr_return.json b/erpnext/stock/ledger_snapshots/pr_return.json new file mode 100644 index 00000000000..830593473f1 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/pr_return.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Received But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock Received But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": -5.0, + "incoming_rate": 0.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 0.0, + "stock_uom": "_Test UOM", + "stock_value": 0.0, + "stock_value_difference": -500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/pr_with_taxes.json b/erpnext/stock/ledger_snapshots/pr_with_taxes.json new file mode 100644 index 00000000000..71e51a92f09 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/pr_with_taxes.json @@ -0,0 +1,74 @@ +{ + "gl": [ + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Received But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 750.0, + "debit_in_account_currency": 750.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock Received But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Customs Duty - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 150.0, + "credit_in_account_currency": 150.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Shipping Charges - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 100.0, + "credit_in_account_currency": 100.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 5.0, + "incoming_rate": 150.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 5.0, + "stock_uom": "_Test UOM", + "stock_value": 750.0, + "stock_value_difference": 750.0, + "valuation_rate": 150.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/se_material_issue.json b/erpnext/stock/ledger_snapshots/se_material_issue.json new file mode 100644 index 00000000000..0f8298b4c91 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/se_material_issue.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock Adjustment - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 750.0, + "debit_in_account_currency": 750.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Adjustment - TCP1", + "cost_center": "Main - TCP1", + "credit": 750.0, + "credit_in_account_currency": 750.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": -5.0, + "incoming_rate": 0.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 0.0, + "stock_uom": "_Test UOM", + "stock_value": 0.0, + "stock_value_difference": -750.0, + "valuation_rate": 150.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/se_material_receipt.json b/erpnext/stock/ledger_snapshots/se_material_receipt.json new file mode 100644 index 00000000000..7697282b7f9 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/se_material_receipt.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock Adjustment - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Adjustment - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 5.0, + "incoming_rate": 100.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 5.0, + "stock_uom": "_Test UOM", + "stock_value": 500.0, + "stock_value_difference": 500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/se_material_transfer.json b/erpnext/stock/ledger_snapshots/se_material_transfer.json new file mode 100644 index 00000000000..08b5429f3d5 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/se_material_transfer.json @@ -0,0 +1,29 @@ +{ + "gl": [], + "sle": [ + { + "actual_qty": -5.0, + "incoming_rate": 0.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 0.0, + "stock_uom": "_Test UOM", + "stock_value": 0.0, + "stock_value_difference": -500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + }, + { + "actual_qty": 5.0, + "incoming_rate": 100.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 5.0, + "stock_uom": "_Test UOM", + "stock_value": 500.0, + "stock_value_difference": 500.0, + "valuation_rate": 100.0, + "warehouse": "Finished Goods - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/sr_basic.json b/erpnext/stock/ledger_snapshots/sr_basic.json new file mode 100644 index 00000000000..f8a92672ebf --- /dev/null +++ b/erpnext/stock/ledger_snapshots/sr_basic.json @@ -0,0 +1,46 @@ +{ + "gl": [ + { + "account": "Stock Adjustment - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 1500.0, + "credit_in_account_currency": 1500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Adjustment - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1500.0, + "debit_in_account_currency": 1500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 0.0, + "incoming_rate": 0.0, + "item_code": "_Test Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 10.0, + "stock_uom": "_Test UOM", + "stock_value": 1500.0, + "stock_value_difference": 1500.0, + "valuation_rate": 150.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/test_ledger_characterization.py b/erpnext/stock/test_ledger_characterization.py new file mode 100644 index 00000000000..d9fdd0fe7f0 --- /dev/null +++ b/erpnext/stock/test_ledger_characterization.py @@ -0,0 +1,178 @@ +"""Phase 0 characterization tests for the stock_controller refactor. + +These are golden-master snapshot tests: each scenario builds a representative +stock voucher, submits it, and compares its GL *and* Stock Ledger entries against +a stored snapshot (see ``erpnext/stock/ledger_snapshots``). They assert nothing +about *correct* accounting or valuation — only that ledger output stays +byte-identical as ``stock_controller`` is split into services. + +Regenerate goldens after an intentional change:: + + REGEN_LEDGER_SNAPSHOTS=1 bench run-tests --site test-erpnext-v17 \\ + --module erpnext.stock.test_ledger_characterization +""" + +import frappe +from frappe.tests import IntegrationTestCase + +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.ledger_snapshot import assert_ledger_snapshot + +POSTING_DATE = "2024-01-15" +CUSTOMER = "_Test Customer" +COMPANY = "_Test Company with perpetual inventory" +WAREHOUSE = "Stores - TCP1" + + +class TestLedgerCharacterization(IntegrationTestCase): + def test_dn_basic(self): + make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100) + dn = _make_dated_delivery_note(qty=5, rate=150) + dn.insert() + dn.submit() + assert_ledger_snapshot(self, "dn_basic", "Delivery Note", dn.name) + + def test_dn_return(self): + make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100) + original = _make_dated_delivery_note(qty=5, rate=150) + original.insert() + original.submit() + + ret = frappe.copy_doc(original) + ret.is_return = 1 + ret.return_against = original.name + for item in ret.items: + item.qty = -item.qty + ret.set_posting_time = 1 + ret.posting_date = POSTING_DATE + ret.insert() + ret.submit() + assert_ledger_snapshot(self, "dn_return", "Delivery Note", ret.name) + + def test_se_material_receipt(self): + se = make_stock_entry( + item_code="_Test Item", + target=WAREHOUSE, + qty=5, + basic_rate=100, + company=COMPANY, + posting_date=POSTING_DATE, + do_not_submit=True, + ) + se.submit() + assert_ledger_snapshot(self, "se_material_receipt", "Stock Entry", se.name) + + def test_se_material_issue(self): + make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, company=COMPANY) + se = make_stock_entry( + item_code="_Test Item", + source=WAREHOUSE, + qty=5, + company=COMPANY, + posting_date=POSTING_DATE, + do_not_submit=True, + ) + se.submit() + assert_ledger_snapshot(self, "se_material_issue", "Stock Entry", se.name) + + def test_se_material_transfer(self): + make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, company=COMPANY) + se = make_stock_entry( + item_code="_Test Item", + source=WAREHOUSE, + target="Finished Goods - TCP1", + qty=5, + company=COMPANY, + posting_date=POSTING_DATE, + do_not_submit=True, + ) + se.submit() + assert_ledger_snapshot(self, "se_material_transfer", "Stock Entry", se.name) + + def test_sr_basic(self): + sr = _make_dated_stock_reconciliation(qty=10, rate=150) + sr.insert() + sr.submit() + assert_ledger_snapshot(self, "sr_basic", "Stock Reconciliation", sr.name) + + def test_pr_basic(self): + pr = make_purchase_receipt( + company=COMPANY, warehouse=WAREHOUSE, posting_date=POSTING_DATE, qty=5, rate=100 + ) + assert_ledger_snapshot(self, "pr_basic", "Purchase Receipt", pr.name) + + def test_pr_with_taxes(self): + pr = make_purchase_receipt( + company=COMPANY, + warehouse=WAREHOUSE, + posting_date=POSTING_DATE, + qty=5, + rate=100, + get_taxes_and_charges=True, + ) + assert_ledger_snapshot(self, "pr_with_taxes", "Purchase Receipt", pr.name) + + def test_pr_return(self): + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return + + original = make_purchase_receipt( + company=COMPANY, warehouse=WAREHOUSE, posting_date=POSTING_DATE, qty=5, rate=100 + ) + ret = make_purchase_return(original.name) + ret.posting_date = POSTING_DATE + ret.set_posting_time = 1 + ret.insert() + ret.submit() + assert_ledger_snapshot(self, "pr_return", "Purchase Receipt", ret.name) + + +def _make_dated_delivery_note(**args) -> frappe.Document: + """Minimal Delivery Note on a fixed posting date using the perpetual-inventory + test company. + + Inlined to avoid importing test_delivery_note which drags in conflicting + test-record dependencies at discovery time.""" + dn = frappe.new_doc("Delivery Note") + dn.company = COMPANY + dn.customer = CUSTOMER + dn.posting_date = POSTING_DATE + dn.set_posting_time = 1 + dn.append( + "items", + { + "item_code": args.get("item_code", "_Test Item"), + "warehouse": args.get("warehouse", WAREHOUSE), + "qty": args.get("qty", 1), + "rate": args.get("rate", 100), + "expense_account": "Cost of Goods Sold - TCP1", + "cost_center": "Main - TCP1", + }, + ) + return dn + + +def _make_dated_stock_reconciliation(**args) -> frappe.Document: + """Minimal Stock Reconciliation on a fixed posting date using the perpetual-inventory + test company. + + Inlined to avoid importing test_stock_reconciliation which drags in conflicting + test-record dependencies at discovery time.""" + sr = frappe.new_doc("Stock Reconciliation") + sr.company = COMPANY + sr.purpose = args.get("purpose", "Stock Reconciliation") + sr.posting_date = POSTING_DATE + sr.posting_time = "00:00:00" + sr.set_posting_time = 1 + sr.expense_account = frappe.get_cached_value("Company", COMPANY, "stock_adjustment_account") + sr.cost_center = frappe.get_cached_value("Company", COMPANY, "cost_center") + sr.append( + "items", + { + "item_code": args.get("item_code", "_Test Item"), + "warehouse": args.get("warehouse", WAREHOUSE), + "qty": args.get("qty", 10), + "valuation_rate": args.get("rate", 100), + }, + ) + return sr From a26d8d448c13478b7fd205b62804816245b0cbf4 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 15:00:44 +0530 Subject: [PATCH 02/21] refactor(stock): extract SerialBatchBundleService from StockController Move serial & batch bundle handling (creation, validation, return bundles, teardown) out of StockController into erpnext/stock/services/serial_batch_bundle.py as a delegating service. The controller keeps thin delegators for the 10 methods reached from other doctypes or run_method; the 12 internal-only helpers live in the service. make_bundle_for_material_transfer stays a module fn in stock_controller (imported by stock/serial_batch_bundle.py). Behaviour-preserving: ledger characterization snapshots and the full Serial and Batch Bundle test suite stay green. --- erpnext/controllers/stock_controller.py | 654 +--------------- erpnext/stock/services/serial_batch_bundle.py | 695 ++++++++++++++++++ 2 files changed, 722 insertions(+), 627 deletions(-) create mode 100644 erpnext/stock/services/serial_batch_bundle.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index ce7422ed2d5..833e28876a5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -77,43 +77,9 @@ class StockController(AccountsController): self.check_zero_rate() def validate_warehouse_of_sabb(self): - if self.is_internal_transfer(): - return + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - doc_before_save = self.get_doc_before_save() - - for row in self.items: - if not row.get("serial_and_batch_bundle"): - continue - - sabb_details = frappe.db.get_value( - "Serial and Batch Bundle", - row.serial_and_batch_bundle, - ["type_of_transaction", "warehouse", "has_serial_no"], - as_dict=True, - ) - if not sabb_details: - continue - - if sabb_details.type_of_transaction != "Outward": - continue - - warehouse = row.get("warehouse") or row.get("s_warehouse") - if sabb_details.warehouse != warehouse: - frappe.throw( - _( - "Row #{0}: Warehouse {1} does not match with the warehouse {2} in Serial and Batch Bundle {3}." - ).format(row.idx, warehouse, sabb_details.warehouse, row.serial_and_batch_bundle) - ) - - if self.doctype == "Stock Reconciliation": - continue - - if sabb_details.has_serial_no and doc_before_save and doc_before_save.get("items"): - prev_row = doc_before_save.get("items", {"idx": row.idx}) - if prev_row and prev_row[0].serial_and_batch_bundle != row.serial_and_batch_bundle: - sabb_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) - sabb_doc.validate_serial_no_status() + return SerialBatchBundleService(self).validate_warehouse_of_sabb() def reset_conversion_factor(self): for row in self.get("items"): @@ -170,37 +136,9 @@ class StockController(AccountsController): frappe.throw(_("Items {0} do not exist in the Item master.").format(", ".join(non_exists_items))) def validate_duplicate_serial_and_batch_bundle(self, table_name): - if not self.get(table_name): - return + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - sbb_list = [] - for item in self.get(table_name): - if item.get("serial_and_batch_bundle"): - sbb_list.append(item.get("serial_and_batch_bundle")) - - if item.get("rejected_serial_and_batch_bundle"): - sbb_list.append(item.get("rejected_serial_and_batch_bundle")) - - if sbb_list: - SLE = frappe.qb.DocType("Stock Ledger Entry") - data = ( - frappe.qb.from_(SLE) - .select(SLE.voucher_type, SLE.voucher_no, SLE.serial_and_batch_bundle) - .where( - (SLE.docstatus == 1) - & (SLE.serial_and_batch_bundle.notnull()) - & (SLE.serial_and_batch_bundle.isin(sbb_list)) - ) - .limit(1) - ).run(as_dict=True) - - if data: - data = data[0] - frappe.throw( - _("Serial and Batch Bundle {0} is already used in {1} {2}.").format( - frappe.bold(data.serial_and_batch_bundle), data.voucher_type, data.voucher_no - ) - ) + return SerialBatchBundleService(self).validate_duplicate_serial_and_batch_bundle(table_name) def get_item_wise_inventory_account_map(self): inventory_account_map = frappe._dict() @@ -287,405 +225,31 @@ class StockController(AccountsController): make_gl_entries(gl_entries, from_repost=from_repost) def validate_serialized_batch(self): - from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - is_material_issue = False - if self.doctype == "Stock Entry" and self.purpose in ["Material Issue", "Material Transfer"]: - is_material_issue = True - - for d in self.get("items"): - if hasattr(d, "serial_no") and hasattr(d, "batch_no") and d.serial_no and d.batch_no: - serial_nos = frappe.get_all( - "Serial No", - fields=["batch_no", "name", "warehouse"], - filters={"name": ("in", get_serial_nos(d.serial_no))}, - ) - - for row in serial_nos: - if row.warehouse and row.batch_no != d.batch_no: - frappe.throw( - _("Row #{0}: Serial No {1} does not belong to Batch {2}").format( - d.idx, row.name, d.batch_no - ) - ) - - if is_material_issue: - continue - - if flt(d.qty) > 0.0 and d.get("batch_no") and self.get("posting_date") and self.docstatus < 2: - expiry_date = frappe.get_cached_value("Batch", d.get("batch_no"), "expiry_date") - - if expiry_date and getdate(expiry_date) < getdate(self.posting_date): - frappe.throw( - _("Row #{0}: The batch {1} has already expired.").format( - d.idx, get_link_to_form("Batch", d.get("batch_no")) - ), - BatchExpiredError, - ) + return SerialBatchBundleService(self).validate_serialized_batch() def clean_serial_nos(self): - from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - for row in self.get("items"): - if hasattr(row, "serial_no") and row.serial_no: - # remove extra whitespace and store one serial no on each line - row.serial_no = clean_serial_no_string(row.serial_no) - - for row in self.get("packed_items") or []: - if hasattr(row, "serial_no") and row.serial_no: - # remove extra whitespace and store one serial no on each line - row.serial_no = clean_serial_no_string(row.serial_no) + return SerialBatchBundleService(self).clean_serial_nos() def make_bundle_using_old_serial_batch_fields(self, table_name=None, via_landed_cost_voucher=False): - if self.get("_action") == "update_after_submit": - return + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - # To handle test cases - if frappe.in_test and frappe.flags.use_serial_and_batch_fields: - return - - if not table_name: - table_name = "items" - - if self.doctype == "Asset Capitalization": - table_name = "stock_items" - - parent_details = frappe._dict() - if table_name == "packed_items": - parent_details = self.get_parent_details_for_packed_items() - - for row in self.get(table_name): - if ( - not via_landed_cost_voucher - and row.serial_and_batch_bundle - and (row.serial_no or row.batch_no) - ): - self.validate_serial_nos_and_batches_with_bundle(row) - - if not row.serial_no and not row.batch_no and not row.get("rejected_serial_no"): - continue - - if not row.use_serial_batch_fields and ( - row.serial_no or row.batch_no or row.get("rejected_serial_no") - ): - row.use_serial_batch_fields = 1 - - if row.use_serial_batch_fields and ( - not row.serial_and_batch_bundle and not row.get("rejected_serial_and_batch_bundle") - ): - bundle_details = { - "item_code": row.get("rm_item_code") or row.item_code, - "posting_datetime": combine_datetime(self.posting_date, self.posting_time), - "voucher_type": self.doctype, - "voucher_no": self.name, - "voucher_detail_no": row.name, - "company": self.company, - "is_rejected": 1 if row.get("rejected_warehouse") else 0, - "use_serial_batch_fields": row.use_serial_batch_fields, - "via_landed_cost_voucher": via_landed_cost_voucher, - "do_not_submit": True if not via_landed_cost_voucher else False, - } - - if self.is_internal_transfer() and row.get("from_warehouse") and not self.is_return: - self.update_bundle_details(bundle_details, table_name, row) - bundle_details["type_of_transaction"] = "Outward" - bundle_details["warehouse"] = row.get("from_warehouse") - bundle_details["qty"] = row.get("stock_qty") or row.get("qty") - self.create_serial_batch_bundle(bundle_details, row) - continue - - if row.get("qty") or row.get("consumed_qty") or row.get("stock_qty"): - self.update_bundle_details(bundle_details, table_name, row, parent_details=parent_details) - self.create_serial_batch_bundle(bundle_details, row) - - if row.get("rejected_qty"): - self.update_bundle_details(bundle_details, table_name, row, is_rejected=True) - self.create_serial_batch_bundle(bundle_details, row) - - def get_parent_details_for_packed_items(self): - parent_details = frappe._dict() - for row in self.get("items"): - parent_details[row.name] = row - - return parent_details + return SerialBatchBundleService(self).make_bundle_using_old_serial_batch_fields( + table_name, via_landed_cost_voucher + ) def make_bundle_for_sales_purchase_return(self, table_name=None): - if not self.get("is_return"): - return + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - if not table_name: - table_name = "items" - - self.make_bundle_for_non_rejected_qty(table_name) - - if self.doctype in ["Purchase Invoice", "Purchase Receipt"]: - self.make_bundle_for_rejected_qty(table_name) - - def make_bundle_for_rejected_qty(self, table_name=None): - field, reference_ids = self.get_reference_ids( - table_name, "rejected_qty", "rejected_serial_and_batch_bundle" - ) - - if not reference_ids: - return - - child_doctype = self.doctype + " Item" - available_dict = available_serial_batch_for_return( - field, child_doctype, reference_ids, is_rejected=True - ) - - for row in self.get(table_name): - if data := available_dict.get(row.get(field)): - qty_field = "rejected_qty" - warehouse_field = "rejected_warehouse" - if row.get("return_qty_from_rejected_warehouse"): - qty_field = "qty" - warehouse_field = "warehouse" - - if not data.get("qty"): - frappe.throw( - _("For the {0}, no stock is available for the return in the warehouse {1}.").format( - frappe.bold(row.item_code), row.get(warehouse_field) - ) - ) - - data = filter_serial_batches( - self, data, row, warehouse_field=warehouse_field, qty_field=qty_field - ) - bundle = make_serial_batch_bundle_for_return(data, row, self, warehouse_field, qty_field) - if row.get("return_qty_from_rejected_warehouse"): - row.db_set( - { - "serial_and_batch_bundle": bundle, - "batch_no": "", - "serial_no": "", - } - ) - else: - row.db_set( - { - "rejected_serial_and_batch_bundle": bundle, - "batch_no": "", - "rejected_serial_no": "", - } - ) - - def make_bundle_for_non_rejected_qty(self, table_name): - field, reference_ids = self.get_reference_ids(table_name) - if not reference_ids: - return - - child_doctype = self.doctype + " Item" - if table_name == "packed_items": - field = "parent_detail_docname" - child_doctype = "Packed Item" - - available_dict = available_serial_batch_for_return(field, child_doctype, reference_ids) - - for row in self.get(table_name): - value = row.get(field) - if table_name == "packed_items" and row.get("parent_detail_docname"): - value = self.get_value_for_packed_item(row) - if not value: - continue - - if data := available_dict.get(value): - data = filter_serial_batches(self, data, row) - bundle = make_serial_batch_bundle_for_return(data, row, self) - row.db_set( - { - "serial_and_batch_bundle": bundle, - "batch_no": "", - "serial_no": "", - } - ) - - if self.doctype in ["Sales Invoice", "Delivery Note"]: - row.db_set( - "incoming_rate", frappe.db.get_value("Serial and Batch Bundle", bundle, "avg_rate") - ) - - def get_value_for_packed_item(self, row): - parent_items = self.get("items", {"name": row.parent_detail_docname}) - if parent_items: - ref = parent_items[0].get("dn_detail") - return (row.item_code, ref) - - return None - - def get_reference_ids(self, table_name, qty_field=None, bundle_field=None) -> tuple[str, list[str]]: - field = { - "Sales Invoice": "sales_invoice_item", - "Delivery Note": "dn_detail", - "Purchase Receipt": "purchase_receipt_item", - "Purchase Invoice": "purchase_invoice_item", - "POS Invoice": "pos_invoice_item", - }.get(self.doctype) - - if not bundle_field: - bundle_field = "serial_and_batch_bundle" - - if not qty_field: - qty_field = "qty" - - reference_ids = [] - - for row in self.get(table_name): - if not self.is_serial_batch_item(row.item_code): - continue - - if ( - row.get(field) - and ( - qty_field == "qty" - and not row.get("return_qty_from_rejected_warehouse") - or qty_field == "rejected_qty" - and (row.get("return_qty_from_rejected_warehouse") or row.get("rejected_warehouse")) - ) - and not row.get("use_serial_batch_fields") - and not row.get(bundle_field) - ): - reference_ids.append(row.get(field)) - - if table_name == "packed_items" and row.get("parent_detail_docname"): - parent_rows = self.get("items", {"name": row.parent_detail_docname}) or [] - for d in parent_rows: - if d.get(field) and not d.get(bundle_field): - reference_ids.append(d.get(field)) - - return field, reference_ids - - @frappe.request_cache - def is_serial_batch_item(self, item_code) -> bool: - if not frappe.db.exists("Item", item_code): - frappe.throw(_("Item {0} does not exist.").format(bold(item_code))) - - item_details = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) - - if item_details.has_serial_no or item_details.has_batch_no: - return True - - return False - - def update_bundle_details(self, bundle_details, table_name, row, is_rejected=False, parent_details=None): - from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - - # Since qty field is different for different doctypes - qty = row.get("qty") - warehouse = row.get("warehouse") - - if table_name == "packed_items": - type_of_transaction = "Inward" - if not self.is_return: - type_of_transaction = "Outward" - elif table_name == "supplied_items": - qty = row.consumed_qty - warehouse = self.supplier_warehouse - type_of_transaction = "Outward" - if self.is_return: - type_of_transaction = "Inward" - else: - type_of_transaction = get_type_of_transaction(self, row) - - if hasattr(row, "stock_qty"): - qty = row.stock_qty - - if self.doctype == "Stock Entry": - qty = row.transfer_qty - warehouse = row.s_warehouse or row.t_warehouse - - serial_nos = row.serial_no - if is_rejected: - serial_nos = row.get("rejected_serial_no") - type_of_transaction = "Inward" if not self.is_return else "Outward" - qty = flt( - row.get("rejected_qty") * row.get("conversion_factor", 1.0), - frappe.get_precision("Serial and Batch Entry", "qty"), - ) - warehouse = row.get("rejected_warehouse") - - if ( - self.is_internal_transfer() - and self.doctype in ["Sales Invoice", "Delivery Note"] - and self.is_return - ): - warehouse = row.get("target_warehouse") or row.get("warehouse") - type_of_transaction = "Outward" - - if table_name == "packed_items": - if not warehouse: - warehouse = parent_details[row.parent_detail_docname].warehouse - bundle_details["voucher_detail_no"] = parent_details[row.parent_detail_docname].name - - bundle_details.update( - { - "qty": qty, - "is_rejected": is_rejected, - "type_of_transaction": type_of_transaction, - "warehouse": warehouse, - "batches": frappe._dict({row.batch_no: qty}) if row.batch_no else None, - "serial_nos": get_serial_nos(serial_nos) if serial_nos else None, - "batch_no": row.batch_no, - } - ) - - def create_serial_batch_bundle(self, bundle_details, row): - from erpnext.stock.serial_batch_bundle import SerialBatchCreation - - sn_doc = SerialBatchCreation(bundle_details).make_serial_and_batch_bundle() - - field = "serial_and_batch_bundle" - if bundle_details.get("is_rejected"): - field = "rejected_serial_and_batch_bundle" - - row.set(field, sn_doc.name) - row.db_set({field: sn_doc.name}) - - def validate_serial_nos_and_batches_with_bundle(self, row): - from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - - throw_error = False - if row.serial_no: - serial_nos = frappe.get_all( - "Serial and Batch Entry", - fields=["serial_no"], - filters={"parent": row.serial_and_batch_bundle}, - ) - serial_nos = sorted([cstr(d.serial_no) for d in serial_nos]) - parsed_serial_nos = get_serial_nos(row.serial_no) - - if len(serial_nos) != len(parsed_serial_nos): - throw_error = True - elif serial_nos != parsed_serial_nos: - for serial_no in serial_nos: - if serial_no not in parsed_serial_nos: - throw_error = True - break - - elif row.batch_no: - batches = sorted( - frappe.get_all( - "Serial and Batch Entry", - filters={"parent": row.serial_and_batch_bundle}, - pluck="batch_no", - distinct=True, - ) - ) - - if batches != [row.batch_no]: - throw_error = True - - if throw_error: - frappe.throw( - _( - "At row {0}: Serial and Batch Bundle {1} has already created. Please remove the values from the serial no or batch no fields." - ).format(row.idx, row.serial_and_batch_bundle) - ) + return SerialBatchBundleService(self).make_bundle_for_sales_purchase_return(table_name) def set_use_serial_batch_fields(self): - if frappe.get_single_value("Stock Settings", "use_serial_batch_fields"): - for row in self.items: - row.use_serial_batch_fields = 1 + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + + return SerialBatchBundleService(self).set_use_serial_batch_fields() def get_gl_entries( self, inventory_account_map=None, default_expense_account=None, default_cost_center=None @@ -826,74 +390,22 @@ class StockController(AccountsController): ) def delete_auto_created_batches(self): - for table_name in ["items", "packed_items", "supplied_items"]: - if not self.get(table_name): - continue + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - for row in self.get(table_name): - update_values = {} - if row.get("batch_no"): - update_values["batch_no"] = None - - if row.get("serial_and_batch_bundle"): - update_values["serial_and_batch_bundle"] = None - frappe.db.set_value( - "Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1} - ) - - frappe.db.set_value( - "Serial and Batch Entry", {"parent": row.serial_and_batch_bundle}, {"is_cancelled": 1} - ) - - if update_values: - row.db_set(update_values) - - if table_name == "items" and row.get("rejected_serial_and_batch_bundle"): - frappe.db.set_value( - "Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, {"is_cancelled": 1} - ) - - frappe.db.set_value( - "Serial and Batch Entry", - {"parent": row.rejected_serial_and_batch_bundle}, - {"is_cancelled": 1}, - ) - - row.db_set("rejected_serial_and_batch_bundle", None) - - if row.get("current_serial_and_batch_bundle"): - row.db_set("current_serial_and_batch_bundle", None) + return SerialBatchBundleService(self).delete_auto_created_batches() def set_serial_and_batch_bundle(self, table_name=None, ignore_validate=False): - if not table_name: - table_name = "items" + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - QTY_FIELD = { - "serial_and_batch_bundle": "qty", - "current_serial_and_batch_bundle": "current_qty", - "rejected_serial_and_batch_bundle": "rejected_qty", - } - - for row in self.get(table_name): - for field in QTY_FIELD.keys(): - if row.get(field): - frappe.get_doc("Serial and Batch Bundle", row.get(field)).set_serial_and_batch_values( - self, row, qty_field=QTY_FIELD[field] - ) + return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate) def make_package_for_transfer( self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0 ): - return make_bundle_for_material_transfer( - is_new=self.is_new(), - docstatus=self.docstatus, - voucher_type=self.doctype, - voucher_no=self.name, - serial_and_batch_bundle=serial_and_batch_bundle, - warehouse=warehouse, - type_of_transaction=type_of_transaction, - do_not_submit=do_not_submit, - qty=qty, + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + + return SerialBatchBundleService(self).make_package_for_transfer( + serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty ) def get_sl_entries(self, d, args): @@ -1107,6 +619,7 @@ class StockController(AccountsController): def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): from erpnext.stock.serial_batch_bundle import update_batch_qty + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService from erpnext.stock.stock_ledger import make_sl_entries make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) @@ -1114,108 +627,7 @@ class StockController(AccountsController): self.doctype, self.name, self.docstatus, via_landed_cost_voucher=via_landed_cost_voucher ) - self.validate_reserved_batches() - - def validate_reserved_batches(self): - if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): - return - - if self.doctype not in ["Delivery Note", "Sales Invoice", "Stock Entry"]: - return - - batches = frappe.get_all( - "Serial and Batch Entry", - filters={ - "voucher_type": self.doctype, - "voucher_no": self.name, - "docstatus": 1, - "batch_no": ("is", "set"), - "qty": ("<", 0), - }, - pluck="batch_no", - ) - - if not batches: - return - - field_mapper = { - "Sales Invoice": [["Sales Order", "sales_order"]], - "Delivery Note": [["Sales Order", "against_sales_order"]], - "Stock Entry": [ - ["Work Order", "work_order"], - ["Subcontracting Inward Order", "subcontracting_inward_order"], - ], - }.get(self.doctype) - - qty_field = { - "Sales Invoice": "qty", - "Delivery Note": "qty", - "Stock Entry": "fg_completed_qty", - }.get(self.doctype) - - reserved_batches_data = self.get_reserved_batches(batches) - items = self.items - if self.doctype == "Stock Entry": - items = [self] - - for item in items: - for field in field_mapper: - if not item.get(field[1]): - continue - - value = item.get(field[1]) - for row in reserved_batches_data: - if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get( - "item_code" - ): - continue - - if row.voucher_no == value: - continue - - batch_qty = get_batch_qty( - row.batch_no, - row.warehouse, - posting_date=self.posting_date, - posting_time=self.posting_time, - consider_negative_batches=True, - ) - - if item.get(qty_field) < batch_qty: - continue - - frappe.throw( - _( - "The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}." - ).format( - frappe.bold(row.batch_no), - frappe.bold(row.voucher_type), - frappe.bold(row.voucher_no), - frappe.bold(self.doctype), - frappe.bold(self.name), - frappe.bold(field[0]), - frappe.bold(value), - ), - title=_("Reserved Batch Conflict"), - ) - - def get_reserved_batches(self, batches): - doctype = frappe.qb.DocType("Stock Reservation Entry") - child_doc = frappe.qb.DocType("Serial and Batch Entry") - - return ( - frappe.qb.from_(doctype) - .join(child_doc) - .on(doctype.name == child_doc.parent) - .select( - child_doc.batch_no, - doctype.voucher_type, - doctype.voucher_no, - doctype.item_code, - doctype.warehouse, - ) - .where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches))) - ).run(as_dict=True) + SerialBatchBundleService(self).validate_reserved_batches() def make_gl_entries_on_cancel(self, from_repost=False): if not from_repost: @@ -1227,18 +639,6 @@ class StockController(AccountsController): ): self.make_gl_entries() - def get_serialized_items(self): - serialized_items = [] - item_codes = list(set(d.item_code for d in self.get("items"))) - if item_codes: - serialized_items = frappe.db.sql_list( - """select name from `tabItem` - where has_serial_no=1 and name in ({})""".format(", ".join(["%s"] * len(item_codes))), - tuple(item_codes), - ) - - return serialized_items - def validate_warehouse(self): from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company diff --git a/erpnext/stock/services/serial_batch_bundle.py b/erpnext/stock/services/serial_batch_bundle.py new file mode 100644 index 00000000000..cf98a0aea5c --- /dev/null +++ b/erpnext/stock/services/serial_batch_bundle.py @@ -0,0 +1,695 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Serial & Batch Bundle handling for stock transactions. + +Extracted from ``StockController``. Owns creation, validation and teardown of +Serial and Batch Bundles for a stock voucher. The controller keeps thin +delegators for methods reached from other doctypes / ``run_method``; internal +helpers live here only. +""" + +import frappe +from frappe import _, bold +from frappe.utils import cstr, flt, get_link_to_form, getdate + +from erpnext.controllers.sales_and_purchase_return import ( + available_serial_batch_for_return, + filter_serial_batches, + make_serial_batch_bundle_for_return, +) +from erpnext.stock.doctype.batch.batch import get_batch_qty +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, + get_type_of_transaction, +) + + +class SerialBatchBundleService: + def __init__(self, doc) -> None: + self.doc = doc + + def validate_warehouse_of_sabb(self): + if self.doc.is_internal_transfer(): + return + + doc_before_save = self.doc.get_doc_before_save() + + for row in self.doc.items: + if not row.get("serial_and_batch_bundle"): + continue + + sabb_details = frappe.db.get_value( + "Serial and Batch Bundle", + row.serial_and_batch_bundle, + ["type_of_transaction", "warehouse", "has_serial_no"], + as_dict=True, + ) + if not sabb_details: + continue + + if sabb_details.type_of_transaction != "Outward": + continue + + warehouse = row.get("warehouse") or row.get("s_warehouse") + if sabb_details.warehouse != warehouse: + frappe.throw( + _( + "Row #{0}: Warehouse {1} does not match with the warehouse {2} in Serial and Batch Bundle {3}." + ).format(row.idx, warehouse, sabb_details.warehouse, row.serial_and_batch_bundle) + ) + + if self.doc.doctype == "Stock Reconciliation": + continue + + if sabb_details.has_serial_no and doc_before_save and doc_before_save.get("items"): + prev_row = doc_before_save.get("items", {"idx": row.idx}) + if prev_row and prev_row[0].serial_and_batch_bundle != row.serial_and_batch_bundle: + sabb_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) + sabb_doc.validate_serial_no_status() + + def validate_duplicate_serial_and_batch_bundle(self, table_name): + if not self.doc.get(table_name): + return + + sbb_list = [] + for item in self.doc.get(table_name): + if item.get("serial_and_batch_bundle"): + sbb_list.append(item.get("serial_and_batch_bundle")) + + if item.get("rejected_serial_and_batch_bundle"): + sbb_list.append(item.get("rejected_serial_and_batch_bundle")) + + if sbb_list: + SLE = frappe.qb.DocType("Stock Ledger Entry") + data = ( + frappe.qb.from_(SLE) + .select(SLE.voucher_type, SLE.voucher_no, SLE.serial_and_batch_bundle) + .where( + (SLE.docstatus == 1) + & (SLE.serial_and_batch_bundle.notnull()) + & (SLE.serial_and_batch_bundle.isin(sbb_list)) + ) + .limit(1) + ).run(as_dict=True) + + if data: + data = data[0] + frappe.throw( + _("Serial and Batch Bundle {0} is already used in {1} {2}.").format( + frappe.bold(data.serial_and_batch_bundle), data.voucher_type, data.voucher_no + ) + ) + + def validate_serialized_batch(self): + from erpnext.controllers.stock_controller import BatchExpiredError + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + is_material_issue = False + if self.doc.doctype == "Stock Entry" and self.doc.purpose in ["Material Issue", "Material Transfer"]: + is_material_issue = True + + for d in self.doc.get("items"): + if hasattr(d, "serial_no") and hasattr(d, "batch_no") and d.serial_no and d.batch_no: + serial_nos = frappe.get_all( + "Serial No", + fields=["batch_no", "name", "warehouse"], + filters={"name": ("in", get_serial_nos(d.serial_no))}, + ) + + for row in serial_nos: + if row.warehouse and row.batch_no != d.batch_no: + frappe.throw( + _("Row #{0}: Serial No {1} does not belong to Batch {2}").format( + d.idx, row.name, d.batch_no + ) + ) + + if is_material_issue: + continue + + if ( + flt(d.qty) > 0.0 + and d.get("batch_no") + and self.doc.get("posting_date") + and self.doc.docstatus < 2 + ): + expiry_date = frappe.get_cached_value("Batch", d.get("batch_no"), "expiry_date") + + if expiry_date and getdate(expiry_date) < getdate(self.doc.posting_date): + frappe.throw( + _("Row #{0}: The batch {1} has already expired.").format( + d.idx, get_link_to_form("Batch", d.get("batch_no")) + ), + BatchExpiredError, + ) + + def clean_serial_nos(self): + from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string + + for row in self.doc.get("items"): + if hasattr(row, "serial_no") and row.serial_no: + # remove extra whitespace and store one serial no on each line + row.serial_no = clean_serial_no_string(row.serial_no) + + for row in self.doc.get("packed_items") or []: + if hasattr(row, "serial_no") and row.serial_no: + # remove extra whitespace and store one serial no on each line + row.serial_no = clean_serial_no_string(row.serial_no) + + def make_bundle_using_old_serial_batch_fields(self, table_name=None, via_landed_cost_voucher=False): + if self.doc.get("_action") == "update_after_submit": + return + + # To handle test cases + if frappe.in_test and frappe.flags.use_serial_and_batch_fields: + return + + if not table_name: + table_name = "items" + + if self.doc.doctype == "Asset Capitalization": + table_name = "stock_items" + + parent_details = frappe._dict() + if table_name == "packed_items": + parent_details = self.get_parent_details_for_packed_items() + + for row in self.doc.get(table_name): + if ( + not via_landed_cost_voucher + and row.serial_and_batch_bundle + and (row.serial_no or row.batch_no) + ): + self.validate_serial_nos_and_batches_with_bundle(row) + + if not row.serial_no and not row.batch_no and not row.get("rejected_serial_no"): + continue + + if not row.use_serial_batch_fields and ( + row.serial_no or row.batch_no or row.get("rejected_serial_no") + ): + row.use_serial_batch_fields = 1 + + if row.use_serial_batch_fields and ( + not row.serial_and_batch_bundle and not row.get("rejected_serial_and_batch_bundle") + ): + bundle_details = { + "item_code": row.get("rm_item_code") or row.item_code, + "posting_datetime": combine_datetime(self.doc.posting_date, self.doc.posting_time), + "voucher_type": self.doc.doctype, + "voucher_no": self.doc.name, + "voucher_detail_no": row.name, + "company": self.doc.company, + "is_rejected": 1 if row.get("rejected_warehouse") else 0, + "use_serial_batch_fields": row.use_serial_batch_fields, + "via_landed_cost_voucher": via_landed_cost_voucher, + "do_not_submit": True if not via_landed_cost_voucher else False, + } + + if self.doc.is_internal_transfer() and row.get("from_warehouse") and not self.doc.is_return: + self.update_bundle_details(bundle_details, table_name, row) + bundle_details["type_of_transaction"] = "Outward" + bundle_details["warehouse"] = row.get("from_warehouse") + bundle_details["qty"] = row.get("stock_qty") or row.get("qty") + self.create_serial_batch_bundle(bundle_details, row) + continue + + if row.get("qty") or row.get("consumed_qty") or row.get("stock_qty"): + self.update_bundle_details(bundle_details, table_name, row, parent_details=parent_details) + self.create_serial_batch_bundle(bundle_details, row) + + if row.get("rejected_qty"): + self.update_bundle_details(bundle_details, table_name, row, is_rejected=True) + self.create_serial_batch_bundle(bundle_details, row) + + def get_parent_details_for_packed_items(self): + parent_details = frappe._dict() + for row in self.doc.get("items"): + parent_details[row.name] = row + + return parent_details + + def make_bundle_for_sales_purchase_return(self, table_name=None): + if not self.doc.get("is_return"): + return + + if not table_name: + table_name = "items" + + self.make_bundle_for_non_rejected_qty(table_name) + + if self.doc.doctype in ["Purchase Invoice", "Purchase Receipt"]: + self.make_bundle_for_rejected_qty(table_name) + + def make_bundle_for_rejected_qty(self, table_name=None): + field, reference_ids = self.get_reference_ids( + table_name, "rejected_qty", "rejected_serial_and_batch_bundle" + ) + + if not reference_ids: + return + + child_doctype = self.doc.doctype + " Item" + available_dict = available_serial_batch_for_return( + field, child_doctype, reference_ids, is_rejected=True + ) + + for row in self.doc.get(table_name): + if data := available_dict.get(row.get(field)): + qty_field = "rejected_qty" + warehouse_field = "rejected_warehouse" + if row.get("return_qty_from_rejected_warehouse"): + qty_field = "qty" + warehouse_field = "warehouse" + + if not data.get("qty"): + frappe.throw( + _("For the {0}, no stock is available for the return in the warehouse {1}.").format( + frappe.bold(row.item_code), row.get(warehouse_field) + ) + ) + + data = filter_serial_batches( + self.doc, data, row, warehouse_field=warehouse_field, qty_field=qty_field + ) + bundle = make_serial_batch_bundle_for_return(data, row, self.doc, warehouse_field, qty_field) + if row.get("return_qty_from_rejected_warehouse"): + row.db_set( + { + "serial_and_batch_bundle": bundle, + "batch_no": "", + "serial_no": "", + } + ) + else: + row.db_set( + { + "rejected_serial_and_batch_bundle": bundle, + "batch_no": "", + "rejected_serial_no": "", + } + ) + + def make_bundle_for_non_rejected_qty(self, table_name): + field, reference_ids = self.get_reference_ids(table_name) + if not reference_ids: + return + + child_doctype = self.doc.doctype + " Item" + if table_name == "packed_items": + field = "parent_detail_docname" + child_doctype = "Packed Item" + + available_dict = available_serial_batch_for_return(field, child_doctype, reference_ids) + + for row in self.doc.get(table_name): + value = row.get(field) + if table_name == "packed_items" and row.get("parent_detail_docname"): + value = self.get_value_for_packed_item(row) + if not value: + continue + + if data := available_dict.get(value): + data = filter_serial_batches(self.doc, data, row) + bundle = make_serial_batch_bundle_for_return(data, row, self.doc) + row.db_set( + { + "serial_and_batch_bundle": bundle, + "batch_no": "", + "serial_no": "", + } + ) + + if self.doc.doctype in ["Sales Invoice", "Delivery Note"]: + row.db_set( + "incoming_rate", frappe.db.get_value("Serial and Batch Bundle", bundle, "avg_rate") + ) + + def get_value_for_packed_item(self, row): + parent_items = self.doc.get("items", {"name": row.parent_detail_docname}) + if parent_items: + ref = parent_items[0].get("dn_detail") + return (row.item_code, ref) + + return None + + def get_reference_ids(self, table_name, qty_field=None, bundle_field=None) -> tuple[str, list[str]]: + field = { + "Sales Invoice": "sales_invoice_item", + "Delivery Note": "dn_detail", + "Purchase Receipt": "purchase_receipt_item", + "Purchase Invoice": "purchase_invoice_item", + "POS Invoice": "pos_invoice_item", + }.get(self.doc.doctype) + + if not bundle_field: + bundle_field = "serial_and_batch_bundle" + + if not qty_field: + qty_field = "qty" + + reference_ids = [] + + for row in self.doc.get(table_name): + if not self.is_serial_batch_item(row.item_code): + continue + + if ( + row.get(field) + and ( + qty_field == "qty" + and not row.get("return_qty_from_rejected_warehouse") + or qty_field == "rejected_qty" + and (row.get("return_qty_from_rejected_warehouse") or row.get("rejected_warehouse")) + ) + and not row.get("use_serial_batch_fields") + and not row.get(bundle_field) + ): + reference_ids.append(row.get(field)) + + if table_name == "packed_items" and row.get("parent_detail_docname"): + parent_rows = self.doc.get("items", {"name": row.parent_detail_docname}) or [] + for d in parent_rows: + if d.get(field) and not d.get(bundle_field): + reference_ids.append(d.get(field)) + + return field, reference_ids + + @frappe.request_cache + def is_serial_batch_item(self, item_code) -> bool: + if not frappe.db.exists("Item", item_code): + frappe.throw(_("Item {0} does not exist.").format(bold(item_code))) + + item_details = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) + + if item_details.has_serial_no or item_details.has_batch_no: + return True + + return False + + def update_bundle_details(self, bundle_details, table_name, row, is_rejected=False, parent_details=None): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + # Since qty field is different for different doctypes + qty = row.get("qty") + warehouse = row.get("warehouse") + + if table_name == "packed_items": + type_of_transaction = "Inward" + if not self.doc.is_return: + type_of_transaction = "Outward" + elif table_name == "supplied_items": + qty = row.consumed_qty + warehouse = self.doc.supplier_warehouse + type_of_transaction = "Outward" + if self.doc.is_return: + type_of_transaction = "Inward" + else: + type_of_transaction = get_type_of_transaction(self.doc, row) + + if hasattr(row, "stock_qty"): + qty = row.stock_qty + + if self.doc.doctype == "Stock Entry": + qty = row.transfer_qty + warehouse = row.s_warehouse or row.t_warehouse + + serial_nos = row.serial_no + if is_rejected: + serial_nos = row.get("rejected_serial_no") + type_of_transaction = "Inward" if not self.doc.is_return else "Outward" + qty = flt( + row.get("rejected_qty") * row.get("conversion_factor", 1.0), + frappe.get_precision("Serial and Batch Entry", "qty"), + ) + warehouse = row.get("rejected_warehouse") + + if ( + self.doc.is_internal_transfer() + and self.doc.doctype in ["Sales Invoice", "Delivery Note"] + and self.doc.is_return + ): + warehouse = row.get("target_warehouse") or row.get("warehouse") + type_of_transaction = "Outward" + + if table_name == "packed_items": + if not warehouse: + warehouse = parent_details[row.parent_detail_docname].warehouse + bundle_details["voucher_detail_no"] = parent_details[row.parent_detail_docname].name + + bundle_details.update( + { + "qty": qty, + "is_rejected": is_rejected, + "type_of_transaction": type_of_transaction, + "warehouse": warehouse, + "batches": frappe._dict({row.batch_no: qty}) if row.batch_no else None, + "serial_nos": get_serial_nos(serial_nos) if serial_nos else None, + "batch_no": row.batch_no, + } + ) + + def create_serial_batch_bundle(self, bundle_details, row): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + sn_doc = SerialBatchCreation(bundle_details).make_serial_and_batch_bundle() + + field = "serial_and_batch_bundle" + if bundle_details.get("is_rejected"): + field = "rejected_serial_and_batch_bundle" + + row.set(field, sn_doc.name) + row.db_set({field: sn_doc.name}) + + def validate_serial_nos_and_batches_with_bundle(self, row): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + throw_error = False + if row.serial_no: + serial_nos = frappe.get_all( + "Serial and Batch Entry", + fields=["serial_no"], + filters={"parent": row.serial_and_batch_bundle}, + ) + serial_nos = sorted([cstr(d.serial_no) for d in serial_nos]) + parsed_serial_nos = get_serial_nos(row.serial_no) + + if len(serial_nos) != len(parsed_serial_nos): + throw_error = True + elif serial_nos != parsed_serial_nos: + for serial_no in serial_nos: + if serial_no not in parsed_serial_nos: + throw_error = True + break + + elif row.batch_no: + batches = sorted( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": row.serial_and_batch_bundle}, + pluck="batch_no", + distinct=True, + ) + ) + + if batches != [row.batch_no]: + throw_error = True + + if throw_error: + frappe.throw( + _( + "At row {0}: Serial and Batch Bundle {1} has already created. Please remove the values from the serial no or batch no fields." + ).format(row.idx, row.serial_and_batch_bundle) + ) + + def set_use_serial_batch_fields(self): + if frappe.get_single_value("Stock Settings", "use_serial_batch_fields"): + for row in self.doc.items: + row.use_serial_batch_fields = 1 + + def delete_auto_created_batches(self): + for table_name in ["items", "packed_items", "supplied_items"]: + if not self.doc.get(table_name): + continue + + for row in self.doc.get(table_name): + update_values = {} + if row.get("batch_no"): + update_values["batch_no"] = None + + if row.get("serial_and_batch_bundle"): + update_values["serial_and_batch_bundle"] = None + frappe.db.set_value( + "Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1} + ) + + frappe.db.set_value( + "Serial and Batch Entry", {"parent": row.serial_and_batch_bundle}, {"is_cancelled": 1} + ) + + if update_values: + row.db_set(update_values) + + if table_name == "items" and row.get("rejected_serial_and_batch_bundle"): + frappe.db.set_value( + "Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, {"is_cancelled": 1} + ) + + frappe.db.set_value( + "Serial and Batch Entry", + {"parent": row.rejected_serial_and_batch_bundle}, + {"is_cancelled": 1}, + ) + + row.db_set("rejected_serial_and_batch_bundle", None) + + if row.get("current_serial_and_batch_bundle"): + row.db_set("current_serial_and_batch_bundle", None) + + def set_serial_and_batch_bundle(self, table_name=None, ignore_validate=False): + if not table_name: + table_name = "items" + + QTY_FIELD = { + "serial_and_batch_bundle": "qty", + "current_serial_and_batch_bundle": "current_qty", + "rejected_serial_and_batch_bundle": "rejected_qty", + } + + for row in self.doc.get(table_name): + for field in QTY_FIELD.keys(): + if row.get(field): + frappe.get_doc("Serial and Batch Bundle", row.get(field)).set_serial_and_batch_values( + self.doc, row, qty_field=QTY_FIELD[field] + ) + + def make_package_for_transfer( + self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0 + ): + from erpnext.controllers.stock_controller import make_bundle_for_material_transfer + + return make_bundle_for_material_transfer( + is_new=self.doc.is_new(), + docstatus=self.doc.docstatus, + voucher_type=self.doc.doctype, + voucher_no=self.doc.name, + serial_and_batch_bundle=serial_and_batch_bundle, + warehouse=warehouse, + type_of_transaction=type_of_transaction, + do_not_submit=do_not_submit, + qty=qty, + ) + + def validate_reserved_batches(self): + if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): + return + + if self.doc.doctype not in ["Delivery Note", "Sales Invoice", "Stock Entry"]: + return + + batches = frappe.get_all( + "Serial and Batch Entry", + filters={ + "voucher_type": self.doc.doctype, + "voucher_no": self.doc.name, + "docstatus": 1, + "batch_no": ("is", "set"), + "qty": ("<", 0), + }, + pluck="batch_no", + ) + + if not batches: + return + + field_mapper = { + "Sales Invoice": [["Sales Order", "sales_order"]], + "Delivery Note": [["Sales Order", "against_sales_order"]], + "Stock Entry": [ + ["Work Order", "work_order"], + ["Subcontracting Inward Order", "subcontracting_inward_order"], + ], + }.get(self.doc.doctype) + + qty_field = { + "Sales Invoice": "qty", + "Delivery Note": "qty", + "Stock Entry": "fg_completed_qty", + }.get(self.doc.doctype) + + reserved_batches_data = self.get_reserved_batches(batches) + items = self.doc.items + if self.doc.doctype == "Stock Entry": + items = [self.doc] + + for item in items: + for field in field_mapper: + if not item.get(field[1]): + continue + + value = item.get(field[1]) + for row in reserved_batches_data: + if self.doc.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get( + "item_code" + ): + continue + + if row.voucher_no == value: + continue + + batch_qty = get_batch_qty( + row.batch_no, + row.warehouse, + posting_date=self.doc.posting_date, + posting_time=self.doc.posting_time, + consider_negative_batches=True, + ) + + if item.get(qty_field) < batch_qty: + continue + + frappe.throw( + _( + "The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}." + ).format( + frappe.bold(row.batch_no), + frappe.bold(row.voucher_type), + frappe.bold(row.voucher_no), + frappe.bold(self.doc.doctype), + frappe.bold(self.doc.name), + frappe.bold(field[0]), + frappe.bold(value), + ), + title=_("Reserved Batch Conflict"), + ) + + def get_reserved_batches(self, batches): + doctype = frappe.qb.DocType("Stock Reservation Entry") + child_doc = frappe.qb.DocType("Serial and Batch Entry") + + return ( + frappe.qb.from_(doctype) + .join(child_doc) + .on(doctype.name == child_doc.parent) + .select( + child_doc.batch_no, + doctype.voucher_type, + doctype.voucher_no, + doctype.item_code, + doctype.warehouse, + ) + .where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches))) + ).run(as_dict=True) + + def get_serialized_items(self): + serialized_items = [] + item_codes = list(set(d.item_code for d in self.doc.get("items"))) + if item_codes: + serialized_items = frappe.db.sql_list( + """select name from `tabItem` + where has_serial_no=1 and name in ({})""".format(", ".join(["%s"] * len(item_codes))), + tuple(item_codes), + ) + + return serialized_items From 4affdd51f6b56c4f889c9f0423f8c79fb6a0b380 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 15:35:04 +0530 Subject: [PATCH 03/21] refactor(stock): extract StockLedgerService from StockController Move SLE building and reposting (get_sl_entries, update_inventory_dimensions, get_stock_ledger_details, get_items_and_warehouses, make_sl_entries, repost_future_sle_and_gle) into erpnext/stock/services/stock_ledger.py as a delegating service. All six keep thin controller delegators (each has external callers). The repost helper *functions* stay module-level in stock_controller (imported widely); the service calls them. Also drop import orphaned by this and the prior bundle extraction. Behaviour-preserving: ledger characterization snapshots and the repost item valuation suite stay green. --- erpnext/controllers/stock_controller.py | 221 ++------------------- erpnext/stock/services/stock_ledger.py | 250 ++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 207 deletions(-) create mode 100644 erpnext/stock/services/stock_ledger.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 833e28876a5..098babd6738 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -14,7 +14,7 @@ from erpnext.accounts.general_ledger import ( make_gl_entries, make_reverse_gl_entries, ) -from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year +from erpnext.accounts.utils import cancel_exchange_gain_loss_journal from erpnext.controllers.accounts_controller import AccountsController from erpnext.controllers.sales_and_purchase_return import ( available_serial_batch_for_return, @@ -24,15 +24,7 @@ from erpnext.controllers.sales_and_purchase_return import ( from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock import get_warehouse_account_map -from erpnext.stock.doctype.batch.batch import get_batch_qty -from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( - get_evaluated_inventory_dimension, -) from erpnext.stock.doctype.item.item import get_item_defaults -from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( - combine_datetime, - get_type_of_transaction, -) from erpnext.stock.stock_ledger import get_items_to_be_repost @@ -296,59 +288,14 @@ class StockController(AccountsController): return details def get_items_and_warehouses(self) -> tuple[list[str], list[str]]: - """Get list of items and warehouses affected by a transaction""" + from erpnext.stock.services.stock_ledger import StockLedgerService - if not (hasattr(self, "items") or hasattr(self, "packed_items")): - return [], [] - - item_rows = (self.get("items") or []) + (self.get("packed_items") or []) - - items = {d.item_code for d in item_rows if d.item_code} - - warehouses = set() - for d in item_rows: - if d.get("warehouse"): - warehouses.add(d.warehouse) - - if self.doctype == "Stock Entry": - if d.get("s_warehouse"): - warehouses.add(d.s_warehouse) - if d.get("t_warehouse"): - warehouses.add(d.t_warehouse) - - return list(items), list(warehouses) + return StockLedgerService(self).get_items_and_warehouses() def get_stock_ledger_details(self): - stock_ledger = {} + from erpnext.stock.services.stock_ledger import StockLedgerService - table = frappe.qb.DocType("Stock Ledger Entry") - - stock_ledger_entries = ( - frappe.qb.from_(table) - .select( - table.name, - table.warehouse, - table.stock_value_difference, - table.valuation_rate, - table.voucher_detail_no, - table.item_code, - table.posting_date, - table.posting_time, - table.actual_qty, - table.qty_after_transaction, - table.project, - ) - .where( - (table.voucher_type == self.doctype) - & (table.voucher_no == self.name) - & (table.is_cancelled == 0) - ) - ).run(as_dict=True) - - for sle in stock_ledger_entries: - stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle) - - return stock_ledger + return StockLedgerService(self).get_stock_ledger_details() def check_expense_account(self, item): if not item.get("expense_account"): @@ -409,41 +356,9 @@ class StockController(AccountsController): ) def get_sl_entries(self, d, args): - sl_dict = frappe._dict( - { - "item_code": d.get("item_code", None), - "warehouse": d.get("warehouse", None), - "serial_and_batch_bundle": d.get("serial_and_batch_bundle"), - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "fiscal_year": get_fiscal_year(self.posting_date, company=self.company)[0], - "voucher_type": self.doctype, - "voucher_no": self.name, - "voucher_detail_no": d.name, - "actual_qty": (self.docstatus == 1 and 1 or -1) * flt(d.get("stock_qty")), - "stock_uom": frappe.get_cached_value( - "Item", args.get("item_code") or d.get("item_code"), "stock_uom" - ), - "incoming_rate": 0, - "company": self.company, - "project": d.get("project") or self.get("project"), - "is_cancelled": 1 if self.docstatus == 2 else 0, - } - ) + from erpnext.stock.services.stock_ledger import StockLedgerService - sl_dict.update(args) - self.update_inventory_dimensions(d, sl_dict) - - if self.docstatus == 2: - from erpnext.deprecation_dumpster import deprecation_warning - - deprecation_warning("unknown", "v16", "No instructions.") - # To handle denormalized serial no records, will br deprecated in v16 - for field in ["serial_no", "batch_no"]: - if d.get(field): - sl_dict[field] = d.get(field) - - return sl_dict + return StockLedgerService(self).get_sl_entries(d, args) def set_landed_cost_voucher_amount(self): for d in self.get("items"): @@ -531,104 +446,17 @@ class StockController(AccountsController): return item_account_wise_cost def update_inventory_dimensions(self, row, sl_dict) -> None: - # To handle delivery note and sales invoice - if row.get("item_row"): - row = row.get("item_row") + from erpnext.stock.services.stock_ledger import StockLedgerService - dimensions = get_evaluated_inventory_dimension(row, sl_dict, parent_doc=self) - for dimension in dimensions: - if not dimension: - continue - - if ( - self.doctype in ["Purchase Invoice", "Purchase Receipt"] - and row.get("rejected_warehouse") - and sl_dict.get("warehouse") == row.get("rejected_warehouse") - ): - fieldname = f"rejected_{dimension.source_fieldname}" - sl_dict[dimension.target_fieldname] = row.get(fieldname) - continue - - if self.doctype in [ - "Purchase Invoice", - "Purchase Receipt", - "Sales Invoice", - "Delivery Note", - "Stock Entry", - ]: - if ( - ( - sl_dict.actual_qty > 0 - and not self.get("is_return") - or sl_dict.actual_qty < 0 - and self.get("is_return") - ) - and self.doctype in ["Purchase Invoice", "Purchase Receipt", "Stock Entry"] - ) or ( - ( - sl_dict.actual_qty < 0 - and not self.get("is_return") - or sl_dict.actual_qty > 0 - and self.get("is_return") - ) - and self.doctype in ["Sales Invoice", "Delivery Note", "Stock Entry"] - ): - if self.doctype == "Stock Entry": - if row.get("t_warehouse") == sl_dict.warehouse and sl_dict.get("actual_qty") > 0: - fieldname = f"to_{dimension.source_fieldname}" - if dimension.source_fieldname.startswith("to_"): - fieldname = f"{dimension.source_fieldname}" - - sl_dict[dimension.target_fieldname] = row.get(fieldname) - continue - - sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) - else: - fieldname_start_with = "to" - if self.doctype in ["Purchase Invoice", "Purchase Receipt"]: - fieldname_start_with = "from" - - fieldname = f"{fieldname_start_with}_{dimension.source_fieldname}" - sl_dict[dimension.target_fieldname] = row.get(fieldname) - - if not sl_dict.get(dimension.target_fieldname): - sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) - - elif row.get(dimension.source_fieldname): - sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) - - if not sl_dict.get(dimension.target_fieldname) and dimension.fetch_from_parent: - sl_dict[dimension.target_fieldname] = self.get(dimension.fetch_from_parent) - - # Get value based on doctype name - if not sl_dict.get(dimension.target_fieldname): - fieldname = next( - ( - field.fieldname - for field in frappe.get_meta(self.doctype).fields - if field.options == dimension.fetch_from_parent - ), - None, - ) - - if fieldname and self.get(fieldname): - sl_dict[dimension.target_fieldname] = self.get(fieldname) - - if sl_dict[dimension.target_fieldname] and self.docstatus == 1: - row.db_set(dimension.source_fieldname, sl_dict[dimension.target_fieldname]) + return StockLedgerService(self).update_inventory_dimensions(row, sl_dict) def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): - from erpnext.stock.serial_batch_bundle import update_batch_qty - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - from erpnext.stock.stock_ledger import make_sl_entries + from erpnext.stock.services.stock_ledger import StockLedgerService - make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) - update_batch_qty( - self.doctype, self.name, self.docstatus, via_landed_cost_voucher=via_landed_cost_voucher + return StockLedgerService(self).make_sl_entries( + sl_entries, allow_negative_stock, via_landed_cost_voucher ) - SerialBatchBundleService(self).validate_reserved_batches() - def make_gl_entries_on_cancel(self, from_repost=False): if not from_repost: cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) @@ -1019,30 +847,9 @@ class StockController(AccountsController): return message def repost_future_sle_and_gle(self, force=False, via_landed_cost_voucher=False): - args = frappe._dict( - { - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "voucher_type": self.doctype, - "voucher_no": self.name, - "company": self.company, - "via_landed_cost_voucher": via_landed_cost_voucher, - } - ) + from erpnext.stock.services.stock_ledger import StockLedgerService - if self.docstatus == 2: - force = True - - if force or future_sle_exists(args) or repost_required_for_queue(self): - item_based_reposting = frappe.get_single_value("Stock Reposting Settings", "item_based_reposting") - if item_based_reposting: - create_item_wise_repost_entries( - voucher_type=self.doctype, - voucher_no=self.name, - via_landed_cost_voucher=via_landed_cost_voucher, - ) - else: - create_repost_item_valuation_entry(args) + return StockLedgerService(self).repost_future_sle_and_gle(force, via_landed_cost_voucher) def add_gl_entry( self, diff --git a/erpnext/stock/services/stock_ledger.py b/erpnext/stock/services/stock_ledger.py new file mode 100644 index 00000000000..f41ae7e53ed --- /dev/null +++ b/erpnext/stock/services/stock_ledger.py @@ -0,0 +1,250 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Stock Ledger Entry building and reposting for stock transactions. + +Extracted from ``StockController``. Builds the SLE dicts for a voucher, writes +them, and triggers future SLE/GL reposting. The repost helper *functions* remain +module-level in ``stock_controller`` (imported widely); this service owns the +instance-level logic. +""" + +import frappe +from frappe.utils import flt + +from erpnext.accounts.utils import get_fiscal_year +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( + get_evaluated_inventory_dimension, +) + + +class StockLedgerService: + def __init__(self, doc) -> None: + self.doc = doc + + def get_items_and_warehouses(self) -> tuple[list[str], list[str]]: + """Get list of items and warehouses affected by a transaction""" + + if not (hasattr(self.doc, "items") or hasattr(self.doc, "packed_items")): + return [], [] + + item_rows = (self.doc.get("items") or []) + (self.doc.get("packed_items") or []) + + items = {d.item_code for d in item_rows if d.item_code} + + warehouses = set() + for d in item_rows: + if d.get("warehouse"): + warehouses.add(d.warehouse) + + if self.doc.doctype == "Stock Entry": + if d.get("s_warehouse"): + warehouses.add(d.s_warehouse) + if d.get("t_warehouse"): + warehouses.add(d.t_warehouse) + + return list(items), list(warehouses) + + def get_stock_ledger_details(self): + stock_ledger = {} + + table = frappe.qb.DocType("Stock Ledger Entry") + + stock_ledger_entries = ( + frappe.qb.from_(table) + .select( + table.name, + table.warehouse, + table.stock_value_difference, + table.valuation_rate, + table.voucher_detail_no, + table.item_code, + table.posting_date, + table.posting_time, + table.actual_qty, + table.qty_after_transaction, + table.project, + ) + .where( + (table.voucher_type == self.doc.doctype) + & (table.voucher_no == self.doc.name) + & (table.is_cancelled == 0) + ) + ).run(as_dict=True) + + for sle in stock_ledger_entries: + stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle) + + return stock_ledger + + def get_sl_entries(self, d, args): + sl_dict = frappe._dict( + { + "item_code": d.get("item_code", None), + "warehouse": d.get("warehouse", None), + "serial_and_batch_bundle": d.get("serial_and_batch_bundle"), + "posting_date": self.doc.posting_date, + "posting_time": self.doc.posting_time, + "fiscal_year": get_fiscal_year(self.doc.posting_date, company=self.doc.company)[0], + "voucher_type": self.doc.doctype, + "voucher_no": self.doc.name, + "voucher_detail_no": d.name, + "actual_qty": (self.doc.docstatus == 1 and 1 or -1) * flt(d.get("stock_qty")), + "stock_uom": frappe.get_cached_value( + "Item", args.get("item_code") or d.get("item_code"), "stock_uom" + ), + "incoming_rate": 0, + "company": self.doc.company, + "project": d.get("project") or self.doc.get("project"), + "is_cancelled": 1 if self.doc.docstatus == 2 else 0, + } + ) + + sl_dict.update(args) + self.update_inventory_dimensions(d, sl_dict) + + if self.doc.docstatus == 2: + from erpnext.deprecation_dumpster import deprecation_warning + + deprecation_warning("unknown", "v16", "No instructions.") + # To handle denormalized serial no records, will br deprecated in v16 + for field in ["serial_no", "batch_no"]: + if d.get(field): + sl_dict[field] = d.get(field) + + return sl_dict + + def update_inventory_dimensions(self, row, sl_dict) -> None: + # To handle delivery note and sales invoice + if row.get("item_row"): + row = row.get("item_row") + + dimensions = get_evaluated_inventory_dimension(row, sl_dict, parent_doc=self.doc) + for dimension in dimensions: + if not dimension: + continue + + if ( + self.doc.doctype in ["Purchase Invoice", "Purchase Receipt"] + and row.get("rejected_warehouse") + and sl_dict.get("warehouse") == row.get("rejected_warehouse") + ): + fieldname = f"rejected_{dimension.source_fieldname}" + sl_dict[dimension.target_fieldname] = row.get(fieldname) + continue + + if self.doc.doctype in [ + "Purchase Invoice", + "Purchase Receipt", + "Sales Invoice", + "Delivery Note", + "Stock Entry", + ]: + if ( + ( + sl_dict.actual_qty > 0 + and not self.doc.get("is_return") + or sl_dict.actual_qty < 0 + and self.doc.get("is_return") + ) + and self.doc.doctype in ["Purchase Invoice", "Purchase Receipt", "Stock Entry"] + ) or ( + ( + sl_dict.actual_qty < 0 + and not self.doc.get("is_return") + or sl_dict.actual_qty > 0 + and self.doc.get("is_return") + ) + and self.doc.doctype in ["Sales Invoice", "Delivery Note", "Stock Entry"] + ): + if self.doc.doctype == "Stock Entry": + if row.get("t_warehouse") == sl_dict.warehouse and sl_dict.get("actual_qty") > 0: + fieldname = f"to_{dimension.source_fieldname}" + if dimension.source_fieldname.startswith("to_"): + fieldname = f"{dimension.source_fieldname}" + + sl_dict[dimension.target_fieldname] = row.get(fieldname) + continue + + sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) + else: + fieldname_start_with = "to" + if self.doc.doctype in ["Purchase Invoice", "Purchase Receipt"]: + fieldname_start_with = "from" + + fieldname = f"{fieldname_start_with}_{dimension.source_fieldname}" + sl_dict[dimension.target_fieldname] = row.get(fieldname) + + if not sl_dict.get(dimension.target_fieldname): + sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) + + elif row.get(dimension.source_fieldname): + sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) + + if not sl_dict.get(dimension.target_fieldname) and dimension.fetch_from_parent: + sl_dict[dimension.target_fieldname] = self.doc.get(dimension.fetch_from_parent) + + # Get value based on doctype name + if not sl_dict.get(dimension.target_fieldname): + fieldname = next( + ( + field.fieldname + for field in frappe.get_meta(self.doc.doctype).fields + if field.options == dimension.fetch_from_parent + ), + None, + ) + + if fieldname and self.doc.get(fieldname): + sl_dict[dimension.target_fieldname] = self.doc.get(fieldname) + + if sl_dict[dimension.target_fieldname] and self.doc.docstatus == 1: + row.db_set(dimension.source_fieldname, sl_dict[dimension.target_fieldname]) + + def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): + from erpnext.stock.serial_batch_bundle import update_batch_qty + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.stock_ledger import make_sl_entries + + make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) + update_batch_qty( + self.doc.doctype, + self.doc.name, + self.doc.docstatus, + via_landed_cost_voucher=via_landed_cost_voucher, + ) + + SerialBatchBundleService(self.doc).validate_reserved_batches() + + def repost_future_sle_and_gle(self, force=False, via_landed_cost_voucher=False): + from erpnext.controllers.stock_controller import ( + create_item_wise_repost_entries, + create_repost_item_valuation_entry, + future_sle_exists, + repost_required_for_queue, + ) + + args = frappe._dict( + { + "posting_date": self.doc.posting_date, + "posting_time": self.doc.posting_time, + "voucher_type": self.doc.doctype, + "voucher_no": self.doc.name, + "company": self.doc.company, + "via_landed_cost_voucher": via_landed_cost_voucher, + } + ) + + if self.doc.docstatus == 2: + force = True + + if force or future_sle_exists(args) or repost_required_for_queue(self.doc): + item_based_reposting = frappe.get_single_value("Stock Reposting Settings", "item_based_reposting") + if item_based_reposting: + create_item_wise_repost_entries( + voucher_type=self.doc.doctype, + voucher_no=self.doc.name, + via_landed_cost_voucher=via_landed_cost_voucher, + ) + else: + create_repost_item_valuation_entry(args) From b447cbc3c192d88f6cf914a8648b43a7db6c0937 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 15:50:32 +0530 Subject: [PATCH 04/21] refactor(stock): move GL-building helpers onto BaseStockGLComposer Relocate get_voucher_details, check_expense_account and get_debit_field_precision from StockController to BaseStockGLComposer, where they are only used (by compose() and AssetCapitalizationGLComposer). Call sites flipped from doc.X to self.X. Inventory-account resolution (get_inventory_account_map/_dict, etc.) stays on the controller: it is a doc-contract method called as doc.X from non-stock-composer code (PI controller/composer, accounts/utils, repost_accounting_ledger), so it cannot fold into BaseStockGLComposer. make_gl_entries / make_gl_entries_on_cancel / add_gl_entry likewise stay (contract entry points). Behaviour-preserving: ledger snapshots, subcontracting receipt and asset capitalization suites stay green. --- .../services/gl_composer.py | 2 +- erpnext/controllers/stock_controller.py | 74 ----------------- .../stock/services/base_stock_gl_composer.py | 81 ++++++++++++++++++- 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py index 2b13bddd5ad..5e1f08edad0 100644 --- a/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py +++ b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py @@ -30,7 +30,7 @@ class AssetCapitalizationGLComposer(BaseStockGLComposer): gl_entries = [] self.inventory_account_map = inventory_account_map or doc.get_inventory_account_map() - self.precision = doc.get_debit_field_precision() + self.precision = self.get_debit_field_precision() self.sle_map = doc.get_stock_ledger_details() target_account = doc.get_target_account() diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 098babd6738..bffd3489ce7 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -252,41 +252,6 @@ class StockController(AccountsController): inventory_account_map, default_expense_account, default_cost_center ) - def get_debit_field_precision(self): - if not frappe.flags.debit_field_precision: - frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency") - - return frappe.flags.debit_field_precision - - def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): - if self.doctype == "Stock Reconciliation": - reconciliation_purpose = frappe.db.get_value(self.doctype, self.name, "purpose") - is_opening = "Yes" if reconciliation_purpose == "Opening Stock" else "No" - details = [] - for voucher_detail_no in sle_map: - details.append( - frappe._dict( - { - "name": voucher_detail_no, - "expense_account": default_expense_account, - "cost_center": default_cost_center, - "is_opening": is_opening, - } - ) - ) - return details - else: - details = self.get("items") - - if default_expense_account or default_cost_center: - for d in details: - if default_expense_account and not d.get("expense_account"): - d.expense_account = default_expense_account - if default_cost_center and not d.get("cost_center"): - d.cost_center = default_cost_center - - return details - def get_items_and_warehouses(self) -> tuple[list[str], list[str]]: from erpnext.stock.services.stock_ledger import StockLedgerService @@ -297,45 +262,6 @@ class StockController(AccountsController): return StockLedgerService(self).get_stock_ledger_details() - def check_expense_account(self, item): - if not item.get("expense_account"): - msg = _("Please set an Expense Account in the Items table") - frappe.throw( - _("Row #{0}: Expense Account not set for the Item {1}. {2}").format( - item.idx, frappe.bold(item.item_code), msg - ), - title=_("Expense Account Missing"), - ) - - else: - is_expense_account = ( - frappe.get_cached_value("Account", item.get("expense_account"), "report_type") - == "Profit and Loss" - ) - if ( - self.doctype - not in ( - "Purchase Receipt", - "Purchase Invoice", - "Stock Reconciliation", - "Stock Entry", - "Subcontracting Receipt", - "Delivery Note", - ) - and not is_expense_account - ): - frappe.throw( - _("Expense / Difference account ({0}) must be a 'Profit or Loss' account").format( - item.get("expense_account") - ) - ) - if is_expense_account and not item.get("cost_center"): - frappe.throw( - _("{0} {1}: Cost Center is mandatory for Item {2}").format( - _(self.doctype), self.name, item.get("item_code") - ) - ) - def delete_auto_created_batches(self): from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py index 89837db9909..8f9d207d8a3 100644 --- a/erpnext/stock/services/base_stock_gl_composer.py +++ b/erpnext/stock/services/base_stock_gl_composer.py @@ -29,11 +29,11 @@ class BaseStockGLComposer(BaseGLComposer): inventory_account_map = doc.get_inventory_account_map() sle_map = doc.get_stock_ledger_details() - voucher_details = doc.get_voucher_details(default_expense_account, default_cost_center, sle_map) + voucher_details = self.get_voucher_details(default_expense_account, default_cost_center, sle_map) gl_list = [] warehouse_with_no_account = [] - precision = doc.get_debit_field_precision() + precision = self.get_debit_field_precision() for item_row in voucher_details: sle_list = sle_map.get(item_row.name) @@ -45,7 +45,7 @@ class BaseStockGLComposer(BaseGLComposer): if _inv_dict.get("account"): sle_rounding_diff += flt(sle.stock_value_difference) - doc.check_expense_account(item_row) + self.check_expense_account(item_row) if item_row.get("target_warehouse"): _target_wh_inv_dict = doc.get_inventory_account_dict( @@ -152,3 +152,78 @@ class BaseStockGLComposer(BaseGLComposer): return process_gl_map( gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation ) + + def get_debit_field_precision(self): + if not frappe.flags.debit_field_precision: + frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency") + + return frappe.flags.debit_field_precision + + def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): + doc = self.doc + if doc.doctype == "Stock Reconciliation": + reconciliation_purpose = frappe.db.get_value(doc.doctype, doc.name, "purpose") + is_opening = "Yes" if reconciliation_purpose == "Opening Stock" else "No" + details = [] + for voucher_detail_no in sle_map: + details.append( + frappe._dict( + { + "name": voucher_detail_no, + "expense_account": default_expense_account, + "cost_center": default_cost_center, + "is_opening": is_opening, + } + ) + ) + return details + else: + details = doc.get("items") + + if default_expense_account or default_cost_center: + for d in details: + if default_expense_account and not d.get("expense_account"): + d.expense_account = default_expense_account + if default_cost_center and not d.get("cost_center"): + d.cost_center = default_cost_center + + return details + + def check_expense_account(self, item): + if not item.get("expense_account"): + msg = _("Please set an Expense Account in the Items table") + frappe.throw( + _("Row #{0}: Expense Account not set for the Item {1}. {2}").format( + item.idx, frappe.bold(item.item_code), msg + ), + title=_("Expense Account Missing"), + ) + + else: + is_expense_account = ( + frappe.get_cached_value("Account", item.get("expense_account"), "report_type") + == "Profit and Loss" + ) + if ( + self.doc.doctype + not in ( + "Purchase Receipt", + "Purchase Invoice", + "Stock Reconciliation", + "Stock Entry", + "Subcontracting Receipt", + "Delivery Note", + ) + and not is_expense_account + ): + frappe.throw( + _("Expense / Difference account ({0}) must be a 'Profit or Loss' account").format( + item.get("expense_account") + ) + ) + if is_expense_account and not item.get("cost_center"): + frappe.throw( + _("{0} {1}: Cost Center is mandatory for Item {2}").format( + _(self.doc.doctype), self.doc.name, item.get("item_code") + ) + ) From 926bdf5a2054943e48dd99b32ca8684bb43f2540 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 15:54:34 +0530 Subject: [PATCH 05/21] refactor(stock): extract QualityInspectionService from StockController Move quality-inspection validation (validate_inspection + validate_qi_presence/ submission/rejection) into erpnext/stock/services/quality_inspection.py as a delegating service. validate_inspection keeps a controller delegator (called from validate() and 3 other doctypes); the three row-level helpers are internal-only. The whitelisted module fns check_item_quality_inspection / make_quality_inspections stay in stock_controller (stable endpoint paths). Behaviour-preserving: ledger snapshots + quality inspection suite stay green. --- erpnext/controllers/stock_controller.py | 87 +-------------- erpnext/stock/services/quality_inspection.py | 110 +++++++++++++++++++ 2 files changed, 112 insertions(+), 85 deletions(-) create mode 100644 erpnext/stock/services/quality_inspection.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index bffd3489ce7..2eb44212616 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -438,92 +438,9 @@ class StockController(AccountsController): ) def validate_inspection(self): - """Checks if quality inspection is set/ is valid for Items that require inspection.""" - inspection_fieldname_map = { - "Purchase Receipt": "inspection_required_before_purchase", - "Purchase Invoice": "inspection_required_before_purchase", - "Subcontracting Receipt": "inspection_required_before_purchase", - "Sales Invoice": "inspection_required_before_delivery", - "Delivery Note": "inspection_required_before_delivery", - } - inspection_required_fieldname = inspection_fieldname_map.get(self.doctype) + from erpnext.stock.services.quality_inspection import QualityInspectionService - # return if inspection is not required on document level - if ( - (not inspection_required_fieldname and self.doctype != "Stock Entry") - or (self.doctype == "Stock Entry" and not self.inspection_required) - or (self.doctype in ["Sales Invoice", "Purchase Invoice"] and not self.update_stock) - ): - return - - for row in self.get("items"): - qi_required = False - if inspection_required_fieldname and frappe.get_cached_value( - "Item", row.item_code, inspection_required_fieldname - ): - qi_required = True - elif self.doctype == "Stock Entry" and row.t_warehouse: - qi_required = True # inward stock needs inspection - - if row.get("secondary_item_type") or row.get("is_legacy_scrap_item"): - continue - - if qi_required: # validate row only if inspection is required on item level - if self.doctype in [ - "Purchase Receipt", - "Purchase Invoice", - "Sales Invoice", - "Delivery Note", - ] and frappe.get_single_value( - "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" - ): - return - - self.validate_qi_presence(row) - if self.docstatus == 1: - self.validate_qi_submission(row) - self.validate_qi_rejection(row) - - def validate_qi_presence(self, row): - """Check if QI is present on row level. Warn on save and stop on submit if missing.""" - if not row.quality_inspection: - msg = _("Row #{0}: Quality Inspection is required for Item {1}").format( - row.idx, frappe.bold(row.item_code) - ) - if self.docstatus == 1: - frappe.throw(msg, title=_("Inspection Required"), exc=QualityInspectionRequiredError) - else: - frappe.msgprint(msg, title=_("Inspection Required"), indicator="blue") - - def validate_qi_submission(self, row): - """Check if QI is submitted on row level, during submission""" - action = frappe.get_single_value("Stock Settings", "action_if_quality_inspection_is_not_submitted") - qa_docstatus = frappe.db.get_value("Quality Inspection", row.quality_inspection, "docstatus") - - if qa_docstatus != 1: - link = frappe.utils.get_link_to_form("Quality Inspection", row.quality_inspection) - msg = _("Row #{0}: Quality Inspection {1} is not submitted for the item: {2}").format( - row.idx, link, row.item_code - ) - if action == "Stop": - frappe.throw(msg, title=_("Inspection Submission"), exc=QualityInspectionNotSubmittedError) - else: - frappe.msgprint(msg, alert=True, indicator="orange") - - def validate_qi_rejection(self, row): - """Check if QI is rejected on row level, during submission""" - action = frappe.get_single_value("Stock Settings", "action_if_quality_inspection_is_rejected") - qa_status = frappe.db.get_value("Quality Inspection", row.quality_inspection, "status") - - if qa_status == "Rejected": - link = frappe.utils.get_link_to_form("Quality Inspection", row.quality_inspection) - msg = _("Row #{0}: Quality Inspection {1} was rejected for item {2}").format( - row.idx, link, row.item_code - ) - if action == "Stop": - frappe.throw(msg, title=_("Inspection Rejected"), exc=QualityInspectionRejectedError) - else: - frappe.msgprint(msg, alert=True, indicator="orange") + return QualityInspectionService(self).validate_inspection() def update_blanket_order(self): blanket_orders = list(set([d.blanket_order for d in self.items if d.blanket_order])) diff --git a/erpnext/stock/services/quality_inspection.py b/erpnext/stock/services/quality_inspection.py new file mode 100644 index 00000000000..28fa8320dce --- /dev/null +++ b/erpnext/stock/services/quality_inspection.py @@ -0,0 +1,110 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Quality inspection validation for stock transactions. + +Extracted from ``StockController``. Validates that items requiring quality +inspection have a present / submitted / non-rejected Quality Inspection. +""" + +import frappe +from frappe import _ + + +class QualityInspectionService: + def __init__(self, doc) -> None: + self.doc = doc + + def validate_inspection(self): + """Checks if quality inspection is set/ is valid for Items that require inspection.""" + inspection_fieldname_map = { + "Purchase Receipt": "inspection_required_before_purchase", + "Purchase Invoice": "inspection_required_before_purchase", + "Subcontracting Receipt": "inspection_required_before_purchase", + "Sales Invoice": "inspection_required_before_delivery", + "Delivery Note": "inspection_required_before_delivery", + } + inspection_required_fieldname = inspection_fieldname_map.get(self.doc.doctype) + + # return if inspection is not required on document level + if ( + (not inspection_required_fieldname and self.doc.doctype != "Stock Entry") + or (self.doc.doctype == "Stock Entry" and not self.doc.inspection_required) + or (self.doc.doctype in ["Sales Invoice", "Purchase Invoice"] and not self.doc.update_stock) + ): + return + + for row in self.doc.get("items"): + qi_required = False + if inspection_required_fieldname and frappe.get_cached_value( + "Item", row.item_code, inspection_required_fieldname + ): + qi_required = True + elif self.doc.doctype == "Stock Entry" and row.t_warehouse: + qi_required = True # inward stock needs inspection + + if row.get("secondary_item_type") or row.get("is_legacy_scrap_item"): + continue + + if qi_required: # validate row only if inspection is required on item level + if self.doc.doctype in [ + "Purchase Receipt", + "Purchase Invoice", + "Sales Invoice", + "Delivery Note", + ] and frappe.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ): + return + + self.validate_qi_presence(row) + if self.doc.docstatus == 1: + self.validate_qi_submission(row) + self.validate_qi_rejection(row) + + def validate_qi_presence(self, row): + """Check if QI is present on row level. Warn on save and stop on submit if missing.""" + from erpnext.controllers.stock_controller import QualityInspectionRequiredError + + if not row.quality_inspection: + msg = _("Row #{0}: Quality Inspection is required for Item {1}").format( + row.idx, frappe.bold(row.item_code) + ) + if self.doc.docstatus == 1: + frappe.throw(msg, title=_("Inspection Required"), exc=QualityInspectionRequiredError) + else: + frappe.msgprint(msg, title=_("Inspection Required"), indicator="blue") + + def validate_qi_submission(self, row): + """Check if QI is submitted on row level, during submission""" + from erpnext.controllers.stock_controller import QualityInspectionNotSubmittedError + + action = frappe.get_single_value("Stock Settings", "action_if_quality_inspection_is_not_submitted") + qa_docstatus = frappe.db.get_value("Quality Inspection", row.quality_inspection, "docstatus") + + if qa_docstatus != 1: + link = frappe.utils.get_link_to_form("Quality Inspection", row.quality_inspection) + msg = _("Row #{0}: Quality Inspection {1} is not submitted for the item: {2}").format( + row.idx, link, row.item_code + ) + if action == "Stop": + frappe.throw(msg, title=_("Inspection Submission"), exc=QualityInspectionNotSubmittedError) + else: + frappe.msgprint(msg, alert=True, indicator="orange") + + def validate_qi_rejection(self, row): + """Check if QI is rejected on row level, during submission""" + from erpnext.controllers.stock_controller import QualityInspectionRejectedError + + action = frappe.get_single_value("Stock Settings", "action_if_quality_inspection_is_rejected") + qa_status = frappe.db.get_value("Quality Inspection", row.quality_inspection, "status") + + if qa_status == "Rejected": + link = frappe.utils.get_link_to_form("Quality Inspection", row.quality_inspection) + msg = _("Row #{0}: Quality Inspection {1} was rejected for item {2}").format( + row.idx, link, row.item_code + ) + if action == "Stop": + frappe.throw(msg, title=_("Inspection Rejected"), exc=QualityInspectionRejectedError) + else: + frappe.msgprint(msg, alert=True, indicator="orange") From 7c2406077a0a2db2426a935f7df3e47870cad2da Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 16:05:42 +0530 Subject: [PATCH 06/21] refactor(stock): extract StockInternalTransferService from StockController Move internal-transfer warehouse/currency/packed-item/over-receipt-qty validation into erpnext/stock/services/internal_transfer.py as a delegating service. This is the stock-side counterpart to accounts/services/internal_transfer.py (party/rate/ pricing). validate_internal_transfer keeps a controller delegator (validate hook); the other 7 methods are internal-only. The is_internal_transfer() predicate is already consolidated on AccountsController. Behaviour-preserving: ledger snapshots + DN/PR internal-transfer suites stay green. --- erpnext/controllers/stock_controller.py | 154 +---------------- erpnext/stock/services/internal_transfer.py | 179 ++++++++++++++++++++ 2 files changed, 181 insertions(+), 152 deletions(-) create mode 100644 erpnext/stock/services/internal_transfer.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 2eb44212616..5eb6016dbb2 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -467,159 +467,9 @@ class StockController(AccountsController): d.stock_uom_rate = d.rate / (d.conversion_factor or 1) def validate_internal_transfer(self): - if self.doctype in ("Sales Invoice", "Delivery Note", "Purchase Invoice", "Purchase Receipt"): - if self.is_internal_transfer(): - self.validate_in_transit_warehouses() - self.validate_multi_currency() - self.validate_packed_items() + from erpnext.stock.services.internal_transfer import StockInternalTransferService - if self.get("is_internal_supplier") and self.docstatus == 1: - self.validate_internal_transfer_qty() - else: - self.validate_internal_transfer_warehouse() - - def validate_internal_transfer_warehouse(self): - for row in self.items: - if row.get("target_warehouse"): - row.target_warehouse = None - - if row.get("from_warehouse"): - row.from_warehouse = None - - def validate_in_transit_warehouses(self): - if (self.doctype == "Sales Invoice" and self.get("update_stock")) or self.doctype == "Delivery Note": - for item in self.get("items"): - if not item.target_warehouse: - frappe.throw( - _("Row {0}: Target Warehouse is mandatory for internal transfers").format(item.idx) - ) - - if ( - self.doctype == "Purchase Invoice" and self.get("update_stock") - ) or self.doctype == "Purchase Receipt": - for item in self.get("items"): - if not item.from_warehouse: - frappe.throw( - _("Row {0}: From Warehouse is mandatory for internal transfers").format(item.idx) - ) - - def validate_multi_currency(self): - if self.currency != self.company_currency: - frappe.throw(_("Internal transfers can only be done in company's default currency")) - - def validate_packed_items(self): - if self.doctype in ("Sales Invoice", "Delivery Note Item") and self.get("packed_items"): - frappe.throw(_("Packed Items cannot be transferred internally")) - - def validate_internal_transfer_qty(self): - if self.doctype not in ["Purchase Invoice", "Purchase Receipt"]: - return - - self.__inter_company_reference = ( - self.get("inter_company_reference") - if self.doctype == "Purchase Invoice" - else self.get("inter_company_invoice_reference") - ) - - item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty() - if not item_wise_transfer_qty: - return - - item_wise_received_qty = self.get_item_wise_inter_received_qty() - precision = frappe.get_precision(self.doctype + " Item", "qty") - - over_receipt_allowance = frappe.get_single_value("Stock Settings", "over_delivery_receipt_allowance") - - parent_doctype = { - "Purchase Receipt": "Delivery Note", - "Purchase Invoice": "Sales Invoice", - }.get(self.doctype) - - for key, transferred_qty in item_wise_transfer_qty.items(): - recevied_qty = flt(item_wise_received_qty.get(key), precision) - if over_receipt_allowance: - transferred_qty = transferred_qty + flt( - transferred_qty * over_receipt_allowance / 100, precision - ) - - if recevied_qty > flt(transferred_qty, precision): - frappe.throw( - _("For Item {0} cannot be received more than {1} qty against the {2} {3}").format( - bold(key[1]), - bold(flt(transferred_qty, precision)), - bold(parent_doctype), - get_link_to_form(parent_doctype, self.__inter_company_reference), - ) - ) - - def get_item_wise_inter_transfer_qty(self): - parent_doctype = { - "Purchase Receipt": "Delivery Note", - "Purchase Invoice": "Sales Invoice", - }.get(self.doctype) - - child_doctype = parent_doctype + " Item" - - parent_tab = frappe.qb.DocType(parent_doctype) - child_tab = frappe.qb.DocType(child_doctype) - - query = ( - frappe.qb.from_(parent_doctype) - .inner_join(child_tab) - .on(child_tab.parent == parent_tab.name) - .select( - child_tab.name, - child_tab.item_code, - child_tab.qty, - ) - .where((parent_tab.name == self.__inter_company_reference) & (parent_tab.docstatus == 1)) - ) - - data = query.run(as_dict=True) - item_wise_transfer_qty = defaultdict(float) - for row in data: - item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty) - - return item_wise_transfer_qty - - def get_item_wise_inter_received_qty(self): - child_doctype = self.doctype + " Item" - - parent_tab = frappe.qb.DocType(self.doctype) - child_tab = frappe.qb.DocType(child_doctype) - - query = ( - frappe.qb.from_(self.doctype) - .inner_join(child_tab) - .on(child_tab.parent == parent_tab.name) - .select( - child_tab.item_code, - child_tab.qty, - ) - .where(parent_tab.docstatus == 1) - ) - - if self.doctype == "Purchase Invoice": - query = query.select( - child_tab.sales_invoice_item.as_("name"), - ) - - query = query.where( - parent_tab.inter_company_invoice_reference == self.inter_company_invoice_reference - ) - else: - query = query.select( - child_tab.delivery_note_item.as_("name"), - ) - - query = query.where(parent_tab.inter_company_reference == self.inter_company_reference) - - data = query.run(as_dict=True) - item_wise_transfer_qty = defaultdict(float) - for row in data: - item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty) - - return item_wise_transfer_qty + return StockInternalTransferService(self).validate_internal_transfer() def validate_putaway_capacity(self): # if over receipt is attempted while 'apply putaway rule' is disabled diff --git a/erpnext/stock/services/internal_transfer.py b/erpnext/stock/services/internal_transfer.py new file mode 100644 index 00000000000..6a2fbc4b857 --- /dev/null +++ b/erpnext/stock/services/internal_transfer.py @@ -0,0 +1,179 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Internal (inter-company) transfer validation for stock transactions. + +Extracted from ``StockController``. Validates warehouses, currency, packed items +and over-receipt quantities for internal-transfer stock vouchers. This is the +stock-side counterpart to ``accounts/services/internal_transfer.py`` (which owns +the party / rate / pricing / account side). The ``is_internal_transfer()`` +predicate lives on ``AccountsController`` (delegating to the accounts service). +""" + +from collections import defaultdict + +import frappe +from frappe import _, bold +from frappe.utils import flt, get_link_to_form + + +class StockInternalTransferService: + def __init__(self, doc) -> None: + self.doc = doc + + def validate_internal_transfer(self): + if self.doc.doctype in ("Sales Invoice", "Delivery Note", "Purchase Invoice", "Purchase Receipt"): + if self.doc.is_internal_transfer(): + self.validate_in_transit_warehouses() + self.validate_multi_currency() + self.validate_packed_items() + + if self.doc.get("is_internal_supplier") and self.doc.docstatus == 1: + self.validate_internal_transfer_qty() + else: + self.validate_internal_transfer_warehouse() + + def validate_internal_transfer_warehouse(self): + for row in self.doc.items: + if row.get("target_warehouse"): + row.target_warehouse = None + + if row.get("from_warehouse"): + row.from_warehouse = None + + def validate_in_transit_warehouses(self): + if ( + self.doc.doctype == "Sales Invoice" and self.doc.get("update_stock") + ) or self.doc.doctype == "Delivery Note": + for item in self.doc.get("items"): + if not item.target_warehouse: + frappe.throw( + _("Row {0}: Target Warehouse is mandatory for internal transfers").format(item.idx) + ) + + if ( + self.doc.doctype == "Purchase Invoice" and self.doc.get("update_stock") + ) or self.doc.doctype == "Purchase Receipt": + for item in self.doc.get("items"): + if not item.from_warehouse: + frappe.throw( + _("Row {0}: From Warehouse is mandatory for internal transfers").format(item.idx) + ) + + def validate_multi_currency(self): + if self.doc.currency != self.doc.company_currency: + frappe.throw(_("Internal transfers can only be done in company's default currency")) + + def validate_packed_items(self): + if self.doc.doctype in ("Sales Invoice", "Delivery Note Item") and self.doc.get("packed_items"): + frappe.throw(_("Packed Items cannot be transferred internally")) + + def validate_internal_transfer_qty(self): + if self.doc.doctype not in ["Purchase Invoice", "Purchase Receipt"]: + return + + self.__inter_company_reference = ( + self.doc.get("inter_company_reference") + if self.doc.doctype == "Purchase Invoice" + else self.doc.get("inter_company_invoice_reference") + ) + + item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty() + if not item_wise_transfer_qty: + return + + item_wise_received_qty = self.get_item_wise_inter_received_qty() + precision = frappe.get_precision(self.doc.doctype + " Item", "qty") + + over_receipt_allowance = frappe.get_single_value("Stock Settings", "over_delivery_receipt_allowance") + + parent_doctype = { + "Purchase Receipt": "Delivery Note", + "Purchase Invoice": "Sales Invoice", + }.get(self.doc.doctype) + + for key, transferred_qty in item_wise_transfer_qty.items(): + recevied_qty = flt(item_wise_received_qty.get(key), precision) + if over_receipt_allowance: + transferred_qty = transferred_qty + flt( + transferred_qty * over_receipt_allowance / 100, precision + ) + + if recevied_qty > flt(transferred_qty, precision): + frappe.throw( + _("For Item {0} cannot be received more than {1} qty against the {2} {3}").format( + bold(key[1]), + bold(flt(transferred_qty, precision)), + bold(parent_doctype), + get_link_to_form(parent_doctype, self.__inter_company_reference), + ) + ) + + def get_item_wise_inter_transfer_qty(self): + parent_doctype = { + "Purchase Receipt": "Delivery Note", + "Purchase Invoice": "Sales Invoice", + }.get(self.doc.doctype) + + child_doctype = parent_doctype + " Item" + + parent_tab = frappe.qb.DocType(parent_doctype) + child_tab = frappe.qb.DocType(child_doctype) + + query = ( + frappe.qb.from_(parent_doctype) + .inner_join(child_tab) + .on(child_tab.parent == parent_tab.name) + .select( + child_tab.name, + child_tab.item_code, + child_tab.qty, + ) + .where((parent_tab.name == self.__inter_company_reference) & (parent_tab.docstatus == 1)) + ) + + data = query.run(as_dict=True) + item_wise_transfer_qty = defaultdict(float) + for row in data: + item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty) + + return item_wise_transfer_qty + + def get_item_wise_inter_received_qty(self): + child_doctype = self.doc.doctype + " Item" + + parent_tab = frappe.qb.DocType(self.doc.doctype) + child_tab = frappe.qb.DocType(child_doctype) + + query = ( + frappe.qb.from_(self.doc.doctype) + .inner_join(child_tab) + .on(child_tab.parent == parent_tab.name) + .select( + child_tab.item_code, + child_tab.qty, + ) + .where(parent_tab.docstatus == 1) + ) + + if self.doc.doctype == "Purchase Invoice": + query = query.select( + child_tab.sales_invoice_item.as_("name"), + ) + + query = query.where( + parent_tab.inter_company_invoice_reference == self.doc.inter_company_invoice_reference + ) + else: + query = query.select( + child_tab.delivery_note_item.as_("name"), + ) + + query = query.where(parent_tab.inter_company_reference == self.doc.inter_company_reference) + + data = query.run(as_dict=True) + item_wise_transfer_qty = defaultdict(float) + for row in data: + item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty) + + return item_wise_transfer_qty From 8e41e75d895214a1195000729bde64b3f9cff365 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 16:22:38 +0530 Subject: [PATCH 07/21] refactor(stock): relocate landed-cost and putaway logic to owning doctypes These clusters are really other doctypes' logic parked on StockController, so they move next to the doctype that owns them rather than into a stock service: - set_landed_cost_voucher_amount / get_item_account_wise_lcv_entries / has_landed_cost_amount -> landed_cost_voucher.py (free functions). Controller keeps thin delegators (called as doc.X from 4 GL composers, buying_controller and the LCV doctype). - validate_putaway_capacity -> putaway_rule.py (free function, next to get_available_putaway_capacity it already used). Controller keeps a delegator (validate hook + Stock Entry/Reconciliation); prepare_over_receipt_message becomes a private helper there. Drops now-unused Sum/defaultdict imports from stock_controller. Behaviour-preserving: ledger snapshots, putaway and landed-cost suites stay green. --- erpnext/controllers/stock_controller.py | 157 ++---------------- .../landed_cost_voucher.py | 91 ++++++++++ .../doctype/putaway_rule/putaway_rule.py | 68 ++++++++ 3 files changed, 172 insertions(+), 144 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 5eb6016dbb2..eb10decd702 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -2,11 +2,9 @@ # License: GNU General Public License v3. See license.txt import json -from collections import defaultdict import frappe from frappe import _, bold -from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, get_link_to_form, getdate import erpnext @@ -287,89 +285,23 @@ class StockController(AccountsController): return StockLedgerService(self).get_sl_entries(d, args) def set_landed_cost_voucher_amount(self): - for d in self.get("items"): - lcv_item = frappe.qb.DocType("Landed Cost Item") - query = ( - frappe.qb.from_(lcv_item) - .select(Sum(lcv_item.applicable_charges), lcv_item.cost_center) - .where((lcv_item.docstatus == 1) & (lcv_item.receipt_document == self.name)) - ) - - if self.doctype == "Stock Entry": - query = query.where(lcv_item.stock_entry_item == d.name) - else: - query = query.where(lcv_item.purchase_receipt_item == d.name) - - lc_voucher_data = query.run(as_list=True) - - d.landed_cost_voucher_amount = lc_voucher_data[0][0] if lc_voucher_data else 0.0 - if not d.cost_center and lc_voucher_data and lc_voucher_data[0][1]: - d.db_set("cost_center", lc_voucher_data[0][1]) - - def has_landed_cost_amount(self): - for row in self.items: - if row.get("landed_cost_voucher_amount"): - return True - - return False - - def get_item_account_wise_lcv_entries(self): - if not self.has_landed_cost_amount(): - return - - landed_cost_vouchers = frappe.get_all( - "Landed Cost Purchase Receipt", - fields=["parent"], - filters={"receipt_document": self.name, "docstatus": 1}, + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + set_landed_cost_voucher_amount, ) - if not landed_cost_vouchers: - return + return set_landed_cost_voucher_amount(self) - item_account_wise_cost = {} + def has_landed_cost_amount(self): + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import has_landed_cost_amount - row_fieldname = "purchase_receipt_item" - if self.doctype == "Stock Entry": - row_fieldname = "stock_entry_item" + return has_landed_cost_amount(self) - for lcv in landed_cost_vouchers: - landed_cost_voucher_doc = frappe.get_doc("Landed Cost Voucher", lcv.parent) + def get_item_account_wise_lcv_entries(self): + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_item_account_wise_lcv_entries, + ) - based_on_field = "applicable_charges" - # Use amount field for total item cost for manually cost distributed LCVs - if landed_cost_voucher_doc.distribute_charges_based_on != "Distribute Manually": - based_on_field = frappe.scrub(landed_cost_voucher_doc.distribute_charges_based_on) - - total_item_cost = 0 - - if based_on_field: - for item in landed_cost_voucher_doc.items: - total_item_cost += item.get(based_on_field) - - for item in landed_cost_voucher_doc.items: - if item.receipt_document == self.name: - for account in landed_cost_voucher_doc.taxes: - exchange_rate = account.exchange_rate or 1 - item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {}) - item_account_wise_cost[(item.item_code, item.get(row_fieldname))].setdefault( - account.expense_account, {"amount": 0.0, "base_amount": 0.0} - ) - - item_row = item_account_wise_cost[(item.item_code, item.get(row_fieldname))][ - account.expense_account - ] - - if total_item_cost > 0: - item_row["amount"] += account.amount * item.get(based_on_field) / total_item_cost - - item_row["base_amount"] += ( - account.base_amount * item.get(based_on_field) / total_item_cost - ) - else: - item_row["amount"] += item.applicable_charges / exchange_rate - item_row["base_amount"] += item.applicable_charges - - return item_account_wise_cost + return get_item_account_wise_lcv_entries(self) def update_inventory_dimensions(self, row, sl_dict) -> None: from erpnext.stock.services.stock_ledger import StockLedgerService @@ -472,72 +404,9 @@ class StockController(AccountsController): return StockInternalTransferService(self).validate_internal_transfer() def validate_putaway_capacity(self): - # if over receipt is attempted while 'apply putaway rule' is disabled - # and if rule was applied on the transaction, validate it. - from erpnext.stock.doctype.putaway_rule.putaway_rule import get_available_putaway_capacity + from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity - valid_doctype = self.doctype in ( - "Purchase Receipt", - "Stock Entry", - "Purchase Invoice", - "Stock Reconciliation", - ) - - if not frappe.get_all("Putaway Rule", limit=1): - return - - if self.doctype == "Purchase Invoice" and self.get("update_stock") == 0: - valid_doctype = False - - if valid_doctype: - rule_map = defaultdict(dict) - for item in self.get("items"): - warehouse_field = "t_warehouse" if self.doctype == "Stock Entry" else "warehouse" - rule = frappe.db.get_value( - "Putaway Rule", - {"item_code": item.get("item_code"), "warehouse": item.get(warehouse_field)}, - ["stock_capacity", "name", "disable"], - as_dict=True, - ) - if rule: - if rule.get("disabled"): - continue # dont validate for disabled rule - - if self.doctype == "Stock Reconciliation": - stock_qty = flt(item.qty) - else: - stock_qty = ( - flt(item.transfer_qty) if self.doctype == "Stock Entry" else flt(item.stock_qty) - ) - - rule_name = rule.get("name") - if not rule_map[rule_name]: - rule_map[rule_name]["warehouse"] = item.get(warehouse_field) - rule_map[rule_name]["item"] = item.get("item_code") - rule_map[rule_name]["qty_put"] = 0 - rule_map[rule_name]["capacity"] = ( - rule.stock_capacity - if self.doctype == "Stock Reconciliation" - else get_available_putaway_capacity(rule_name) - ) - rule_map[rule_name]["qty_put"] += flt(stock_qty) - - for rule, values in rule_map.items(): - if flt(values["qty_put"]) > flt(values["capacity"]): - message = self.prepare_over_receipt_message(rule, values) - frappe.throw(msg=message, title=_("Over Receipt")) - - def prepare_over_receipt_message(self, rule, values): - message = _("{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}.").format( - frappe.bold(values["qty_put"]), - frappe.bold(values["item"]), - frappe.bold(values["warehouse"]), - frappe.bold(values["capacity"]), - ) - message += "

" - rule_link = frappe.utils.get_link_to_form("Putaway Rule", rule) - message += _("Please adjust the qty or edit {0} to proceed.").format(rule_link) - return message + return validate_putaway_capacity(self) def repost_future_sle_and_gle(self, force=False, via_landed_cost_voucher=False): from erpnext.stock.services.stock_ledger import StockLedgerService diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 932ce242b01..dc5384f76ee 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Sum from frappe.utils import cint, flt import erpnext @@ -523,3 +524,93 @@ def get_vendor_invoice_query(filters): query = query.where(doctype.name == filters.get("name")) return query + + +def set_landed_cost_voucher_amount(doc): + """Set landed_cost_voucher_amount on the receipt document's items from submitted LCVs.""" + for d in doc.get("items"): + lcv_item = frappe.qb.DocType("Landed Cost Item") + query = ( + frappe.qb.from_(lcv_item) + .select(Sum(lcv_item.applicable_charges), lcv_item.cost_center) + .where((lcv_item.docstatus == 1) & (lcv_item.receipt_document == doc.name)) + ) + + if doc.doctype == "Stock Entry": + query = query.where(lcv_item.stock_entry_item == d.name) + else: + query = query.where(lcv_item.purchase_receipt_item == d.name) + + lc_voucher_data = query.run(as_list=True) + + d.landed_cost_voucher_amount = lc_voucher_data[0][0] if lc_voucher_data else 0.0 + if not d.cost_center and lc_voucher_data and lc_voucher_data[0][1]: + d.db_set("cost_center", lc_voucher_data[0][1]) + + +def has_landed_cost_amount(doc): + for row in doc.items: + if row.get("landed_cost_voucher_amount"): + return True + + return False + + +def get_item_account_wise_lcv_entries(doc): + """Account-wise landed-cost map for a receipt document, consumed by the GL composers.""" + if not has_landed_cost_amount(doc): + return + + landed_cost_vouchers = frappe.get_all( + "Landed Cost Purchase Receipt", + fields=["parent"], + filters={"receipt_document": doc.name, "docstatus": 1}, + ) + + if not landed_cost_vouchers: + return + + item_account_wise_cost = {} + + row_fieldname = "purchase_receipt_item" + if doc.doctype == "Stock Entry": + row_fieldname = "stock_entry_item" + + for lcv in landed_cost_vouchers: + landed_cost_voucher_doc = frappe.get_doc("Landed Cost Voucher", lcv.parent) + + based_on_field = "applicable_charges" + # Use amount field for total item cost for manually cost distributed LCVs + if landed_cost_voucher_doc.distribute_charges_based_on != "Distribute Manually": + based_on_field = frappe.scrub(landed_cost_voucher_doc.distribute_charges_based_on) + + total_item_cost = 0 + + if based_on_field: + for item in landed_cost_voucher_doc.items: + total_item_cost += item.get(based_on_field) + + for item in landed_cost_voucher_doc.items: + if item.receipt_document == doc.name: + for account in landed_cost_voucher_doc.taxes: + exchange_rate = account.exchange_rate or 1 + item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {}) + item_account_wise_cost[(item.item_code, item.get(row_fieldname))].setdefault( + account.expense_account, {"amount": 0.0, "base_amount": 0.0} + ) + + item_row = item_account_wise_cost[(item.item_code, item.get(row_fieldname))][ + account.expense_account + ] + + if total_item_cost > 0: + item_row["amount"] += account.amount * item.get(based_on_field) / total_item_cost + + item_row["base_amount"] += ( + account.base_amount * item.get(based_on_field) / total_item_cost + ) + else: + item_row["amount"] += item.applicable_charges / exchange_rate + item_row["base_amount"] += item.applicable_charges + + return item_account_wise_cost diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index 382b7106552..ea2474b112b 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -334,3 +334,71 @@ def get_serial_nos_to_allocate(serial_nos, to_allocate): return "\n".join(allocated_serial_nos) if allocated_serial_nos else "" else: return "" + + +def validate_putaway_capacity(doc): + # if over receipt is attempted while 'apply putaway rule' is disabled + # and if rule was applied on the transaction, validate it. + valid_doctype = doc.doctype in ( + "Purchase Receipt", + "Stock Entry", + "Purchase Invoice", + "Stock Reconciliation", + ) + + if not frappe.get_all("Putaway Rule", limit=1): + return + + if doc.doctype == "Purchase Invoice" and doc.get("update_stock") == 0: + valid_doctype = False + + if valid_doctype: + rule_map = defaultdict(dict) + for item in doc.get("items"): + warehouse_field = "t_warehouse" if doc.doctype == "Stock Entry" else "warehouse" + rule = frappe.db.get_value( + "Putaway Rule", + {"item_code": item.get("item_code"), "warehouse": item.get(warehouse_field)}, + ["stock_capacity", "name", "disable"], + as_dict=True, + ) + if rule: + if rule.get("disabled"): + continue # dont validate for disabled rule + + if doc.doctype == "Stock Reconciliation": + stock_qty = flt(item.qty) + else: + stock_qty = ( + flt(item.transfer_qty) if doc.doctype == "Stock Entry" else flt(item.stock_qty) + ) + + rule_name = rule.get("name") + if not rule_map[rule_name]: + rule_map[rule_name]["warehouse"] = item.get(warehouse_field) + rule_map[rule_name]["item"] = item.get("item_code") + rule_map[rule_name]["qty_put"] = 0 + rule_map[rule_name]["capacity"] = ( + rule.stock_capacity + if doc.doctype == "Stock Reconciliation" + else get_available_putaway_capacity(rule_name) + ) + rule_map[rule_name]["qty_put"] += flt(stock_qty) + + for rule, values in rule_map.items(): + if flt(values["qty_put"]) > flt(values["capacity"]): + message = _prepare_over_receipt_message(rule, values) + frappe.throw(msg=message, title=_("Over Receipt")) + + +def _prepare_over_receipt_message(rule, values): + message = _("{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}.").format( + frappe.bold(values["qty_put"]), + frappe.bold(values["item"]), + frappe.bold(values["warehouse"]), + frappe.bold(values["capacity"]), + ) + message += "

" + rule_link = frappe.utils.get_link_to_form("Putaway Rule", rule) + message += _("Please adjust the qty or edit {0} to proceed.").format(rule_link) + return message From d1fd91a542596310244abe466cee6a1e6ffb7639 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 4 Jun 2026 16:51:17 +0530 Subject: [PATCH 08/21] refactor(stock): extract ledger preview helpers to ledger_preview module Move the read-side GL/SLE preview helpers (get_accounting_ledger_preview, get_stock_ledger_preview, get_sl_entries_for_preview, get_gl_entries_for_preview, get_columns, get_data) into erpnext/stock/services/ledger_preview.py. The whitelisted show_accounting_ledger_preview / show_stock_ledger_preview entry points stay in stock_controller (client JS hardcodes their dotted path) and call the relocated helpers. Behaviour-preserving: ledger characterization snapshots stay green. --- erpnext/controllers/stock_controller.py | 120 +-------------------- erpnext/stock/services/ledger_preview.py | 129 +++++++++++++++++++++++ 2 files changed, 133 insertions(+), 116 deletions(-) create mode 100644 erpnext/stock/services/ledger_preview.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index eb10decd702..67862520a83 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -612,6 +612,8 @@ class StockController(AccountsController): @frappe.whitelist() def show_accounting_ledger_preview(company: str, doctype: str, docname: str): + from erpnext.stock.services.ledger_preview import get_accounting_ledger_preview + filters = frappe._dict(company=company, include_dimensions=1) doc = frappe.get_lazy_doc(doctype, docname) doc.run_method("before_gl_preview") @@ -625,6 +627,8 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str): @frappe.whitelist() def show_stock_ledger_preview(company: str, doctype: str, docname: str): + from erpnext.stock.services.ledger_preview import get_stock_ledger_preview + filters = frappe._dict(company=company) doc = frappe.get_lazy_doc(doctype, docname) doc.run_method("before_sl_preview") @@ -639,122 +643,6 @@ def show_stock_ledger_preview(company: str, doctype: str, docname: str): } -def get_accounting_ledger_preview(doc, filters): - from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns - - gl_columns, gl_data = [], [] - fields = [ - "posting_date", - "account", - "debit", - "credit", - "against", - "party_type", - "party", - "cost_center", - "against_voucher_type", - "against_voucher", - ] - - doc.docstatus = 1 - - if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): - doc.update_stock_ledger() - - doc.make_gl_entries() - columns = get_gl_columns(filters) - gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields) - - gl_columns = get_columns(columns, fields) - gl_data = get_data(fields, gl_entries) - - return gl_columns, gl_data - - -def get_stock_ledger_preview(doc, filters): - from erpnext.stock.report.stock_ledger.stock_ledger import get_columns as get_sl_columns - - sl_columns, sl_data = [], [] - fields = [ - "item_code", - "stock_uom", - "actual_qty", - "qty_after_transaction", - "warehouse", - "incoming_rate", - "valuation_rate", - "stock_value", - "stock_value_difference", - ] - columns_fields = [ - "item_code", - "stock_uom", - "in_qty", - "out_qty", - "qty_after_transaction", - "warehouse", - "incoming_rate", - "in_out_rate", - "stock_value", - "stock_value_difference", - ] - - if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): - doc.docstatus = 1 - doc.make_bundle_using_old_serial_batch_fields() - doc.update_stock_ledger() - - columns = get_sl_columns(filters) - sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields) - - sl_columns = get_columns(columns, columns_fields) - sl_data = get_data(columns_fields, sl_entries) - - return sl_columns, sl_data - - -def get_sl_entries_for_preview(doctype, docname, fields): - sl_entries = frappe.get_all( - "Stock Ledger Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields - ) - - for entry in sl_entries: - if entry.actual_qty > 0: - entry["in_qty"] = entry.actual_qty - entry["out_qty"] = 0 - else: - entry["out_qty"] = abs(entry.actual_qty) - entry["in_qty"] = 0 - - entry["in_out_rate"] = entry["valuation_rate"] - - return sl_entries - - -def get_gl_entries_for_preview(doctype, docname, fields): - return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields) - - -def get_columns(raw_columns, fields): - return [ - {"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")} - for d in raw_columns - if not d.get("hidden") and d.get("fieldname") in fields - ] - - -def get_data(raw_columns, raw_data): - datatable_data = [] - for row in raw_data: - data_row = [] - for column in raw_columns: - data_row.append(row.get(column) or "") - - datatable_data.append(data_row) - - return datatable_data - - def repost_required_for_queue(doc: StockController) -> bool: """check if stock document contains repeated item-warehouse with queue based valuation. diff --git a/erpnext/stock/services/ledger_preview.py b/erpnext/stock/services/ledger_preview.py new file mode 100644 index 00000000000..20c533c8ddd --- /dev/null +++ b/erpnext/stock/services/ledger_preview.py @@ -0,0 +1,129 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Read-side GL / Stock Ledger preview helpers for stock transactions. + +A dry-run consumer of the posting path: it submits-in-memory, reads the resulting +GL/SLE entries and formats them for the datatable preview, then the caller rolls +back. Lives separately from the posting services it orchestrates. The whitelisted +``show_*_preview`` entry points stay on ``stock_controller`` (their dotted path is +referenced from client JS). +""" + +import frappe + + +def get_accounting_ledger_preview(doc, filters): + from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns + + gl_columns, gl_data = [], [] + fields = [ + "posting_date", + "account", + "debit", + "credit", + "against", + "party_type", + "party", + "cost_center", + "against_voucher_type", + "against_voucher", + ] + + doc.docstatus = 1 + + if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): + doc.update_stock_ledger() + + doc.make_gl_entries() + columns = get_gl_columns(filters) + gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields) + + gl_columns = get_columns(columns, fields) + gl_data = get_data(fields, gl_entries) + + return gl_columns, gl_data + + +def get_stock_ledger_preview(doc, filters): + from erpnext.stock.report.stock_ledger.stock_ledger import get_columns as get_sl_columns + + sl_columns, sl_data = [], [] + fields = [ + "item_code", + "stock_uom", + "actual_qty", + "qty_after_transaction", + "warehouse", + "incoming_rate", + "valuation_rate", + "stock_value", + "stock_value_difference", + ] + columns_fields = [ + "item_code", + "stock_uom", + "in_qty", + "out_qty", + "qty_after_transaction", + "warehouse", + "incoming_rate", + "in_out_rate", + "stock_value", + "stock_value_difference", + ] + + if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): + doc.docstatus = 1 + doc.make_bundle_using_old_serial_batch_fields() + doc.update_stock_ledger() + + columns = get_sl_columns(filters) + sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields) + + sl_columns = get_columns(columns, columns_fields) + sl_data = get_data(columns_fields, sl_entries) + + return sl_columns, sl_data + + +def get_sl_entries_for_preview(doctype, docname, fields): + sl_entries = frappe.get_all( + "Stock Ledger Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields + ) + + for entry in sl_entries: + if entry.actual_qty > 0: + entry["in_qty"] = entry.actual_qty + entry["out_qty"] = 0 + else: + entry["out_qty"] = abs(entry.actual_qty) + entry["in_qty"] = 0 + + entry["in_out_rate"] = entry["valuation_rate"] + + return sl_entries + + +def get_gl_entries_for_preview(doctype, docname, fields): + return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields) + + +def get_columns(raw_columns, fields): + return [ + {"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")} + for d in raw_columns + if not d.get("hidden") and d.get("fieldname") in fields + ] + + +def get_data(raw_columns, raw_data): + datatable_data = [] + for row in raw_data: + data_row = [] + for column in raw_columns: + data_row.append(row.get(column) or "") + + datatable_data.append(data_row) + + return datatable_data From 1a4b61a8224ca97e7dc52814859f5996ceef26cc Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 11:44:22 +0530 Subject: [PATCH 09/21] fix(stock): skip disabled Putaway Rules in capacity validation validate_putaway_capacity selected the 'disable' field but checked rule.get('disabled') (always None), so disabled rules still enforced capacity and could wrongly raise 'Over Receipt'. Use the correct 'disable' key. Pre-existing bug surfaced during the stock_controller refactor review. --- erpnext/stock/doctype/putaway_rule/putaway_rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index ea2474b112b..b7dacb9c230 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -363,7 +363,7 @@ def validate_putaway_capacity(doc): as_dict=True, ) if rule: - if rule.get("disabled"): + if rule.get("disable"): continue # dont validate for disabled rule if doc.doctype == "Stock Reconciliation": From a02ef40a5bec94602cca529e9426f1dfe792a46d Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 11:44:25 +0530 Subject: [PATCH 10/21] test(stock): harden ledger characterization harness Address code-review findings on the Phase-0 safety net: - Per-test savepoint/rollback isolation so cumulative SLE fields (qty_after_transaction, stock_value, valuation_rate) are deterministic regardless of test order or leftover state (were order-coupled before). - Backdate prerequisite stock to PREREQUISITE_DATE so balances are positive and independent of the wall-clock date. - Capture has_serial_and_batch_bundle (boolean linkage, not the volatile docname) so a dropped serial/batch bundle link is caught. - Add pr_batch_item and pr_serial_item scenarios to exercise SerialBatchBundleService (the largest extraction, previously uncovered). Goldens regenerated. Verified deterministic across repeated assert runs. --- erpnext/stock/ledger_snapshot.py | 4 + erpnext/stock/ledger_snapshots/dn_basic.json | 5 +- erpnext/stock/ledger_snapshots/dn_return.json | 5 +- erpnext/stock/ledger_snapshots/pr_basic.json | 5 +- .../stock/ledger_snapshots/pr_batch_item.json | 47 +++++++++++ erpnext/stock/ledger_snapshots/pr_return.json | 1 + .../ledger_snapshots/pr_serial_item.json | 47 +++++++++++ .../stock/ledger_snapshots/pr_with_taxes.json | 1 + .../ledger_snapshots/se_material_issue.json | 17 ++-- .../ledger_snapshots/se_material_receipt.json | 1 + .../se_material_transfer.json | 6 +- erpnext/stock/ledger_snapshots/sr_basic.json | 1 + erpnext/stock/test_ledger_characterization.py | 78 ++++++++++++++++++- 13 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 erpnext/stock/ledger_snapshots/pr_batch_item.json create mode 100644 erpnext/stock/ledger_snapshots/pr_serial_item.json diff --git a/erpnext/stock/ledger_snapshot.py b/erpnext/stock/ledger_snapshot.py index 977ede74ed0..a50d245670d 100644 --- a/erpnext/stock/ledger_snapshot.py +++ b/erpnext/stock/ledger_snapshot.py @@ -114,6 +114,7 @@ class SLSnapshot: sle.valuation_rate, sle.stock_value, sle.stock_value_difference, + sle.serial_and_batch_bundle, sle.posting_date, ) .where( @@ -136,6 +137,9 @@ class SLSnapshot: "valuation_rate": flt(row.valuation_rate, RATE_PRECISION), "stock_value": flt(row.stock_value, RATE_PRECISION), "stock_value_difference": flt(row.stock_value_difference, RATE_PRECISION), + # Linkage presence, not the volatile bundle docname — catches a dropped + # serial/batch bundle link without coupling the golden to generated names. + "has_serial_and_batch_bundle": bool(row.serial_and_batch_bundle), "posting_date": str(row.posting_date), } diff --git a/erpnext/stock/ledger_snapshots/dn_basic.json b/erpnext/stock/ledger_snapshots/dn_basic.json index ec5d8c1c1ee..0c405a4772d 100644 --- a/erpnext/stock/ledger_snapshots/dn_basic.json +++ b/erpnext/stock/ledger_snapshots/dn_basic.json @@ -32,12 +32,13 @@ "sle": [ { "actual_qty": -5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 0.0, "item_code": "_Test Item", "posting_date": "2024-01-15", - "qty_after_transaction": -5.0, + "qty_after_transaction": 5.0, "stock_uom": "_Test UOM", - "stock_value": -500.0, + "stock_value": 500.0, "stock_value_difference": -500.0, "valuation_rate": 100.0, "warehouse": "Stores - TCP1" diff --git a/erpnext/stock/ledger_snapshots/dn_return.json b/erpnext/stock/ledger_snapshots/dn_return.json index e5dbe2be296..0144a698310 100644 --- a/erpnext/stock/ledger_snapshots/dn_return.json +++ b/erpnext/stock/ledger_snapshots/dn_return.json @@ -32,12 +32,13 @@ "sle": [ { "actual_qty": 5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 100.0, "item_code": "_Test Item", "posting_date": "2024-01-15", - "qty_after_transaction": -5.0, + "qty_after_transaction": 10.0, "stock_uom": "_Test UOM", - "stock_value": -500.0, + "stock_value": 1000.0, "stock_value_difference": 500.0, "valuation_rate": 100.0, "warehouse": "Stores - TCP1" diff --git a/erpnext/stock/ledger_snapshots/pr_basic.json b/erpnext/stock/ledger_snapshots/pr_basic.json index 8cbc6763694..62e422fb272 100644 --- a/erpnext/stock/ledger_snapshots/pr_basic.json +++ b/erpnext/stock/ledger_snapshots/pr_basic.json @@ -32,12 +32,13 @@ "sle": [ { "actual_qty": 5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 100.0, "item_code": "_Test Item", "posting_date": "2024-01-15", - "qty_after_transaction": 0.0, + "qty_after_transaction": 5.0, "stock_uom": "_Test UOM", - "stock_value": 0.0, + "stock_value": 500.0, "stock_value_difference": 500.0, "valuation_rate": 100.0, "warehouse": "Stores - TCP1" diff --git a/erpnext/stock/ledger_snapshots/pr_batch_item.json b/erpnext/stock/ledger_snapshots/pr_batch_item.json new file mode 100644 index 00000000000..76c4bf81517 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/pr_batch_item.json @@ -0,0 +1,47 @@ +{ + "gl": [ + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Received But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1000.0, + "debit_in_account_currency": 1000.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock Received But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 1000.0, + "credit_in_account_currency": 1000.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 10.0, + "has_serial_and_batch_bundle": true, + "incoming_rate": 100.0, + "item_code": "_Test Characterization Batch Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 10.0, + "stock_uom": "Nos", + "stock_value": 1000.0, + "stock_value_difference": 1000.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/pr_return.json b/erpnext/stock/ledger_snapshots/pr_return.json index 830593473f1..9dc339da1a3 100644 --- a/erpnext/stock/ledger_snapshots/pr_return.json +++ b/erpnext/stock/ledger_snapshots/pr_return.json @@ -32,6 +32,7 @@ "sle": [ { "actual_qty": -5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 0.0, "item_code": "_Test Item", "posting_date": "2024-01-15", diff --git a/erpnext/stock/ledger_snapshots/pr_serial_item.json b/erpnext/stock/ledger_snapshots/pr_serial_item.json new file mode 100644 index 00000000000..b581bff8d00 --- /dev/null +++ b/erpnext/stock/ledger_snapshots/pr_serial_item.json @@ -0,0 +1,47 @@ +{ + "gl": [ + { + "account": "Stock In Hand - TCP1", + "account_currency": "INR", + "against": "Stock Received But Not Billed - TCP1", + "cost_center": "Main - TCP1", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Stock Received But Not Billed - TCP1", + "account_currency": "INR", + "against": "Stock In Hand - TCP1", + "cost_center": "Main - TCP1", + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } + ], + "sle": [ + { + "actual_qty": 5.0, + "has_serial_and_batch_bundle": true, + "incoming_rate": 100.0, + "item_code": "_Test Characterization Serial Item", + "posting_date": "2024-01-15", + "qty_after_transaction": 5.0, + "stock_uom": "Nos", + "stock_value": 500.0, + "stock_value_difference": 500.0, + "valuation_rate": 100.0, + "warehouse": "Stores - TCP1" + } + ] +} diff --git a/erpnext/stock/ledger_snapshots/pr_with_taxes.json b/erpnext/stock/ledger_snapshots/pr_with_taxes.json index 71e51a92f09..dc6a71b6ed7 100644 --- a/erpnext/stock/ledger_snapshots/pr_with_taxes.json +++ b/erpnext/stock/ledger_snapshots/pr_with_taxes.json @@ -60,6 +60,7 @@ "sle": [ { "actual_qty": 5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 150.0, "item_code": "_Test Item", "posting_date": "2024-01-15", diff --git a/erpnext/stock/ledger_snapshots/se_material_issue.json b/erpnext/stock/ledger_snapshots/se_material_issue.json index 0f8298b4c91..87f1be7efff 100644 --- a/erpnext/stock/ledger_snapshots/se_material_issue.json +++ b/erpnext/stock/ledger_snapshots/se_material_issue.json @@ -7,8 +7,8 @@ "cost_center": "Main - TCP1", "credit": 0.0, "credit_in_account_currency": 0.0, - "debit": 750.0, - "debit_in_account_currency": 750.0, + "debit": 500.0, + "debit_in_account_currency": 500.0, "is_opening": "No", "party": null, "party_type": null, @@ -19,8 +19,8 @@ "account_currency": "INR", "against": "Stock Adjustment - TCP1", "cost_center": "Main - TCP1", - "credit": 750.0, - "credit_in_account_currency": 750.0, + "credit": 500.0, + "credit_in_account_currency": 500.0, "debit": 0.0, "debit_in_account_currency": 0.0, "is_opening": "No", @@ -32,14 +32,15 @@ "sle": [ { "actual_qty": -5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 0.0, "item_code": "_Test Item", "posting_date": "2024-01-15", - "qty_after_transaction": 0.0, + "qty_after_transaction": 5.0, "stock_uom": "_Test UOM", - "stock_value": 0.0, - "stock_value_difference": -750.0, - "valuation_rate": 150.0, + "stock_value": 500.0, + "stock_value_difference": -500.0, + "valuation_rate": 100.0, "warehouse": "Stores - TCP1" } ] diff --git a/erpnext/stock/ledger_snapshots/se_material_receipt.json b/erpnext/stock/ledger_snapshots/se_material_receipt.json index 7697282b7f9..47c8aa744ce 100644 --- a/erpnext/stock/ledger_snapshots/se_material_receipt.json +++ b/erpnext/stock/ledger_snapshots/se_material_receipt.json @@ -32,6 +32,7 @@ "sle": [ { "actual_qty": 5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 100.0, "item_code": "_Test Item", "posting_date": "2024-01-15", diff --git a/erpnext/stock/ledger_snapshots/se_material_transfer.json b/erpnext/stock/ledger_snapshots/se_material_transfer.json index 08b5429f3d5..d9d76458d3f 100644 --- a/erpnext/stock/ledger_snapshots/se_material_transfer.json +++ b/erpnext/stock/ledger_snapshots/se_material_transfer.json @@ -3,18 +3,20 @@ "sle": [ { "actual_qty": -5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 0.0, "item_code": "_Test Item", "posting_date": "2024-01-15", - "qty_after_transaction": 0.0, + "qty_after_transaction": 5.0, "stock_uom": "_Test UOM", - "stock_value": 0.0, + "stock_value": 500.0, "stock_value_difference": -500.0, "valuation_rate": 100.0, "warehouse": "Stores - TCP1" }, { "actual_qty": 5.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 100.0, "item_code": "_Test Item", "posting_date": "2024-01-15", diff --git a/erpnext/stock/ledger_snapshots/sr_basic.json b/erpnext/stock/ledger_snapshots/sr_basic.json index f8a92672ebf..1203530416d 100644 --- a/erpnext/stock/ledger_snapshots/sr_basic.json +++ b/erpnext/stock/ledger_snapshots/sr_basic.json @@ -32,6 +32,7 @@ "sle": [ { "actual_qty": 0.0, + "has_serial_and_batch_bundle": false, "incoming_rate": 0.0, "item_code": "_Test Item", "posting_date": "2024-01-15", diff --git a/erpnext/stock/test_ledger_characterization.py b/erpnext/stock/test_ledger_characterization.py index d9fdd0fe7f0..9a3cbd69b20 100644 --- a/erpnext/stock/test_ledger_characterization.py +++ b/erpnext/stock/test_ledger_characterization.py @@ -6,6 +6,13 @@ a stored snapshot (see ``erpnext/stock/ledger_snapshots``). They assert nothing about *correct* accounting or valuation — only that ledger output stays byte-identical as ``stock_controller`` is split into services. +Determinism: each test is wrapped in a savepoint that is rolled back in tearDown, +so the cumulative Stock Ledger fields (qty_after_transaction, stock_value, +valuation_rate) do not depend on test execution order or on state left by other +tests. Prerequisite stock is posted on PREREQUISITE_DATE (before POSTING_DATE) so +balances are positive and independent of the wall-clock date. Run the module in +isolation (``--module ...``) as below. + Regenerate goldens after an intentional change:: REGEN_LEDGER_SNAPSHOTS=1 bench run-tests --site test-erpnext-v17 \\ @@ -15,26 +22,38 @@ Regenerate goldens after an intentional change:: import frappe from frappe.tests import IntegrationTestCase +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.ledger_snapshot import assert_ledger_snapshot POSTING_DATE = "2024-01-15" +PREREQUISITE_DATE = "2024-01-10" CUSTOMER = "_Test Customer" COMPANY = "_Test Company with perpetual inventory" WAREHOUSE = "Stores - TCP1" class TestLedgerCharacterization(IntegrationTestCase): + def setUp(self): + frappe.db.savepoint("ledger_characterization") + + def tearDown(self): + frappe.db.rollback(save_point="ledger_characterization") + def test_dn_basic(self): - make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100) + make_stock_entry( + item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, posting_date=PREREQUISITE_DATE + ) dn = _make_dated_delivery_note(qty=5, rate=150) dn.insert() dn.submit() assert_ledger_snapshot(self, "dn_basic", "Delivery Note", dn.name) def test_dn_return(self): - make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100) + make_stock_entry( + item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, posting_date=PREREQUISITE_DATE + ) original = _make_dated_delivery_note(qty=5, rate=150) original.insert() original.submit() @@ -64,7 +83,14 @@ class TestLedgerCharacterization(IntegrationTestCase): assert_ledger_snapshot(self, "se_material_receipt", "Stock Entry", se.name) def test_se_material_issue(self): - make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, company=COMPANY) + make_stock_entry( + item_code="_Test Item", + target=WAREHOUSE, + qty=10, + basic_rate=100, + company=COMPANY, + posting_date=PREREQUISITE_DATE, + ) se = make_stock_entry( item_code="_Test Item", source=WAREHOUSE, @@ -77,7 +103,14 @@ class TestLedgerCharacterization(IntegrationTestCase): assert_ledger_snapshot(self, "se_material_issue", "Stock Entry", se.name) def test_se_material_transfer(self): - make_stock_entry(item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, company=COMPANY) + make_stock_entry( + item_code="_Test Item", + target=WAREHOUSE, + qty=10, + basic_rate=100, + company=COMPANY, + posting_date=PREREQUISITE_DATE, + ) se = make_stock_entry( item_code="_Test Item", source=WAREHOUSE, @@ -126,6 +159,43 @@ class TestLedgerCharacterization(IntegrationTestCase): ret.submit() assert_ledger_snapshot(self, "pr_return", "Purchase Receipt", ret.name) + def test_pr_batch_item(self): + """Exercises SerialBatchBundleService bundle creation + SLE bundle linkage.""" + item_code = make_item( + "_Test Characterization Batch Item", + { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "CHAR-BATCH-.#####", + "is_stock_item": 1, + }, + ).name + pr = make_purchase_receipt( + item_code=item_code, + company=COMPANY, + warehouse=WAREHOUSE, + posting_date=POSTING_DATE, + qty=10, + rate=100, + ) + assert_ledger_snapshot(self, "pr_batch_item", "Purchase Receipt", pr.name) + + def test_pr_serial_item(self): + """Exercises SerialBatchBundleService for serialized items + SLE bundle linkage.""" + item_code = make_item( + "_Test Characterization Serial Item", + {"has_serial_no": 1, "serial_no_series": "CHAR-SER-.#####", "is_stock_item": 1}, + ).name + pr = make_purchase_receipt( + item_code=item_code, + company=COMPANY, + warehouse=WAREHOUSE, + posting_date=POSTING_DATE, + qty=5, + rate=100, + ) + assert_ledger_snapshot(self, "pr_serial_item", "Purchase Receipt", pr.name) + def _make_dated_delivery_note(**args) -> frappe.Document: """Minimal Delivery Note on a fixed posting date using the perpetual-inventory From f1f66bdf2faa7ad72f715990513cca1ea9c0f64b Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 12:12:36 +0530 Subject: [PATCH 11/21] perf(stock): cache is_serial_batch_item via Item document cache The @frappe.request_cache decorator keyed on `self`, which after the service extraction is a transient SerialBatchBundleService built per delegated call, so the request-wide dedup was lost and dead instances were pinned in request_cache. Use frappe.get_cached_value on the Item instead: caching is keyed by the item (request- local + redis), effective regardless of service-instance churn, and the redundant frappe.db.exists query is dropped. Verified: ledger snapshots + serial and batch bundle suite stay green. --- erpnext/stock/services/serial_batch_bundle.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/services/serial_batch_bundle.py b/erpnext/stock/services/serial_batch_bundle.py index cf98a0aea5c..d628b404aa2 100644 --- a/erpnext/stock/services/serial_batch_bundle.py +++ b/erpnext/stock/services/serial_batch_bundle.py @@ -376,17 +376,14 @@ class SerialBatchBundleService: return field, reference_ids - @frappe.request_cache def is_serial_batch_item(self, item_code) -> bool: - if not frappe.db.exists("Item", item_code): + item_details = frappe.get_cached_value( + "Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=True + ) + if not item_details: frappe.throw(_("Item {0} does not exist.").format(bold(item_code))) - item_details = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) - - if item_details.has_serial_no or item_details.has_batch_no: - return True - - return False + return bool(item_details.has_serial_no or item_details.has_batch_no) def update_bundle_details(self, bundle_details, table_name, row, is_rejected=False, parent_details=None): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos From f4705fd5a85d08c8389e0612e6b62eecca0cc4cf Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 12:17:54 +0530 Subject: [PATCH 12/21] refactor(stock): remove dead get_serialized_items method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_serialized_items had zero callers anywhere (Python, JS, run_method) on develop and after the refactor; it was relocated into SerialBatchBundleService by mistake instead of being dropped. Delete it — also removes a raw frappe.db.sql_list query that duplicated the ORM helper get_serial_or_batch_items. --- erpnext/stock/services/serial_batch_bundle.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/erpnext/stock/services/serial_batch_bundle.py b/erpnext/stock/services/serial_batch_bundle.py index d628b404aa2..ce8ca4c92f5 100644 --- a/erpnext/stock/services/serial_batch_bundle.py +++ b/erpnext/stock/services/serial_batch_bundle.py @@ -678,15 +678,3 @@ class SerialBatchBundleService: ) .where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches))) ).run(as_dict=True) - - def get_serialized_items(self): - serialized_items = [] - item_codes = list(set(d.item_code for d in self.doc.get("items"))) - if item_codes: - serialized_items = frappe.db.sql_list( - """select name from `tabItem` - where has_serial_no=1 and name in ({})""".format(", ".join(["%s"] * len(item_codes))), - tuple(item_codes), - ) - - return serialized_items From 3dba21f814b94c1205878940721ac832ae91fb5a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 12:19:52 +0530 Subject: [PATCH 13/21] refactor(stock): pass inter_company_reference as an argument validate_internal_transfer_qty stashed the value on a name-mangled instance attribute (self.__inter_company_reference) that get_item_wise_inter_transfer_qty read back, creating an implicit call-ordering contract: calling the latter on a fresh service without the former first raised AttributeError. Compute it as a local and pass it as a method argument, removing the hidden cross-method state. Verified: ledger snapshots + PR internal-transfer suite stay green. --- erpnext/stock/services/internal_transfer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/services/internal_transfer.py b/erpnext/stock/services/internal_transfer.py index 6a2fbc4b857..62fec7ff95c 100644 --- a/erpnext/stock/services/internal_transfer.py +++ b/erpnext/stock/services/internal_transfer.py @@ -72,13 +72,13 @@ class StockInternalTransferService: if self.doc.doctype not in ["Purchase Invoice", "Purchase Receipt"]: return - self.__inter_company_reference = ( + inter_company_reference = ( self.doc.get("inter_company_reference") if self.doc.doctype == "Purchase Invoice" else self.doc.get("inter_company_invoice_reference") ) - item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty() + item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty(inter_company_reference) if not item_wise_transfer_qty: return @@ -105,11 +105,11 @@ class StockInternalTransferService: bold(key[1]), bold(flt(transferred_qty, precision)), bold(parent_doctype), - get_link_to_form(parent_doctype, self.__inter_company_reference), + get_link_to_form(parent_doctype, inter_company_reference), ) ) - def get_item_wise_inter_transfer_qty(self): + def get_item_wise_inter_transfer_qty(self, inter_company_reference): parent_doctype = { "Purchase Receipt": "Delivery Note", "Purchase Invoice": "Sales Invoice", @@ -129,7 +129,7 @@ class StockInternalTransferService: child_tab.item_code, child_tab.qty, ) - .where((parent_tab.name == self.__inter_company_reference) & (parent_tab.docstatus == 1)) + .where((parent_tab.name == inter_company_reference) & (parent_tab.docstatus == 1)) ) data = query.run(as_dict=True) From 78d5fbaca4b94355ae5f514b194ae6d13ff08c77 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 12:29:55 +0530 Subject: [PATCH 14/21] refactor(stock): address layering/robustness review findings (#8, #9, #10) #8 ledger_preview: wrap the submit-in-memory dry run in a savepoint inside get_accounting_ledger_preview / get_stock_ledger_preview and roll back to it in a finally, so the preview never persists entries regardless of caller (previously only the whitelisted show_*_preview wrappers' full rollback made it safe). #9 exceptions: move BatchExpiredError and the QualityInspection* errors into a new erpnext/stock/exceptions.py and re-export them from stock_controller for backward compatibility (job_card and tests still import from the controller; identity is preserved). Services now import from the neutral module instead of back from the controller they were extracted out of. #10 quality inspection: extract the duplicated doctype->inspection-field map into a single INSPECTION_FIELDNAME_MAP constant in the service, consumed by both validate_inspection and check_item_quality_inspection. Verified: ledger snapshots, quality inspection suite, stock_entry batch-expiry test stay green; preview smoke-tested to persist nothing and not roll back the caller. --- erpnext/controllers/stock_controller.py | 36 +++++----------- erpnext/stock/exceptions.py | 27 ++++++++++++ erpnext/stock/services/ledger_preview.py | 42 ++++++++++++------- erpnext/stock/services/quality_inspection.py | 31 +++++++------- erpnext/stock/services/serial_batch_bundle.py | 2 +- 5 files changed, 83 insertions(+), 55 deletions(-) create mode 100644 erpnext/stock/exceptions.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 67862520a83..857ce76eb84 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -23,25 +23,17 @@ from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.item.item import get_item_defaults + +# Re-exported for backward compatibility; canonical home is erpnext.stock.exceptions. +from erpnext.stock.exceptions import ( + BatchExpiredError, + QualityInspectionNotSubmittedError, + QualityInspectionRejectedError, + QualityInspectionRequiredError, +) from erpnext.stock.stock_ledger import get_items_to_be_repost -class QualityInspectionRequiredError(frappe.ValidationError): - pass - - -class QualityInspectionRejectedError(frappe.ValidationError): - pass - - -class QualityInspectionNotSubmittedError(frappe.ValidationError): - pass - - -class BatchExpiredError(frappe.ValidationError): - pass - - class StockController(AccountsController): def validate(self): super().validate() @@ -674,18 +666,12 @@ def repost_required_for_queue(doc: StockController) -> bool: @frappe.whitelist() def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]): + from erpnext.stock.services.quality_inspection import INSPECTION_FIELDNAME_MAP + if isinstance(items, str): items = json.loads(items) - inspection_fieldname_map = { - "Purchase Receipt": "inspection_required_before_purchase", - "Purchase Invoice": "inspection_required_before_purchase", - "Subcontracting Receipt": "inspection_required_before_purchase", - "Sales Invoice": "inspection_required_before_delivery", - "Delivery Note": "inspection_required_before_delivery", - } - - inspection_fieldname = inspection_fieldname_map.get(doctype) + inspection_fieldname = INSPECTION_FIELDNAME_MAP.get(doctype) if inspection_fieldname is None: return [] diff --git a/erpnext/stock/exceptions.py b/erpnext/stock/exceptions.py new file mode 100644 index 00000000000..5ec9c6f1b28 --- /dev/null +++ b/erpnext/stock/exceptions.py @@ -0,0 +1,27 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Shared exceptions for stock transactions. + +Raised by the stock services (serial/batch bundle, quality inspection) and +re-exported from ``stock_controller`` for backward compatibility, so the services +do not have to import back from the controller they were extracted out of. +""" + +import frappe + + +class QualityInspectionRequiredError(frappe.ValidationError): + pass + + +class QualityInspectionRejectedError(frappe.ValidationError): + pass + + +class QualityInspectionNotSubmittedError(frappe.ValidationError): + pass + + +class BatchExpiredError(frappe.ValidationError): + pass diff --git a/erpnext/stock/services/ledger_preview.py b/erpnext/stock/services/ledger_preview.py index 20c533c8ddd..cdb8209da2f 100644 --- a/erpnext/stock/services/ledger_preview.py +++ b/erpnext/stock/services/ledger_preview.py @@ -30,17 +30,23 @@ def get_accounting_ledger_preview(doc, filters): "against_voucher", ] - doc.docstatus = 1 + # Dry run: submit in memory to materialise GL entries, read them, then roll back + # to the savepoint so the preview never persists anything, regardless of caller. + frappe.db.savepoint("ledger_preview") + try: + doc.docstatus = 1 - if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): - doc.update_stock_ledger() + if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): + doc.update_stock_ledger() - doc.make_gl_entries() - columns = get_gl_columns(filters) - gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields) + doc.make_gl_entries() + columns = get_gl_columns(filters) + gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields) - gl_columns = get_columns(columns, fields) - gl_data = get_data(fields, gl_entries) + gl_columns = get_columns(columns, fields) + gl_data = get_data(fields, gl_entries) + finally: + frappe.db.rollback(save_point="ledger_preview") return gl_columns, gl_data @@ -74,15 +80,21 @@ def get_stock_ledger_preview(doc, filters): ] if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): - doc.docstatus = 1 - doc.make_bundle_using_old_serial_batch_fields() - doc.update_stock_ledger() + # Dry run: submit in memory to materialise SLEs, read them, then roll back to + # the savepoint so the preview never persists anything, regardless of caller. + frappe.db.savepoint("ledger_preview") + try: + doc.docstatus = 1 + doc.make_bundle_using_old_serial_batch_fields() + doc.update_stock_ledger() - columns = get_sl_columns(filters) - sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields) + columns = get_sl_columns(filters) + sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields) - sl_columns = get_columns(columns, columns_fields) - sl_data = get_data(columns_fields, sl_entries) + sl_columns = get_columns(columns, columns_fields) + sl_data = get_data(columns_fields, sl_entries) + finally: + frappe.db.rollback(save_point="ledger_preview") return sl_columns, sl_data diff --git a/erpnext/stock/services/quality_inspection.py b/erpnext/stock/services/quality_inspection.py index 28fa8320dce..d9441bc6826 100644 --- a/erpnext/stock/services/quality_inspection.py +++ b/erpnext/stock/services/quality_inspection.py @@ -10,6 +10,22 @@ inspection have a present / submitted / non-rejected Quality Inspection. import frappe from frappe import _ +from erpnext.stock.exceptions import ( + QualityInspectionNotSubmittedError, + QualityInspectionRejectedError, + QualityInspectionRequiredError, +) + +# Doctype -> the document-level "inspection required" flag. Shared with +# check_item_quality_inspection in stock_controller so the two stay in sync. +INSPECTION_FIELDNAME_MAP = { + "Purchase Receipt": "inspection_required_before_purchase", + "Purchase Invoice": "inspection_required_before_purchase", + "Subcontracting Receipt": "inspection_required_before_purchase", + "Sales Invoice": "inspection_required_before_delivery", + "Delivery Note": "inspection_required_before_delivery", +} + class QualityInspectionService: def __init__(self, doc) -> None: @@ -17,14 +33,7 @@ class QualityInspectionService: def validate_inspection(self): """Checks if quality inspection is set/ is valid for Items that require inspection.""" - inspection_fieldname_map = { - "Purchase Receipt": "inspection_required_before_purchase", - "Purchase Invoice": "inspection_required_before_purchase", - "Subcontracting Receipt": "inspection_required_before_purchase", - "Sales Invoice": "inspection_required_before_delivery", - "Delivery Note": "inspection_required_before_delivery", - } - inspection_required_fieldname = inspection_fieldname_map.get(self.doc.doctype) + inspection_required_fieldname = INSPECTION_FIELDNAME_MAP.get(self.doc.doctype) # return if inspection is not required on document level if ( @@ -64,8 +73,6 @@ class QualityInspectionService: def validate_qi_presence(self, row): """Check if QI is present on row level. Warn on save and stop on submit if missing.""" - from erpnext.controllers.stock_controller import QualityInspectionRequiredError - if not row.quality_inspection: msg = _("Row #{0}: Quality Inspection is required for Item {1}").format( row.idx, frappe.bold(row.item_code) @@ -77,8 +84,6 @@ class QualityInspectionService: def validate_qi_submission(self, row): """Check if QI is submitted on row level, during submission""" - from erpnext.controllers.stock_controller import QualityInspectionNotSubmittedError - action = frappe.get_single_value("Stock Settings", "action_if_quality_inspection_is_not_submitted") qa_docstatus = frappe.db.get_value("Quality Inspection", row.quality_inspection, "docstatus") @@ -94,8 +99,6 @@ class QualityInspectionService: def validate_qi_rejection(self, row): """Check if QI is rejected on row level, during submission""" - from erpnext.controllers.stock_controller import QualityInspectionRejectedError - action = frappe.get_single_value("Stock Settings", "action_if_quality_inspection_is_rejected") qa_status = frappe.db.get_value("Quality Inspection", row.quality_inspection, "status") diff --git a/erpnext/stock/services/serial_batch_bundle.py b/erpnext/stock/services/serial_batch_bundle.py index ce8ca4c92f5..dce7be2beb3 100644 --- a/erpnext/stock/services/serial_batch_bundle.py +++ b/erpnext/stock/services/serial_batch_bundle.py @@ -102,8 +102,8 @@ class SerialBatchBundleService: ) def validate_serialized_batch(self): - from erpnext.controllers.stock_controller import BatchExpiredError from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + from erpnext.stock.exceptions import BatchExpiredError is_material_issue = False if self.doc.doctype == "Stock Entry" and self.doc.purpose in ["Material Issue", "Material Transfer"]: From 6a064765d15c7b857ac88c6fee25dd55a457102a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 12:46:09 +0530 Subject: [PATCH 15/21] refactor(stock): drop zero-caller StockController delegators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-audited the kept delegators for true external callers. Two had none: - has_landed_cost_amount: no caller anywhere (the landed_cost_voucher.py free function is what the composers use) — pure dead delegator, removed. - validate_internal_transfer: only StockController.validate() called it; inline that one hook to StockInternalTransferService(self).validate_internal_transfer() and remove the delegator. All other kept delegators have real external/subclass/run_method callers and remain as the stock extension contract. Verified: ledger snapshots + DN/PR internal-transfer suites stay green. --- erpnext/controllers/stock_controller.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 857ce76eb84..20ed45a2b50 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -31,6 +31,7 @@ from erpnext.stock.exceptions import ( QualityInspectionRejectedError, QualityInspectionRequiredError, ) +from erpnext.stock.services.internal_transfer import StockInternalTransferService from erpnext.stock.stock_ledger import get_items_to_be_repost @@ -50,7 +51,7 @@ class StockController(AccountsController): self.clean_serial_nos() self.validate_customer_provided_item() self.set_rate_of_stock_uom() - self.validate_internal_transfer() + StockInternalTransferService(self).validate_internal_transfer() self.validate_putaway_capacity() self.reset_conversion_factor() @@ -283,11 +284,6 @@ class StockController(AccountsController): return set_landed_cost_voucher_amount(self) - def has_landed_cost_amount(self): - from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import has_landed_cost_amount - - return has_landed_cost_amount(self) - def get_item_account_wise_lcv_entries(self): from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( get_item_account_wise_lcv_entries, @@ -390,11 +386,6 @@ class StockController(AccountsController): for d in self.get("items"): d.stock_uom_rate = d.rate / (d.conversion_factor or 1) - def validate_internal_transfer(self): - from erpnext.stock.services.internal_transfer import StockInternalTransferService - - return StockInternalTransferService(self).validate_internal_transfer() - def validate_putaway_capacity(self): from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity From 8db05fc4da735f5901248c51ec0bdbc142e8bc92 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 13:32:04 +0530 Subject: [PATCH 16/21] refactor(stock): drop 7 in-repo-only StockController delegators Remove the delegators whose only callers were in-repo StockController subclasses, repointing every caller to the owning service / free function: - validate_warehouse_of_sabb, validate_duplicate_serial_and_batch_bundle, validate_serialized_batch, clean_serial_nos -> SerialBatchBundleService - update_inventory_dimensions -> StockLedgerService - validate_putaway_capacity -> putaway_rule.validate_putaway_capacity (free fn) - set_landed_cost_voucher_amount -> landed_cost_voucher.set_landed_cost_voucher_amount Callers repointed: StockController.validate() (base), StockEntry.validate(), StockReconciliation (validate + reconciliation SLE build), BuyingController.validate(), and the Landed Cost Voucher submit (doc.set_landed_cost_voucher_amount on the receipt). Verified green: ledger snapshots, stock_entry (91), stock_reconciliation (34), landed_cost_voucher (15), subcontracting_receipt (32), delivery_note (71). --- erpnext/controllers/buying_controller.py | 6 ++- erpnext/controllers/stock_controller.py | 52 ++++--------------- .../landed_cost_voucher.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 15 ++++-- .../stock_reconciliation.py | 15 ++++-- 5 files changed, 37 insertions(+), 53 deletions(-) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 1fac4f8b216..1b5574b764f 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -35,6 +35,10 @@ class BuyingController(SubcontractingController): self.flags.ignore_permlevel_for_fields = ["buying_price_list", "price_list_currency"] def validate(self): + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + set_landed_cost_voucher_amount, + ) + self.set_rate_for_standalone_debit_note() super().validate() @@ -59,7 +63,7 @@ class BuyingController(SubcontractingController): self.validate_rejected_warehouse() self.validate_accepted_rejected_qty() validate_for_items(self) - self.set_landed_cost_voucher_amount() + set_landed_cost_voucher_amount(self) if self.doctype in ("Purchase Receipt", "Purchase Invoice"): self.update_valuation_rate() diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 20ed45a2b50..427d5a104e5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -37,33 +37,33 @@ from erpnext.stock.stock_ledger import get_items_to_be_repost class StockController(AccountsController): def validate(self): + from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + + sbb = SerialBatchBundleService(self) + super().validate() if self.docstatus == 0: for table_name in ["items", "packed_items", "supplied_items"]: - self.validate_duplicate_serial_and_batch_bundle(table_name) + sbb.validate_duplicate_serial_and_batch_bundle(table_name) if not self.get("is_return"): self.validate_inspection() - self.validate_warehouse_of_sabb() - self.validate_serialized_batch() - self.clean_serial_nos() + sbb.validate_warehouse_of_sabb() + sbb.validate_serialized_batch() + sbb.clean_serial_nos() self.validate_customer_provided_item() self.set_rate_of_stock_uom() StockInternalTransferService(self).validate_internal_transfer() - self.validate_putaway_capacity() + validate_putaway_capacity(self) self.reset_conversion_factor() def on_update(self): super().on_update() self.check_zero_rate() - def validate_warehouse_of_sabb(self): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - - return SerialBatchBundleService(self).validate_warehouse_of_sabb() - def reset_conversion_factor(self): for row in self.get("items"): if row.uom != row.stock_uom: @@ -118,11 +118,6 @@ class StockController(AccountsController): if non_exists_items: frappe.throw(_("Items {0} do not exist in the Item master.").format(", ".join(non_exists_items))) - def validate_duplicate_serial_and_batch_bundle(self, table_name): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - - return SerialBatchBundleService(self).validate_duplicate_serial_and_batch_bundle(table_name) - def get_item_wise_inventory_account_map(self): inventory_account_map = frappe._dict() for table in ["items", "packed_items", "supplied_items"]: @@ -207,16 +202,6 @@ class StockController(AccountsController): ) make_gl_entries(gl_entries, from_repost=from_repost) - def validate_serialized_batch(self): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - - return SerialBatchBundleService(self).validate_serialized_batch() - - def clean_serial_nos(self): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService - - return SerialBatchBundleService(self).clean_serial_nos() - def make_bundle_using_old_serial_batch_fields(self, table_name=None, via_landed_cost_voucher=False): from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService @@ -277,13 +262,6 @@ class StockController(AccountsController): return StockLedgerService(self).get_sl_entries(d, args) - def set_landed_cost_voucher_amount(self): - from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( - set_landed_cost_voucher_amount, - ) - - return set_landed_cost_voucher_amount(self) - def get_item_account_wise_lcv_entries(self): from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( get_item_account_wise_lcv_entries, @@ -291,11 +269,6 @@ class StockController(AccountsController): return get_item_account_wise_lcv_entries(self) - def update_inventory_dimensions(self, row, sl_dict) -> None: - from erpnext.stock.services.stock_ledger import StockLedgerService - - return StockLedgerService(self).update_inventory_dimensions(row, sl_dict) - def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): from erpnext.stock.services.stock_ledger import StockLedgerService @@ -386,11 +359,6 @@ class StockController(AccountsController): for d in self.get("items"): d.stock_uom_rate = d.rate / (d.conversion_factor or 1) - def validate_putaway_capacity(self): - from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity - - return validate_putaway_capacity(self) - def repost_future_sle_and_gle(self, force=False, via_landed_cost_voucher=False): from erpnext.stock.services.stock_ledger import StockLedgerService diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index dc5384f76ee..6576380e862 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -315,7 +315,7 @@ class LandedCostVoucher(Document): self.validate_asset_qty_and_status(d.receipt_document_type, doc) # set landed cost voucher amount in pr item - doc.set_landed_cost_voucher_amount() + set_landed_cost_voucher_amount(doc) if d.receipt_document_type == "Subcontracting Receipt": doc.calculate_items_qty_and_amount() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 1574efa67e9..41284504f7c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -265,17 +265,22 @@ class StockEntry(StockController, SubcontractingInwardController): ) def validate(self): + from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + + sbb = SerialBatchBundleService(self) + if self.purpose_cls: self.purpose_cls(self).validate() - self.validate_duplicate_serial_and_batch_bundle("items") + sbb.validate_duplicate_serial_and_batch_bundle("items") self.validate_posting_time() self.validate_item() self.validate_customer_provided_item() self.set_transfer_qty() self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "transfer_qty") - self.validate_warehouse_of_sabb() + sbb.validate_warehouse_of_sabb() self.validate_source_stock_entry() self.validate_bom() self.set_process_loss_qty() @@ -294,11 +299,11 @@ class StockEntry(StockController, SubcontractingInwardController): self.validate_difference_account() self.validate_job_card_item() self.set_purpose_for_stock_entry() - self.clean_serial_nos() + sbb.clean_serial_nos() self.remove_fg_completed_qty() - self.validate_serialized_batch() + sbb.validate_serialized_batch() self.calculate_rate_and_amount() - self.validate_putaway_capacity() + validate_putaway_capacity(self) self.validate_closed_subcontracting_order() super().validate_subcontracting_inward() diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 388ab6793b8..5118c9c41ae 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -65,6 +65,11 @@ class StockReconciliation(StockController): self.head_row = ["Item Code", "Warehouse", "Quantity", "Valuation Rate"] def validate(self): + from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity + from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + + sbb = SerialBatchBundleService(self) + self.validate_items_exist() if not self.expense_account: self.expense_account = frappe.get_cached_value( @@ -75,16 +80,16 @@ class StockReconciliation(StockController): self.validate_posting_time() self.set_current_serial_and_batch_bundle() self.set_new_serial_and_batch_bundle() - self.validate_duplicate_serial_and_batch_bundle("items") + sbb.validate_duplicate_serial_and_batch_bundle("items") self.remove_items_with_no_change() self.validate_data() self.change_row_indexes() self.validate_expense_account() self.validate_customer_provided_item() self.set_zero_value_for_customer_provided_items() - self.clean_serial_nos() + sbb.clean_serial_nos() self.set_total_qty_and_amount() - self.validate_putaway_capacity() + validate_putaway_capacity(self) self.validate_inventory_dimension() self.validate_uom_is_integer("stock_uom", "qty") @@ -925,7 +930,9 @@ class StockReconciliation(StockController): data.qty_after_transaction = 0.0 data.incoming_rate = flt(row.valuation_rate) - self.update_inventory_dimensions(row, data) + from erpnext.stock.services.stock_ledger import StockLedgerService + + StockLedgerService(self).update_inventory_dimensions(row, data) return data From b82b2c2ebd4f00b9fffbfa677e0bd27b19f127bb Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 14:34:27 +0530 Subject: [PATCH 17/21] refactor(stock): use central erpnext/exceptions.py for stock exceptions Merge the stock exceptions into the existing app-wide erpnext/exceptions.py (under a '# stock' section) instead of a separate erpnext/stock/exceptions.py, matching the established convention. stock_controller still re-exports them for backward compatibility; services import from erpnext.exceptions. Verified: ledger snapshots, quality inspection suite, stock_entry batch-expiry stay green. --- erpnext/controllers/stock_controller.py | 12 ++++----- erpnext/exceptions.py | 17 ++++++++++++ erpnext/stock/exceptions.py | 27 ------------------- erpnext/stock/services/quality_inspection.py | 2 +- erpnext/stock/services/serial_batch_bundle.py | 2 +- 5 files changed, 25 insertions(+), 35 deletions(-) delete mode 100644 erpnext/stock/exceptions.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 427d5a104e5..77ee04d3390 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -19,18 +19,18 @@ from erpnext.controllers.sales_and_purchase_return import ( filter_serial_batches, make_serial_batch_bundle_for_return, ) -from erpnext.setup.doctype.brand.brand import get_brand_defaults -from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.stock import get_warehouse_account_map -from erpnext.stock.doctype.item.item import get_item_defaults -# Re-exported for backward compatibility; canonical home is erpnext.stock.exceptions. -from erpnext.stock.exceptions import ( +# Re-exported for backward compatibility; canonical home is erpnext.exceptions. +from erpnext.exceptions import ( BatchExpiredError, QualityInspectionNotSubmittedError, QualityInspectionRejectedError, QualityInspectionRequiredError, ) +from erpnext.setup.doctype.brand.brand import get_brand_defaults +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock import get_warehouse_account_map +from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.services.internal_transfer import StockInternalTransferService from erpnext.stock.stock_ledger import get_items_to_be_repost diff --git a/erpnext/exceptions.py b/erpnext/exceptions.py index e12c69757e0..c8d2d790d1b 100644 --- a/erpnext/exceptions.py +++ b/erpnext/exceptions.py @@ -28,3 +28,20 @@ class MandatoryAccountDimensionError(frappe.ValidationError): class ReportingCurrencyExchangeNotFoundError(frappe.ValidationError): pass + + +# stock +class QualityInspectionRequiredError(frappe.ValidationError): + pass + + +class QualityInspectionRejectedError(frappe.ValidationError): + pass + + +class QualityInspectionNotSubmittedError(frappe.ValidationError): + pass + + +class BatchExpiredError(frappe.ValidationError): + pass diff --git a/erpnext/stock/exceptions.py b/erpnext/stock/exceptions.py deleted file mode 100644 index 5ec9c6f1b28..00000000000 --- a/erpnext/stock/exceptions.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Shared exceptions for stock transactions. - -Raised by the stock services (serial/batch bundle, quality inspection) and -re-exported from ``stock_controller`` for backward compatibility, so the services -do not have to import back from the controller they were extracted out of. -""" - -import frappe - - -class QualityInspectionRequiredError(frappe.ValidationError): - pass - - -class QualityInspectionRejectedError(frappe.ValidationError): - pass - - -class QualityInspectionNotSubmittedError(frappe.ValidationError): - pass - - -class BatchExpiredError(frappe.ValidationError): - pass diff --git a/erpnext/stock/services/quality_inspection.py b/erpnext/stock/services/quality_inspection.py index d9441bc6826..7e7fc4ba078 100644 --- a/erpnext/stock/services/quality_inspection.py +++ b/erpnext/stock/services/quality_inspection.py @@ -10,7 +10,7 @@ inspection have a present / submitted / non-rejected Quality Inspection. import frappe from frappe import _ -from erpnext.stock.exceptions import ( +from erpnext.exceptions import ( QualityInspectionNotSubmittedError, QualityInspectionRejectedError, QualityInspectionRequiredError, diff --git a/erpnext/stock/services/serial_batch_bundle.py b/erpnext/stock/services/serial_batch_bundle.py index dce7be2beb3..17b3af32fd7 100644 --- a/erpnext/stock/services/serial_batch_bundle.py +++ b/erpnext/stock/services/serial_batch_bundle.py @@ -102,8 +102,8 @@ class SerialBatchBundleService: ) def validate_serialized_batch(self): + from erpnext.exceptions import BatchExpiredError from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - from erpnext.stock.exceptions import BatchExpiredError is_material_issue = False if self.doc.doctype == "Stock Entry" and self.doc.purpose in ["Material Issue", "Material Transfer"]: From c5ff1009b2af9451843e514ecb1c4d453482ca7d Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 14:41:58 +0530 Subject: [PATCH 18/21] refactor: relocate ledger_preview to controllers (cross-cutting, not stock-only) The preview feature serves both accounts and stock vouchers (SI/PI/PE + DN/PR/SE) and its show_*_preview entry points live in controllers/stock_controller, so the cohesive GL+SLE preview module belongs in controllers/, not stock/services/. Pure move + import-path update; GL and stock previews stay together (shared get_columns/ get_data formatters; read-side, kept out of the write-path services). Verified: ledger snapshots green; module resolves at new path. --- .../services => controllers}/ledger_preview.py | 13 +++++++------ erpnext/controllers/stock_controller.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) rename erpnext/{stock/services => controllers}/ledger_preview.py (87%) diff --git a/erpnext/stock/services/ledger_preview.py b/erpnext/controllers/ledger_preview.py similarity index 87% rename from erpnext/stock/services/ledger_preview.py rename to erpnext/controllers/ledger_preview.py index cdb8209da2f..ce074903aaf 100644 --- a/erpnext/stock/services/ledger_preview.py +++ b/erpnext/controllers/ledger_preview.py @@ -1,13 +1,14 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -"""Read-side GL / Stock Ledger preview helpers for stock transactions. +"""Read-side GL / Stock Ledger preview helpers. -A dry-run consumer of the posting path: it submits-in-memory, reads the resulting -GL/SLE entries and formats them for the datatable preview, then the caller rolls -back. Lives separately from the posting services it orchestrates. The whitelisted -``show_*_preview`` entry points stay on ``stock_controller`` (their dotted path is -referenced from client JS). +A dry-run consumer of the posting path, shared across accounts and stock vouchers +(Sales/Purchase Invoice, Payment Entry, Delivery Note, Purchase Receipt, Stock +Entry): it submits-in-memory, reads the resulting GL/SLE entries and formats them +for the datatable preview, then rolls back. Lives separately from the posting +services it orchestrates. The whitelisted ``show_*_preview`` entry points stay on +``stock_controller`` (their dotted path is referenced from client JS). """ import frappe diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 77ee04d3390..9fd61d6a209 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -563,7 +563,7 @@ class StockController(AccountsController): @frappe.whitelist() def show_accounting_ledger_preview(company: str, doctype: str, docname: str): - from erpnext.stock.services.ledger_preview import get_accounting_ledger_preview + from erpnext.controllers.ledger_preview import get_accounting_ledger_preview filters = frappe._dict(company=company, include_dimensions=1) doc = frappe.get_lazy_doc(doctype, docname) @@ -578,7 +578,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str): @frappe.whitelist() def show_stock_ledger_preview(company: str, doctype: str, docname: str): - from erpnext.stock.services.ledger_preview import get_stock_ledger_preview + from erpnext.controllers.ledger_preview import get_stock_ledger_preview filters = frappe._dict(company=company) doc = frappe.get_lazy_doc(doctype, docname) From 9bb71e5ec406be64bc0d2df1b24e2d67e8858030 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 14:45:09 +0530 Subject: [PATCH 19/21] chore(stock): remove ledger characterization scaffolding Phase 0 golden-master safety net for the stock_controller refactor. It served its purpose (every extraction verified byte-identical GL + Stock Ledger output) and is removed before shipping, mirroring the earlier GL characterization cleanup. --- erpnext/stock/ledger_snapshot.py | 174 ------------ erpnext/stock/ledger_snapshots/dn_basic.json | 47 ---- erpnext/stock/ledger_snapshots/dn_return.json | 47 ---- erpnext/stock/ledger_snapshots/pr_basic.json | 47 ---- .../stock/ledger_snapshots/pr_batch_item.json | 47 ---- erpnext/stock/ledger_snapshots/pr_return.json | 47 ---- .../ledger_snapshots/pr_serial_item.json | 47 ---- .../stock/ledger_snapshots/pr_with_taxes.json | 75 ------ .../ledger_snapshots/se_material_issue.json | 47 ---- .../ledger_snapshots/se_material_receipt.json | 47 ---- .../se_material_transfer.json | 31 --- erpnext/stock/ledger_snapshots/sr_basic.json | 47 ---- erpnext/stock/test_ledger_characterization.py | 248 ------------------ 13 files changed, 951 deletions(-) delete mode 100644 erpnext/stock/ledger_snapshot.py delete mode 100644 erpnext/stock/ledger_snapshots/dn_basic.json delete mode 100644 erpnext/stock/ledger_snapshots/dn_return.json delete mode 100644 erpnext/stock/ledger_snapshots/pr_basic.json delete mode 100644 erpnext/stock/ledger_snapshots/pr_batch_item.json delete mode 100644 erpnext/stock/ledger_snapshots/pr_return.json delete mode 100644 erpnext/stock/ledger_snapshots/pr_serial_item.json delete mode 100644 erpnext/stock/ledger_snapshots/pr_with_taxes.json delete mode 100644 erpnext/stock/ledger_snapshots/se_material_issue.json delete mode 100644 erpnext/stock/ledger_snapshots/se_material_receipt.json delete mode 100644 erpnext/stock/ledger_snapshots/se_material_transfer.json delete mode 100644 erpnext/stock/ledger_snapshots/sr_basic.json delete mode 100644 erpnext/stock/test_ledger_characterization.py diff --git a/erpnext/stock/ledger_snapshot.py b/erpnext/stock/ledger_snapshot.py deleted file mode 100644 index a50d245670d..00000000000 --- a/erpnext/stock/ledger_snapshot.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Golden-master snapshot harness for ledger characterization tests. - -Captures the General Ledger *and* Stock Ledger entries produced by a submitted -voucher in a normalized, deterministic form and compares them against a stored -golden snapshot. Volatile fields (name, creation, voucher number, serial/batch -bundle id) are stripped so the snapshot is stable across runs. - -This is the Phase 0 safety net for the stock_controller refactor: every later -phase must keep these snapshots byte-identical. Regenerate goldens with:: - - REGEN_LEDGER_SNAPSHOTS=1 bench run-tests --site test-erpnext-v17 \\ - --module erpnext.stock.test_ledger_characterization -""" - -import json -import os -from pathlib import Path - -import frappe -from frappe.utils import flt - -SNAPSHOT_DIR = Path(__file__).parent / "ledger_snapshots" -REGEN_ENV = "REGEN_LEDGER_SNAPSHOTS" -GL_PRECISION = 2 -QTY_PRECISION = 6 -RATE_PRECISION = 4 - - -class GLSnapshot: - """Normalized, order-stable view of a voucher's GL entries.""" - - def __init__(self, voucher_type: str, voucher_no: str) -> None: - self.voucher_type = voucher_type - self.voucher_no = voucher_no - - def capture(self) -> list[dict]: - rows = [self._normalize(row) for row in self._fetch_rows()] - # Sort on the full normalized row so ordering never depends on the DB's - # return order. - return sorted(rows, key=lambda row: json.dumps(row, sort_keys=True)) - - def _fetch_rows(self) -> list[dict]: - gl = frappe.qb.DocType("GL Entry") - query = ( - frappe.qb.from_(gl) - .select( - gl.account, - gl.party_type, - gl.party, - gl.debit, - gl.credit, - gl.debit_in_account_currency, - gl.credit_in_account_currency, - gl.account_currency, - gl.against, - gl.cost_center, - gl.is_opening, - gl.posting_date, - ) - .where( - (gl.voucher_type == self.voucher_type) - & (gl.voucher_no == self.voucher_no) - & (gl.is_cancelled == 0) - ) - .orderby(gl.account, gl.party, gl.debit, gl.credit) - ) - return query.run(as_dict=True) - - def _normalize(self, row: dict) -> dict: - return { - "account": row.account, - "party_type": row.party_type or None, - "party": row.party or None, - "debit": flt(row.debit, GL_PRECISION), - "credit": flt(row.credit, GL_PRECISION), - "debit_in_account_currency": flt(row.debit_in_account_currency, GL_PRECISION), - "credit_in_account_currency": flt(row.credit_in_account_currency, GL_PRECISION), - "account_currency": row.account_currency, - "against": self._normalize_against(row.against), - "cost_center": row.cost_center, - "is_opening": row.is_opening, - "posting_date": str(row.posting_date), - } - - def _normalize_against(self, against: str | None) -> str | None: - """`against` is a comma-joined account list whose order is not stable.""" - if not against: - return None - return ", ".join(sorted(part.strip() for part in against.split(","))) - - -class SLSnapshot: - """Normalized, order-stable view of a voucher's Stock Ledger entries.""" - - def __init__(self, voucher_type: str, voucher_no: str) -> None: - self.voucher_type = voucher_type - self.voucher_no = voucher_no - - def capture(self) -> list[dict]: - rows = [self._normalize(row) for row in self._fetch_rows()] - return sorted(rows, key=lambda row: json.dumps(row, sort_keys=True)) - - def _fetch_rows(self) -> list[dict]: - sle = frappe.qb.DocType("Stock Ledger Entry") - query = ( - frappe.qb.from_(sle) - .select( - sle.item_code, - sle.warehouse, - sle.stock_uom, - sle.actual_qty, - sle.qty_after_transaction, - sle.incoming_rate, - sle.valuation_rate, - sle.stock_value, - sle.stock_value_difference, - sle.serial_and_batch_bundle, - sle.posting_date, - ) - .where( - (sle.voucher_type == self.voucher_type) - & (sle.voucher_no == self.voucher_no) - & (sle.is_cancelled == 0) - ) - .orderby(sle.item_code, sle.warehouse, sle.actual_qty) - ) - return query.run(as_dict=True) - - def _normalize(self, row: dict) -> dict: - return { - "item_code": row.item_code, - "warehouse": row.warehouse, - "stock_uom": row.stock_uom, - "actual_qty": flt(row.actual_qty, QTY_PRECISION), - "qty_after_transaction": flt(row.qty_after_transaction, QTY_PRECISION), - "incoming_rate": flt(row.incoming_rate, RATE_PRECISION), - "valuation_rate": flt(row.valuation_rate, RATE_PRECISION), - "stock_value": flt(row.stock_value, RATE_PRECISION), - "stock_value_difference": flt(row.stock_value_difference, RATE_PRECISION), - # Linkage presence, not the volatile bundle docname — catches a dropped - # serial/batch bundle link without coupling the golden to generated names. - "has_serial_and_batch_bundle": bool(row.serial_and_batch_bundle), - "posting_date": str(row.posting_date), - } - - -def capture_ledger_snapshot(voucher_type: str, voucher_no: str) -> dict: - """Combined GL + SLE snapshot for a single voucher.""" - return { - "gl": GLSnapshot(voucher_type, voucher_no).capture(), - "sle": SLSnapshot(voucher_type, voucher_no).capture(), - } - - -def assert_ledger_snapshot(test_case, name: str, voucher_type: str, voucher_no: str) -> None: - """Compare a voucher's GL + SLE entries against the golden snapshot ``name``. - - In regen mode (``REGEN_LEDGER_SNAPSHOTS`` set) the golden file is written - instead of asserted, so the same scenarios both produce and verify the goldens. - """ - actual = capture_ledger_snapshot(voucher_type, voucher_no) - path = SNAPSHOT_DIR / f"{name}.json" - - if os.environ.get(REGEN_ENV): - SNAPSHOT_DIR.mkdir(exist_ok=True) - path.write_text(json.dumps(actual, indent="\t", sort_keys=True) + "\n") - return - - test_case.assertTrue( - path.exists(), - f"Golden snapshot {path} missing. Run with {REGEN_ENV}=1 to create it.", - ) - expected = json.loads(path.read_text()) - test_case.assertEqual(expected, actual, f"Ledger snapshot mismatch for '{name}'") diff --git a/erpnext/stock/ledger_snapshots/dn_basic.json b/erpnext/stock/ledger_snapshots/dn_basic.json deleted file mode 100644 index 0c405a4772d..00000000000 --- a/erpnext/stock/ledger_snapshots/dn_basic.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock Delivered But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Delivered But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": -5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 0.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 500.0, - "stock_value_difference": -500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/dn_return.json b/erpnext/stock/ledger_snapshots/dn_return.json deleted file mode 100644 index 0144a698310..00000000000 --- a/erpnext/stock/ledger_snapshots/dn_return.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock Delivered But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Delivered But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 100.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 10.0, - "stock_uom": "_Test UOM", - "stock_value": 1000.0, - "stock_value_difference": 500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/pr_basic.json b/erpnext/stock/ledger_snapshots/pr_basic.json deleted file mode 100644 index 62e422fb272..00000000000 --- a/erpnext/stock/ledger_snapshots/pr_basic.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Received But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock Received But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 100.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 500.0, - "stock_value_difference": 500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/pr_batch_item.json b/erpnext/stock/ledger_snapshots/pr_batch_item.json deleted file mode 100644 index 76c4bf81517..00000000000 --- a/erpnext/stock/ledger_snapshots/pr_batch_item.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Received But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 1000.0, - "debit_in_account_currency": 1000.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock Received But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 1000.0, - "credit_in_account_currency": 1000.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 10.0, - "has_serial_and_batch_bundle": true, - "incoming_rate": 100.0, - "item_code": "_Test Characterization Batch Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 10.0, - "stock_uom": "Nos", - "stock_value": 1000.0, - "stock_value_difference": 1000.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/pr_return.json b/erpnext/stock/ledger_snapshots/pr_return.json deleted file mode 100644 index 9dc339da1a3..00000000000 --- a/erpnext/stock/ledger_snapshots/pr_return.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Received But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock Received But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": -5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 0.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 0.0, - "stock_uom": "_Test UOM", - "stock_value": 0.0, - "stock_value_difference": -500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/pr_serial_item.json b/erpnext/stock/ledger_snapshots/pr_serial_item.json deleted file mode 100644 index b581bff8d00..00000000000 --- a/erpnext/stock/ledger_snapshots/pr_serial_item.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Received But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock Received But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 5.0, - "has_serial_and_batch_bundle": true, - "incoming_rate": 100.0, - "item_code": "_Test Characterization Serial Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "Nos", - "stock_value": 500.0, - "stock_value_difference": 500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/pr_with_taxes.json b/erpnext/stock/ledger_snapshots/pr_with_taxes.json deleted file mode 100644 index dc6a71b6ed7..00000000000 --- a/erpnext/stock/ledger_snapshots/pr_with_taxes.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "gl": [ - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Received But Not Billed - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 750.0, - "debit_in_account_currency": 750.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock Received But Not Billed - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "_Test Account Customs Duty - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 150.0, - "credit_in_account_currency": 150.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "_Test Account Shipping Charges - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 100.0, - "credit_in_account_currency": 100.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 150.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 750.0, - "stock_value_difference": 750.0, - "valuation_rate": 150.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/se_material_issue.json b/erpnext/stock/ledger_snapshots/se_material_issue.json deleted file mode 100644 index 87f1be7efff..00000000000 --- a/erpnext/stock/ledger_snapshots/se_material_issue.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock Adjustment - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Adjustment - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": -5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 0.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 500.0, - "stock_value_difference": -500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/se_material_receipt.json b/erpnext/stock/ledger_snapshots/se_material_receipt.json deleted file mode 100644 index 47c8aa744ce..00000000000 --- a/erpnext/stock/ledger_snapshots/se_material_receipt.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock Adjustment - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Adjustment - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 100.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 500.0, - "stock_value_difference": 500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/se_material_transfer.json b/erpnext/stock/ledger_snapshots/se_material_transfer.json deleted file mode 100644 index d9d76458d3f..00000000000 --- a/erpnext/stock/ledger_snapshots/se_material_transfer.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "gl": [], - "sle": [ - { - "actual_qty": -5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 0.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 500.0, - "stock_value_difference": -500.0, - "valuation_rate": 100.0, - "warehouse": "Stores - TCP1" - }, - { - "actual_qty": 5.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 100.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 5.0, - "stock_uom": "_Test UOM", - "stock_value": 500.0, - "stock_value_difference": 500.0, - "valuation_rate": 100.0, - "warehouse": "Finished Goods - TCP1" - } - ] -} diff --git a/erpnext/stock/ledger_snapshots/sr_basic.json b/erpnext/stock/ledger_snapshots/sr_basic.json deleted file mode 100644 index 1203530416d..00000000000 --- a/erpnext/stock/ledger_snapshots/sr_basic.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "gl": [ - { - "account": "Stock Adjustment - TCP1", - "account_currency": "INR", - "against": "Stock In Hand - TCP1", - "cost_center": "Main - TCP1", - "credit": 1500.0, - "credit_in_account_currency": 1500.0, - "debit": 0.0, - "debit_in_account_currency": 0.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - }, - { - "account": "Stock In Hand - TCP1", - "account_currency": "INR", - "against": "Stock Adjustment - TCP1", - "cost_center": "Main - TCP1", - "credit": 0.0, - "credit_in_account_currency": 0.0, - "debit": 1500.0, - "debit_in_account_currency": 1500.0, - "is_opening": "No", - "party": null, - "party_type": null, - "posting_date": "2024-01-15" - } - ], - "sle": [ - { - "actual_qty": 0.0, - "has_serial_and_batch_bundle": false, - "incoming_rate": 0.0, - "item_code": "_Test Item", - "posting_date": "2024-01-15", - "qty_after_transaction": 10.0, - "stock_uom": "_Test UOM", - "stock_value": 1500.0, - "stock_value_difference": 1500.0, - "valuation_rate": 150.0, - "warehouse": "Stores - TCP1" - } - ] -} diff --git a/erpnext/stock/test_ledger_characterization.py b/erpnext/stock/test_ledger_characterization.py deleted file mode 100644 index 9a3cbd69b20..00000000000 --- a/erpnext/stock/test_ledger_characterization.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Phase 0 characterization tests for the stock_controller refactor. - -These are golden-master snapshot tests: each scenario builds a representative -stock voucher, submits it, and compares its GL *and* Stock Ledger entries against -a stored snapshot (see ``erpnext/stock/ledger_snapshots``). They assert nothing -about *correct* accounting or valuation — only that ledger output stays -byte-identical as ``stock_controller`` is split into services. - -Determinism: each test is wrapped in a savepoint that is rolled back in tearDown, -so the cumulative Stock Ledger fields (qty_after_transaction, stock_value, -valuation_rate) do not depend on test execution order or on state left by other -tests. Prerequisite stock is posted on PREREQUISITE_DATE (before POSTING_DATE) so -balances are positive and independent of the wall-clock date. Run the module in -isolation (``--module ...``) as below. - -Regenerate goldens after an intentional change:: - - REGEN_LEDGER_SNAPSHOTS=1 bench run-tests --site test-erpnext-v17 \\ - --module erpnext.stock.test_ledger_characterization -""" - -import frappe -from frappe.tests import IntegrationTestCase - -from erpnext.stock.doctype.item.test_item import make_item -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt -from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.ledger_snapshot import assert_ledger_snapshot - -POSTING_DATE = "2024-01-15" -PREREQUISITE_DATE = "2024-01-10" -CUSTOMER = "_Test Customer" -COMPANY = "_Test Company with perpetual inventory" -WAREHOUSE = "Stores - TCP1" - - -class TestLedgerCharacterization(IntegrationTestCase): - def setUp(self): - frappe.db.savepoint("ledger_characterization") - - def tearDown(self): - frappe.db.rollback(save_point="ledger_characterization") - - def test_dn_basic(self): - make_stock_entry( - item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, posting_date=PREREQUISITE_DATE - ) - dn = _make_dated_delivery_note(qty=5, rate=150) - dn.insert() - dn.submit() - assert_ledger_snapshot(self, "dn_basic", "Delivery Note", dn.name) - - def test_dn_return(self): - make_stock_entry( - item_code="_Test Item", target=WAREHOUSE, qty=10, basic_rate=100, posting_date=PREREQUISITE_DATE - ) - original = _make_dated_delivery_note(qty=5, rate=150) - original.insert() - original.submit() - - ret = frappe.copy_doc(original) - ret.is_return = 1 - ret.return_against = original.name - for item in ret.items: - item.qty = -item.qty - ret.set_posting_time = 1 - ret.posting_date = POSTING_DATE - ret.insert() - ret.submit() - assert_ledger_snapshot(self, "dn_return", "Delivery Note", ret.name) - - def test_se_material_receipt(self): - se = make_stock_entry( - item_code="_Test Item", - target=WAREHOUSE, - qty=5, - basic_rate=100, - company=COMPANY, - posting_date=POSTING_DATE, - do_not_submit=True, - ) - se.submit() - assert_ledger_snapshot(self, "se_material_receipt", "Stock Entry", se.name) - - def test_se_material_issue(self): - make_stock_entry( - item_code="_Test Item", - target=WAREHOUSE, - qty=10, - basic_rate=100, - company=COMPANY, - posting_date=PREREQUISITE_DATE, - ) - se = make_stock_entry( - item_code="_Test Item", - source=WAREHOUSE, - qty=5, - company=COMPANY, - posting_date=POSTING_DATE, - do_not_submit=True, - ) - se.submit() - assert_ledger_snapshot(self, "se_material_issue", "Stock Entry", se.name) - - def test_se_material_transfer(self): - make_stock_entry( - item_code="_Test Item", - target=WAREHOUSE, - qty=10, - basic_rate=100, - company=COMPANY, - posting_date=PREREQUISITE_DATE, - ) - se = make_stock_entry( - item_code="_Test Item", - source=WAREHOUSE, - target="Finished Goods - TCP1", - qty=5, - company=COMPANY, - posting_date=POSTING_DATE, - do_not_submit=True, - ) - se.submit() - assert_ledger_snapshot(self, "se_material_transfer", "Stock Entry", se.name) - - def test_sr_basic(self): - sr = _make_dated_stock_reconciliation(qty=10, rate=150) - sr.insert() - sr.submit() - assert_ledger_snapshot(self, "sr_basic", "Stock Reconciliation", sr.name) - - def test_pr_basic(self): - pr = make_purchase_receipt( - company=COMPANY, warehouse=WAREHOUSE, posting_date=POSTING_DATE, qty=5, rate=100 - ) - assert_ledger_snapshot(self, "pr_basic", "Purchase Receipt", pr.name) - - def test_pr_with_taxes(self): - pr = make_purchase_receipt( - company=COMPANY, - warehouse=WAREHOUSE, - posting_date=POSTING_DATE, - qty=5, - rate=100, - get_taxes_and_charges=True, - ) - assert_ledger_snapshot(self, "pr_with_taxes", "Purchase Receipt", pr.name) - - def test_pr_return(self): - from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return - - original = make_purchase_receipt( - company=COMPANY, warehouse=WAREHOUSE, posting_date=POSTING_DATE, qty=5, rate=100 - ) - ret = make_purchase_return(original.name) - ret.posting_date = POSTING_DATE - ret.set_posting_time = 1 - ret.insert() - ret.submit() - assert_ledger_snapshot(self, "pr_return", "Purchase Receipt", ret.name) - - def test_pr_batch_item(self): - """Exercises SerialBatchBundleService bundle creation + SLE bundle linkage.""" - item_code = make_item( - "_Test Characterization Batch Item", - { - "has_batch_no": 1, - "create_new_batch": 1, - "batch_number_series": "CHAR-BATCH-.#####", - "is_stock_item": 1, - }, - ).name - pr = make_purchase_receipt( - item_code=item_code, - company=COMPANY, - warehouse=WAREHOUSE, - posting_date=POSTING_DATE, - qty=10, - rate=100, - ) - assert_ledger_snapshot(self, "pr_batch_item", "Purchase Receipt", pr.name) - - def test_pr_serial_item(self): - """Exercises SerialBatchBundleService for serialized items + SLE bundle linkage.""" - item_code = make_item( - "_Test Characterization Serial Item", - {"has_serial_no": 1, "serial_no_series": "CHAR-SER-.#####", "is_stock_item": 1}, - ).name - pr = make_purchase_receipt( - item_code=item_code, - company=COMPANY, - warehouse=WAREHOUSE, - posting_date=POSTING_DATE, - qty=5, - rate=100, - ) - assert_ledger_snapshot(self, "pr_serial_item", "Purchase Receipt", pr.name) - - -def _make_dated_delivery_note(**args) -> frappe.Document: - """Minimal Delivery Note on a fixed posting date using the perpetual-inventory - test company. - - Inlined to avoid importing test_delivery_note which drags in conflicting - test-record dependencies at discovery time.""" - dn = frappe.new_doc("Delivery Note") - dn.company = COMPANY - dn.customer = CUSTOMER - dn.posting_date = POSTING_DATE - dn.set_posting_time = 1 - dn.append( - "items", - { - "item_code": args.get("item_code", "_Test Item"), - "warehouse": args.get("warehouse", WAREHOUSE), - "qty": args.get("qty", 1), - "rate": args.get("rate", 100), - "expense_account": "Cost of Goods Sold - TCP1", - "cost_center": "Main - TCP1", - }, - ) - return dn - - -def _make_dated_stock_reconciliation(**args) -> frappe.Document: - """Minimal Stock Reconciliation on a fixed posting date using the perpetual-inventory - test company. - - Inlined to avoid importing test_stock_reconciliation which drags in conflicting - test-record dependencies at discovery time.""" - sr = frappe.new_doc("Stock Reconciliation") - sr.company = COMPANY - sr.purpose = args.get("purpose", "Stock Reconciliation") - sr.posting_date = POSTING_DATE - sr.posting_time = "00:00:00" - sr.set_posting_time = 1 - sr.expense_account = frappe.get_cached_value("Company", COMPANY, "stock_adjustment_account") - sr.cost_center = frappe.get_cached_value("Company", COMPANY, "cost_center") - sr.append( - "items", - { - "item_code": args.get("item_code", "_Test Item"), - "warehouse": args.get("warehouse", WAREHOUSE), - "qty": args.get("qty", 10), - "valuation_rate": args.get("rate", 100), - }, - ) - return sr From b41eb6876a5b314c9a62de5372f28be109cf3a14 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 14:59:41 +0530 Subject: [PATCH 20/21] refactor(stock): rename stock_ledger service module to stock_ledger_service Avoids basename collision with the core SLE engine erpnext/stock/stock_ledger.py (the service even imports make_sl_entries from it). File now maps 1:1 to its class, StockLedgerService. --- erpnext/controllers/stock_controller.py | 10 +++++----- .../stock_reconciliation/stock_reconciliation.py | 2 +- .../{stock_ledger.py => stock_ledger_service.py} | 0 3 files changed, 6 insertions(+), 6 deletions(-) rename erpnext/stock/services/{stock_ledger.py => stock_ledger_service.py} (100%) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9fd61d6a209..0c0f27b3a95 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -229,12 +229,12 @@ class StockController(AccountsController): ) def get_items_and_warehouses(self) -> tuple[list[str], list[str]]: - from erpnext.stock.services.stock_ledger import StockLedgerService + from erpnext.stock.services.stock_ledger_service import StockLedgerService return StockLedgerService(self).get_items_and_warehouses() def get_stock_ledger_details(self): - from erpnext.stock.services.stock_ledger import StockLedgerService + from erpnext.stock.services.stock_ledger_service import StockLedgerService return StockLedgerService(self).get_stock_ledger_details() @@ -258,7 +258,7 @@ class StockController(AccountsController): ) def get_sl_entries(self, d, args): - from erpnext.stock.services.stock_ledger import StockLedgerService + from erpnext.stock.services.stock_ledger_service import StockLedgerService return StockLedgerService(self).get_sl_entries(d, args) @@ -270,7 +270,7 @@ class StockController(AccountsController): return get_item_account_wise_lcv_entries(self) def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): - from erpnext.stock.services.stock_ledger import StockLedgerService + from erpnext.stock.services.stock_ledger_service import StockLedgerService return StockLedgerService(self).make_sl_entries( sl_entries, allow_negative_stock, via_landed_cost_voucher @@ -360,7 +360,7 @@ class StockController(AccountsController): d.stock_uom_rate = d.rate / (d.conversion_factor or 1) def repost_future_sle_and_gle(self, force=False, via_landed_cost_voucher=False): - from erpnext.stock.services.stock_ledger import StockLedgerService + from erpnext.stock.services.stock_ledger_service import StockLedgerService return StockLedgerService(self).repost_future_sle_and_gle(force, via_landed_cost_voucher) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 5118c9c41ae..8ee20df2d72 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -930,7 +930,7 @@ class StockReconciliation(StockController): data.qty_after_transaction = 0.0 data.incoming_rate = flt(row.valuation_rate) - from erpnext.stock.services.stock_ledger import StockLedgerService + from erpnext.stock.services.stock_ledger_service import StockLedgerService StockLedgerService(self).update_inventory_dimensions(row, data) diff --git a/erpnext/stock/services/stock_ledger.py b/erpnext/stock/services/stock_ledger_service.py similarity index 100% rename from erpnext/stock/services/stock_ledger.py rename to erpnext/stock/services/stock_ledger_service.py From 7d72d21bbed8d9bf2dcf446d4353ce02c9c7e37e Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 5 Jun 2026 15:16:41 +0530 Subject: [PATCH 21/21] refactor(stock): add _service suffix to serial_batch_bundle and quality_inspection modules Consistent service-module naming: serial_batch_bundle_service.py / quality_inspection_service.py (matching stock_ledger_service.py). Importers updated; engine-module imports (stock.serial_batch_bundle) untouched. --- erpnext/controllers/stock_controller.py | 18 +++++++++--------- .../stock/doctype/stock_entry/stock_entry.py | 2 +- .../stock_reconciliation.py | 2 +- ...ection.py => quality_inspection_service.py} | 0 ...undle.py => serial_batch_bundle_service.py} | 0 erpnext/stock/services/stock_ledger_service.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) rename erpnext/stock/services/{quality_inspection.py => quality_inspection_service.py} (100%) rename erpnext/stock/services/{serial_batch_bundle.py => serial_batch_bundle_service.py} (100%) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 0c0f27b3a95..904d600c9a8 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -38,7 +38,7 @@ from erpnext.stock.stock_ledger import get_items_to_be_repost class StockController(AccountsController): def validate(self): from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService sbb = SerialBatchBundleService(self) @@ -203,19 +203,19 @@ class StockController(AccountsController): make_gl_entries(gl_entries, from_repost=from_repost) def make_bundle_using_old_serial_batch_fields(self, table_name=None, via_landed_cost_voucher=False): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService return SerialBatchBundleService(self).make_bundle_using_old_serial_batch_fields( table_name, via_landed_cost_voucher ) def make_bundle_for_sales_purchase_return(self, table_name=None): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService return SerialBatchBundleService(self).make_bundle_for_sales_purchase_return(table_name) def set_use_serial_batch_fields(self): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService return SerialBatchBundleService(self).set_use_serial_batch_fields() @@ -239,19 +239,19 @@ class StockController(AccountsController): return StockLedgerService(self).get_stock_ledger_details() def delete_auto_created_batches(self): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService return SerialBatchBundleService(self).delete_auto_created_batches() def set_serial_and_batch_bundle(self, table_name=None, ignore_validate=False): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate) def make_package_for_transfer( self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0 ): - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService return SerialBatchBundleService(self).make_package_for_transfer( serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty @@ -331,7 +331,7 @@ class StockController(AccountsController): ) def validate_inspection(self): - from erpnext.stock.services.quality_inspection import QualityInspectionService + from erpnext.stock.services.quality_inspection_service import QualityInspectionService return QualityInspectionService(self).validate_inspection() @@ -625,7 +625,7 @@ def repost_required_for_queue(doc: StockController) -> bool: @frappe.whitelist() def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]): - from erpnext.stock.services.quality_inspection import INSPECTION_FIELDNAME_MAP + from erpnext.stock.services.quality_inspection_service import INSPECTION_FIELDNAME_MAP if isinstance(items, str): items = json.loads(items) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 41284504f7c..9f78d3295d6 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -266,7 +266,7 @@ class StockEntry(StockController, SubcontractingInwardController): def validate(self): from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService sbb = SerialBatchBundleService(self) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 8ee20df2d72..13aa0225568 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -66,7 +66,7 @@ class StockReconciliation(StockController): def validate(self): from erpnext.stock.doctype.putaway_rule.putaway_rule import validate_putaway_capacity - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService sbb = SerialBatchBundleService(self) diff --git a/erpnext/stock/services/quality_inspection.py b/erpnext/stock/services/quality_inspection_service.py similarity index 100% rename from erpnext/stock/services/quality_inspection.py rename to erpnext/stock/services/quality_inspection_service.py diff --git a/erpnext/stock/services/serial_batch_bundle.py b/erpnext/stock/services/serial_batch_bundle_service.py similarity index 100% rename from erpnext/stock/services/serial_batch_bundle.py rename to erpnext/stock/services/serial_batch_bundle_service.py diff --git a/erpnext/stock/services/stock_ledger_service.py b/erpnext/stock/services/stock_ledger_service.py index f41ae7e53ed..275f27b9652 100644 --- a/erpnext/stock/services/stock_ledger_service.py +++ b/erpnext/stock/services/stock_ledger_service.py @@ -203,7 +203,7 @@ class StockLedgerService: def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): from erpnext.stock.serial_batch_bundle import update_batch_qty - from erpnext.stock.services.serial_batch_bundle import SerialBatchBundleService + from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService from erpnext.stock.stock_ledger import make_sl_entries make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher)