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