From dfbd8db9d3db1a831bf20c1d51c04dbb25fc29c3 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 00:05:35 +0530 Subject: [PATCH 01/65] docs: add accounts/controller refactor spec Phased plan to decompose accounts_controller.py and the sales_invoice.py monolith into composed services. Documents the frozen GL-layer design (GLComposer / gl_validator / general_ledger sink), method bucketing, and the 8-phase rollout. --- specs/accounts_refactor_spec.md | 99 +++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 specs/accounts_refactor_spec.md diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md new file mode 100644 index 00000000000..5215c714b01 --- /dev/null +++ b/specs/accounts_refactor_spec.md @@ -0,0 +1,99 @@ +# Accounts / Controller Refactor — Spec + +## Motivation +Move ERPNext away from the deep `AccountsController → SellingController/BuyingController → SalesInvoice` +inheritance chain and the monolithic `sales_invoice.py` / god-object `accounts_controller.py` +toward **composition**: per-doctype `services/` plus shared module-level `accounts/services/`. +Goal is testability, readability, and factoring shared domain logic out so Sales/Purchase +voucher logic is not duplicated. + +## Target structure +``` +erpnext +├── controllers +│ └── transaction_controller.py # thin lifecycle base, delegates to services +├── accounts +│ ├── general_ledger.py # the SINK (unchanged): post / merge / round-off / reverse +│ ├── services +│ │ ├── base_gl_composer.py # BaseGLComposer — shared GL helpers +│ │ ├── gl_validator.py # list-level validation (functions, stateless) +│ │ ├── advances.py +│ │ ├── taxes.py +│ │ └── budget.py +│ └── doctype +│ └── sales_invoice +│ ├── sales_invoice.py # thin: delegates to services +│ ├── services +│ │ ├── gl_composer.py # SalesInvoiceGLComposer(BaseGLComposer) +│ │ ├── pos.py +│ │ ├── loyalty.py +│ │ ├── status.py +│ │ ├── inter_company.py +│ │ ├── fixed_assets.py +│ │ └── timesheet_billing.py +│ ├── mapper.py +│ └── api.py +``` + +## GL layer — frozen design +Pipeline: +``` +SalesInvoiceGLComposer.compose() → gl_entries → gl_validator.validate(gl_entries) → general_ledger.make_gl_entries() +``` + +| Role | Location | Form | Responsibility | +|---|---|---|---| +| **Composer (base)** | `accounts/services/base_gl_composer.py` → `BaseGLComposer` | class (stateful, holds `self.doc`) | shared row factory + common entries | +| **Composer (doctype)** | `sales_invoice/services/gl_composer.py` → `SalesInvoiceGLComposer(BaseGLComposer)` | class | voucher-specific rows via `.compose()` | +| **Validator** | `accounts/services/gl_validator.py` | module functions (stateless) | assert the finished `gl_entries` list is legal to post | +| **Sink** | `accounts/general_ledger.py` (unchanged) | module functions | merge / round-off / post / reverse | + +### Naming decisions (frozen) +- Chose **`compose`** over `make`/`build` — the sink already owns the verb `make` (`make_gl_entries`); `compose` avoids a two-makers collision. +- `base_` prefix on the shared/abstract file; the concrete subclass carries the specific name, no prefix. +- Rejected: `gl_map` (it's a list, not a map — but it's an entrenched public param; rename to `gl_entries` later as its own deprecation pass), `gl_processor` (redundant with `general_ledger.py`), `gl_entries.py` (collides with the `gl_entry` doctype + the ubiquitous local var), `ledger_builder` (clashes with stock/payment ledger), `builder`/`maker` (generic; "maker" collides with `make_gl_entries`). + +## Bucketing `accounts_controller.py` +- **Base composer (`BaseGLComposer`):** `get_gl_dict`, `get_value_in_transaction_currency`, `make_discount_gl_entries` (+ `get_amount_and_base_amount`, `get_tax_amounts`), `make_precision_loss_gl_entry`, `make_exchange_gain_loss_journal` (+ `gain_loss_journal_already_booked`), `set_transaction_currency_and_rate_in_gl_map`. Regional hooks `update_gl_dict_with_regional_fields` / `..._app_based_fields` stay free functions called inside `get_gl_dict`. +- **Advances service:** `set_advances`, `get_advance_entries`, `clear_unallocated_advances`, `validate_advance_entries`, `set_advance_gain_or_loss`, `calculate_total_advance_from_ledger`, `set_total_advance_paid`, `set_advance_payment_status`, `delink_advance_entries`, `create_advance_and_reconcile`, `get_advance_payment_doctypes`, `_remove_advance_payment_ledger_entries`, module funcs `get_advance_journal_entries` / `get_advance_payment_entries`. +- **Validator (from `general_ledger.py`):** `validate_disabled_accounts`, `validate_accounting_period`, `validate_cwip_accounts`, `check_freezing_date`, `validate_against_pcv`, `validate_allowed_dimensions`, balance assertion (`get_debit_credit_difference` / `get_debit_credit_allowance` / `raise_debit_credit_not_equal_error`). + - **Stays in compose (do NOT move to validator):** `process_debit_credit_difference` / `make_round_off_gle` — these *repair* balance by appending a round-off entry (mutation), not validation. + - **Stays in composer (not validator):** row-level checks (right account for a row, dimension applicability) — validator only validates the finished list. +- **Leave in controller:** `validate_company_in_accounting_dimension`, `validate_company` (dimension validation, not GL). + +## Phases +Each phase is behavior-preserving, one draft PR, gated by the Phase-0 snapshot suite + `bench run-tests --site test-site-ai`. + +### Phase 0 — Safety net (first, mandatory) +Characterization tests snapshotting `gl_entries` output for representative transactions (SI/PI with taxes, multi-currency, advances, discounts, round-off, POS). Every later phase passes iff snapshots are byte-identical. + +### Phase 1 — Extract `gl_validator.py` (lowest risk) +Move list-level validations out of `general_ledger.py`; `make_gl_entries` calls `gl_validator.validate(gl_entries)`. Near-pure move; proves the safety net. + +### Phase 2 — Pilot composer on Sales Invoice only +Create `BaseGLComposer` + `SalesInvoiceGLComposer`; lift bucket-A helpers from `accounts_controller`; move SI's `get_gl_entries` body into `.compose()`; old method becomes a thin shim. Do not over-generalise the base from one example. + +### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) +Add `PurchaseInvoiceGLComposer`; reshape `BaseGLComposer` from what SI + PI *actually* share. Two real consumers is the minimum to size the abstraction — prevents premature abstraction. + +### Phase 4 — Roll out composer to remaining GL-posting doctypes +Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated. + +### Phase 5 — Extract `advances.py` +Move the advances cluster. After composers, because advances cross-calls the exchange-gain/loss helper now on `BaseGLComposer`. + +### Phase 6 — Extract remaining domain services from `accounts_controller` +`taxes.py`, `budget.py`, etc. Shrink `accounts_controller` to a thin lifecycle base that delegates. + +### Phase 7 — Split the rest of the `sales_invoice.py` monolith +Non-GL doctype services: `pos.py`, `loyalty.py`, `status.py`, `inter_company.py`, `fixed_assets.py`, `timesheet_billing.py`. Independent of GL work; can run parallel to 5–6. + +### Phase 8 — Collapse the inheritance chain +Flatten `SellingController` / `BuyingController` layers that are now pass-through. Last, because only safe once the delegated-to services exist. + +**Dependencies:** 1→2→3 sequential; 4 and 7 can parallelize once 3 lands; 8 always last. + +## Cross-cutting rules +- Public signatures stay stable — keep the `gl_map=` param and `make_gl_entries` intact. The `gl_map → gl_entries` rename is its own deprecation pass, deferred to the end (or excluded). +- Composers are classes (stateful, per-document); sink and validator are stateless module functions. +- Every phase: behavior-preserving, snapshot + `bench run-tests --site test-site-ai` green before merge, draft PR. From 064340cafb326e9d162a87205d331fe88446a190 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 00:49:48 +0530 Subject: [PATCH 02/65] test: add Phase 0 GL characterization safety net Golden-master snapshot harness (GLSnapshot / assert_gl_snapshot) plus 12 characterization scenarios for Sales and Purchase Invoice (basic, taxes, multi-currency, returns, round-off, discount accounting, advance, POS). Locks current GL Entry output so the upcoming GL pipeline refactor (composer / validator / sink) can be verified byte-identical. Regenerate goldens with REGEN_GL_SNAPSHOTS=1. --- erpnext/accounts/gl_snapshot.py | 110 ++++++++++ erpnext/accounts/gl_snapshots/pi_basic.json | 30 +++ .../gl_snapshots/pi_multi_currency.json | 30 +++ erpnext/accounts/gl_snapshots/pi_return.json | 30 +++ .../accounts/gl_snapshots/pi_with_taxes.json | 58 ++++++ erpnext/accounts/gl_snapshots/si_basic.json | 30 +++ .../gl_snapshots/si_multi_currency.json | 30 +++ erpnext/accounts/gl_snapshots/si_pos.json | 86 ++++++++ erpnext/accounts/gl_snapshots/si_return.json | 30 +++ .../accounts/gl_snapshots/si_round_off.json | 58 ++++++ .../gl_snapshots/si_with_advance.json | 30 +++ .../gl_snapshots/si_with_discount.json | 44 ++++ .../accounts/gl_snapshots/si_with_taxes.json | 44 ++++ erpnext/accounts/test_gl_characterization.py | 189 ++++++++++++++++++ 14 files changed, 799 insertions(+) create mode 100644 erpnext/accounts/gl_snapshot.py create mode 100644 erpnext/accounts/gl_snapshots/pi_basic.json create mode 100644 erpnext/accounts/gl_snapshots/pi_multi_currency.json create mode 100644 erpnext/accounts/gl_snapshots/pi_return.json create mode 100644 erpnext/accounts/gl_snapshots/pi_with_taxes.json create mode 100644 erpnext/accounts/gl_snapshots/si_basic.json create mode 100644 erpnext/accounts/gl_snapshots/si_multi_currency.json create mode 100644 erpnext/accounts/gl_snapshots/si_pos.json create mode 100644 erpnext/accounts/gl_snapshots/si_return.json create mode 100644 erpnext/accounts/gl_snapshots/si_round_off.json create mode 100644 erpnext/accounts/gl_snapshots/si_with_advance.json create mode 100644 erpnext/accounts/gl_snapshots/si_with_discount.json create mode 100644 erpnext/accounts/gl_snapshots/si_with_taxes.json create mode 100644 erpnext/accounts/test_gl_characterization.py diff --git a/erpnext/accounts/gl_snapshot.py b/erpnext/accounts/gl_snapshot.py new file mode 100644 index 00000000000..1bb81384f45 --- /dev/null +++ b/erpnext/accounts/gl_snapshot.py @@ -0,0 +1,110 @@ +"""Golden-master snapshot harness for GL Entry characterization tests. + +Captures the General 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) are stripped so the +snapshot is stable across runs. + +This is the Phase 0 safety net for the accounts/controller refactor: every +later phase must keep these snapshots byte-identical. Regenerate goldens with:: + + REGEN_GL_SNAPSHOTS=1 bench run-tests --site test-site-ai \\ + --module erpnext.accounts.test_gl_characterization +""" + +import json +import os +from pathlib import Path + +import frappe +from frappe.utils import flt + +SNAPSHOT_DIR = Path(__file__).parent / "gl_snapshots" +REGEN_ENV = "REGEN_GL_SNAPSHOTS" +PRECISION = 2 + + +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 — e.g. two POS payment legs that tie on account/party/amount + # but differ only in `against`. + 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, PRECISION), + "credit": flt(row.credit, PRECISION), + "debit_in_account_currency": flt(row.debit_in_account_currency, PRECISION), + "credit_in_account_currency": flt(row.credit_in_account_currency, 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(","))) + + +def assert_gl_snapshot(test_case, name: str, voucher_type: str, voucher_no: str) -> None: + """Compare a voucher's GL entries against the golden snapshot ``name``. + + In regen mode (``REGEN_GL_SNAPSHOTS`` set) the golden file is written instead + of asserted, so the same scenarios both produce and verify the goldens. + """ + actual = GLSnapshot(voucher_type, voucher_no).capture() + 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"GL snapshot mismatch for '{name}'") diff --git a/erpnext/accounts/gl_snapshots/pi_basic.json b/erpnext/accounts/gl_snapshots/pi_basic.json new file mode 100644 index 00000000000..7e999295def --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pi_basic.json @@ -0,0 +1,30 @@ +[ + { + "account": "Creditors - _TC", + "account_currency": "INR", + "against": "_Test Account Cost for Goods Sold - _TC", + "cost_center": null, + "credit": 250.0, + "credit_in_account_currency": 250.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Supplier", + "party_type": "Supplier", + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Cost for Goods Sold - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 250.0, + "debit_in_account_currency": 250.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/pi_multi_currency.json b/erpnext/accounts/gl_snapshots/pi_multi_currency.json new file mode 100644 index 00000000000..e20e31810bb --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pi_multi_currency.json @@ -0,0 +1,30 @@ +[ + { + "account": "Creditors - _TC", + "account_currency": "INR", + "against": "_Test Account Cost for Goods Sold - _TC", + "cost_center": null, + "credit": 18750.0, + "credit_in_account_currency": 18750.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Supplier", + "party_type": "Supplier", + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Cost for Goods Sold - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 18750.0, + "debit_in_account_currency": 18750.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/pi_return.json b/erpnext/accounts/gl_snapshots/pi_return.json new file mode 100644 index 00000000000..ffc8afc9a03 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pi_return.json @@ -0,0 +1,30 @@ +[ + { + "account": "Creditors - _TC", + "account_currency": "INR", + "against": "_Test Account Cost for Goods Sold - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 250.0, + "debit_in_account_currency": 250.0, + "is_opening": "No", + "party": "_Test Supplier", + "party_type": "Supplier", + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Cost for Goods Sold - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "_Test Cost Center - _TC", + "credit": 250.0, + "credit_in_account_currency": 250.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/pi_with_taxes.json b/erpnext/accounts/gl_snapshots/pi_with_taxes.json new file mode 100644 index 00000000000..5cb6ca60a3e --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pi_with_taxes.json @@ -0,0 +1,58 @@ +[ + { + "account": "Creditors - _TC", + "account_currency": "INR", + "against": "_Test Account Cost for Goods Sold - _TC", + "cost_center": null, + "credit": 288.0, + "credit_in_account_currency": 288.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Supplier", + "party_type": "Supplier", + "posting_date": "2024-01-15" + }, + { + "account": "Round Off - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "Main - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 0.5, + "debit_in_account_currency": 0.5, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Cost for Goods Sold - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 250.0, + "debit_in_account_currency": 250.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account VAT - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 37.5, + "debit_in_account_currency": 37.5, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_basic.json b/erpnext/accounts/gl_snapshots/si_basic.json new file mode 100644 index 00000000000..48bcf835043 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_basic.json @@ -0,0 +1,30 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1000.0, + "debit_in_account_currency": 1000.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_multi_currency.json b/erpnext/accounts/gl_snapshots/si_multi_currency.json new file mode 100644 index 00000000000..637eb8110b0 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_multi_currency.json @@ -0,0 +1,30 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 75000.0, + "debit_in_account_currency": 75000.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "credit": 75000.0, + "credit_in_account_currency": 75000.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_pos.json b/erpnext/accounts/gl_snapshots/si_pos.json new file mode 100644 index 00000000000..153c15cf334 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_pos.json @@ -0,0 +1,86 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1000.0, + "debit_in_account_currency": 1000.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "_Test Bank - _TC", + "cost_center": null, + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "_Test Cash - _TC", + "cost_center": null, + "credit": 500.0, + "credit_in_account_currency": 500.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + }, + { + "account": "_Test Bank - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": null, + "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": "_Test Cash - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": null, + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_return.json b/erpnext/accounts/gl_snapshots/si_return.json new file mode 100644 index 00000000000..477f83d50c5 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_return.json @@ -0,0 +1,30 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 1000.0, + "credit_in_account_currency": 1000.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_round_off.json b/erpnext/accounts/gl_snapshots/si_round_off.json new file mode 100644 index 00000000000..c994ced1fec --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_round_off.json @@ -0,0 +1,58 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 106.0, + "debit_in_account_currency": 106.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Round Off - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "Main - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 0.5, + "debit_in_account_currency": 0.5, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + }, + { + "account": "_Test Account Service Tax - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "credit": 6.5, + "credit_in_account_currency": 6.5, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_with_advance.json b/erpnext/accounts/gl_snapshots/si_with_advance.json new file mode 100644 index 00000000000..48bcf835043 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_with_advance.json @@ -0,0 +1,30 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1000.0, + "debit_in_account_currency": 1000.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_with_discount.json b/erpnext/accounts/gl_snapshots/si_with_discount.json new file mode 100644 index 00000000000..29a2d25ac0e --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_with_discount.json @@ -0,0 +1,44 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 90.0, + "debit_in_account_currency": 90.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Discount Account - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 10.0, + "debit_in_account_currency": 10.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/si_with_taxes.json b/erpnext/accounts/gl_snapshots/si_with_taxes.json new file mode 100644 index 00000000000..228e76cf295 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/si_with_taxes.json @@ -0,0 +1,44 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Sales - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1140.0, + "debit_in_account_currency": 1140.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Sales - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + }, + { + "account": "_Test Account Service Tax - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "credit": 140.0, + "credit_in_account_currency": 140.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py new file mode 100644 index 00000000000..891d906e794 --- /dev/null +++ b/erpnext/accounts/test_gl_characterization.py @@ -0,0 +1,189 @@ +"""Phase 0 characterization tests for the accounts/controller refactor. + +These are golden-master snapshot tests: each scenario builds a representative +voucher, submits it, and compares its GL entries against a stored snapshot +(see ``erpnext/accounts/gl_snapshots``). They assert nothing about *correct* +accounting — only that GL output stays byte-identical as the GL pipeline is +refactored into composer / validator / sink services. + +Regenerate goldens after an intentional change:: + + REGEN_GL_SNAPSHOTS=1 bench run-tests --site test-erpnext-v17 \\ + --module erpnext.accounts.test_gl_characterization +""" + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.tests.classes.context_managers import change_settings + +from erpnext.accounts.doctype.account.test_account import create_account +from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( + set_default_account_for_mode_of_payment, +) +from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.gl_snapshot import assert_gl_snapshot + +POSTING_DATE = "2024-01-15" +COMPANY = "_Test Company" +CUSTOMER = "_Test Customer" + + +def make_dated_purchase_invoice(**args): + """make_purchase_invoice ignores posting_date unless set_posting_time is on, + which would make snapshots depend on the run date. Force the backdated time.""" + pi = make_purchase_invoice(do_not_save=True, **args) + pi.set_posting_time = 1 + pi.posting_date = POSTING_DATE + return pi + + +class TestGLCharacterization(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + for mode, account in (("Cash", "_Test Cash - _TC"), ("Bank Draft", "_Test Bank - _TC")): + set_default_account_for_mode_of_payment(frappe.get_doc("Mode of Payment", mode), COMPANY, account) + + def test_si_basic(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100) + assert_gl_snapshot(self, "si_basic", "Sales Invoice", si.name) + + def test_si_with_taxes(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 14, + }, + ) + si.insert() + si.submit() + assert_gl_snapshot(self, "si_with_taxes", "Sales Invoice", si.name) + + def test_si_multi_currency(self): + si = create_sales_invoice( + posting_date=POSTING_DATE, qty=10, rate=100, currency="USD", conversion_rate=75 + ) + assert_gl_snapshot(self, "si_multi_currency", "Sales Invoice", si.name) + + def test_si_return(self): + original = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100) + credit_note = make_sales_return(original.name) + credit_note.set_posting_time = 1 + credit_note.posting_date = POSTING_DATE + credit_note.insert() + credit_note.submit() + assert_gl_snapshot(self, "si_return", "Sales Invoice", credit_note.name) + + def test_si_round_off(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=1, rate=100, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 6.5, + }, + ) + si.insert() + si.submit() + assert_gl_snapshot(self, "si_round_off", "Sales Invoice", si.name) + + def test_si_with_discount_accounting(self): + with change_settings("Selling Settings", {"enable_discount_accounting": 1}): + discount_account = create_account( + account_name="Discount Account", + parent_account="Indirect Expenses - _TC", + company=COMPANY, + ) + si = create_sales_invoice( + posting_date=POSTING_DATE, qty=1, rate=90, discount_account=discount_account + ) + assert_gl_snapshot(self, "si_with_discount", "Sales Invoice", si.name) + + def test_si_with_advance(self): + advance = frappe.get_doc( + { + "doctype": "Payment Entry", + "payment_type": "Receive", + "party_type": "Customer", + "party": CUSTOMER, + "company": COMPANY, + "posting_date": POSTING_DATE, + "paid_from": "Debtors - _TC", + "paid_to": "_Test Cash - _TC", + "paid_from_account_currency": "INR", + "paid_to_account_currency": "INR", + "source_exchange_rate": 1, + "target_exchange_rate": 1, + "reference_no": "ADV-1", + "reference_date": POSTING_DATE, + "paid_amount": 500, + "received_amount": 500, + } + ) + advance.insert() + advance.submit() + + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100, do_not_save=True) + si.allocate_advances_automatically = 1 + si.insert() + si.submit() + assert_gl_snapshot(self, "si_with_advance", "Sales Invoice", si.name) + + def test_si_pos(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100, do_not_save=True) + si.is_pos = 1 + si.append("payments", {"mode_of_payment": "Cash", "amount": 500}) + si.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500}) + si.insert() + si.submit() + assert_gl_snapshot(self, "si_pos", "Sales Invoice", si.name) + + def test_pi_basic(self): + pi = make_dated_purchase_invoice(qty=5, rate=50) + pi.insert() + pi.submit() + assert_gl_snapshot(self, "pi_basic", "Purchase Invoice", pi.name) + + def test_pi_with_taxes(self): + pi = make_dated_purchase_invoice(qty=5, rate=50) + pi.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "rate": 15, + }, + ) + pi.insert() + pi.submit() + assert_gl_snapshot(self, "pi_with_taxes", "Purchase Invoice", pi.name) + + def test_pi_multi_currency(self): + pi = make_dated_purchase_invoice(qty=5, rate=50, currency="USD", conversion_rate=75) + pi.insert() + pi.submit() + assert_gl_snapshot(self, "pi_multi_currency", "Purchase Invoice", pi.name) + + def test_pi_return(self): + original = make_dated_purchase_invoice(qty=5, rate=50) + original.insert() + original.submit() + debit_note = make_debit_note(original.name) + debit_note.set_posting_time = 1 + debit_note.posting_date = POSTING_DATE + debit_note.insert() + debit_note.submit() + assert_gl_snapshot(self, "pi_return", "Purchase Invoice", debit_note.name) From 234c4a45b8d6990deaaa7140b22c83ae56a02574 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 01:01:20 +0530 Subject: [PATCH 03/65] refactor: extract list-level GL validations into gl_validator service Phase 1 of the accounts/controller refactor. Moves the six pure list-level validators (validate_disabled_accounts, validate_accounting_period, validate_cwip_accounts, check_freezing_date, validate_against_pcv, validate_allowed_dimensions) out of general_ledger.py into the new erpnext/accounts/services/gl_validator.py. general_ledger.py imports and calls them at the existing sites; no behavior change (Phase 0 GL snapshots remain byte-identical). The debit/credit balance trio stays in general_ledger.py for now since get_debit_credit_difference mutates entries and is interleaved with the round-off repair. --- erpnext/accounts/general_ledger.py | 171 +---------------- erpnext/accounts/services/__init__.py | 0 erpnext/accounts/services/gl_validator.py | 176 ++++++++++++++++++ .../repost_item_valuation.py | 2 +- specs/accounts_refactor_spec.md | 7 +- 5 files changed, 190 insertions(+), 166 deletions(-) create mode 100644 erpnext/accounts/services/__init__.py create mode 100644 erpnext/accounts/services/gl_validator.py diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 9effa1a09c5..2c793765946 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -7,7 +7,7 @@ import copy import frappe from frappe import _ from frappe.model.meta import get_field_precision -from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now +from frappe.utils import cint, flt, get_link_to_form, getdate, now from frappe.utils.caching import request_cache import erpnext @@ -18,11 +18,17 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( get_dimension_filter_map, ) -from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget +from erpnext.accounts.services.gl_validator import ( + check_freezing_date, + validate_accounting_period, + validate_against_pcv, + validate_allowed_dimensions, + validate_cwip_accounts, + validate_disabled_accounts, +) from erpnext.accounts.utils import create_payment_ledger_entry, is_immutable_ledger_enabled from erpnext.controllers.budget_controller import BudgetValidation -from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError def make_gl_entries( @@ -132,60 +138,6 @@ def get_accounting_dimensions_for_offsetting_entry(gl_map, company): return accounting_dimensions_to_offset -def validate_disabled_accounts(gl_map): - accounts = [d.account for d in gl_map if d.account] - - disabled_accounts = frappe.get_all( - "Account", - filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company}, - fields=["name"], - ) - - used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts])) - if used_disabled_accounts: - account_list = "
" - account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts]) - frappe.throw( - _("Cannot create accounting entries against disabled accounts: {0}").format(account_list), - title=_("Disabled Account Selected"), - ) - - -def validate_accounting_period(gl_map): - accounting_periods = frappe.db.sql( - """ SELECT - ap.name as name, ap.exempted_role as exempted_role - FROM - `tabAccounting Period` ap, `tabClosed Document` cd - WHERE - ap.name = cd.parent - AND ap.company = %(company)s - AND ap.disabled = 0 - AND cd.closed = 1 - AND cd.document_type = %(voucher_type)s - AND %(date)s between ap.start_date and ap.end_date - """, - { - "date": gl_map[0].posting_date, - "company": gl_map[0].company, - "voucher_type": gl_map[0].voucher_type, - }, - as_dict=1, - ) - - if accounting_periods: - if accounting_periods[0].exempted_role: - exempted_roles = accounting_periods[0].exempted_role - if exempted_roles in frappe.get_roles(): - return - frappe.throw( - _( - "You cannot create or cancel any accounting entries with in the closed Accounting Period {0}" - ).format(frappe.bold(accounting_periods[0].name)), - ClosedAccountingPeriod, - ) - - def process_gl_map(gl_map, merge_entries=True, precision=None, from_repost=False): if not gl_map: return [] @@ -442,33 +394,6 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False): validate_expense_against_budget(args) -def validate_cwip_accounts(gl_map): - """Validate that CWIP account are not used in Journal Entry""" - if gl_map and gl_map[0].voucher_type != "Journal Entry": - return - - cwip_enabled = any( - cint(ac.enable_cwip_accounting) - for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting") - ) - if cwip_enabled: - cwip_accounts = [ - d[0] - for d in frappe.db.sql( - """select name from tabAccount - where account_type = 'Capital Work in Progress' and is_group=0""" - ) - ] - - for entry in gl_map: - if entry.account in cwip_accounts: - frappe.throw( - _( - "Account: {0} is capital Work in progress and can not be updated by Journal Entry" - ).format(entry.account) - ) - - def process_debit_credit_difference(gl_map): precision = get_field_precision( frappe.get_meta("GL Entry").get_field("debit"), @@ -796,48 +721,6 @@ def make_reverse_gl_entries( make_entry(new_gle, adv_adj, "Yes") -def check_freezing_date(posting_date, company, adv_adj=False): - """ - Nobody can do GL Entries where posting date is before freezing date - except authorized person - - Administrator has all the roles so this check will be bypassed if any role is allowed to post - Hence stop admin to bypass if accounts are freezed - """ - if not adv_adj: - acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date") - if acc_frozen_till_date: - frozen_accounts_modifier = frappe.db.get_value( - "Company", company, "role_allowed_for_frozen_entries" - ) - if getdate(posting_date) <= getdate(acc_frozen_till_date) and ( - frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator" - ): - frappe.throw( - _("You are not authorized to add or update entries before {0}").format( - formatdate(acc_frozen_till_date) - ) - ) - - -def validate_against_pcv(is_opening, posting_date, company): - if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}): - frappe.throw( - _("Opening Entry can not be created after Period Closing Voucher is created."), - title=_("Invalid Opening Entry"), - ) - - last_pcv_date = frappe.db.get_value( - "Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}] - ) - - if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date): - message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date)) - message += "
" - message += _("You cannot create/amend any accounting entries till this date.") - frappe.throw(message, title=_("Period Closed")) - - def set_as_cancel(voucher_type, voucher_no): """ Set is_cancelled=1 in all original gl entries for the voucher @@ -848,39 +731,3 @@ def set_as_cancel(voucher_type, voucher_no): where voucher_type=%s and voucher_no=%s and is_cancelled = 0""", (now(), frappe.session.user, voucher_type, voucher_no), ) - - -def validate_allowed_dimensions(gl_entry, dimension_filter_map): - for key, value in dimension_filter_map.items(): - dimension = key[0] - account = key[1] - - if gl_entry.account == account: - if value["is_mandatory"] and not gl_entry.get(dimension): - frappe.throw( - _("{0} is mandatory for account {1}").format( - frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account) - ), - MandatoryAccountDimensionError, - ) - - if value["allow_or_restrict"] == "Allow": - if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]: - frappe.throw( - _("Invalid value {0} for {1} against account {2}").format( - frappe.bold(gl_entry.get(dimension)), - frappe.bold(frappe.unscrub(dimension)), - frappe.bold(gl_entry.account), - ), - InvalidAccountDimensionError, - ) - else: - if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]: - frappe.throw( - _("Invalid value {0} for {1} against account {2}").format( - frappe.bold(gl_entry.get(dimension)), - frappe.bold(frappe.unscrub(dimension)), - frappe.bold(gl_entry.account), - ), - InvalidAccountDimensionError, - ) diff --git a/erpnext/accounts/services/__init__.py b/erpnext/accounts/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/services/gl_validator.py b/erpnext/accounts/services/gl_validator.py new file mode 100644 index 00000000000..e29b4d42103 --- /dev/null +++ b/erpnext/accounts/services/gl_validator.py @@ -0,0 +1,176 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""List-level validations for a GL map. + +These functions assert that an assembled list of GL entries is legal to post — +no disabled accounts, the period/freeze/PCV gates pass, dimensions are allowed. +They do not mutate or repair the entries; balancing and round-off live with the +posting sink in ``erpnext.accounts.general_ledger``. +""" + +import frappe +from frappe import _ +from frappe.utils import cint, formatdate, getdate + +from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod +from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError + + +def validate_disabled_accounts(gl_map): + accounts = [d.account for d in gl_map if d.account] + + disabled_accounts = frappe.get_all( + "Account", + filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company}, + fields=["name"], + ) + + used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts])) + if used_disabled_accounts: + account_list = "
" + account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts]) + frappe.throw( + _("Cannot create accounting entries against disabled accounts: {0}").format(account_list), + title=_("Disabled Account Selected"), + ) + + +def validate_accounting_period(gl_map): + accounting_periods = frappe.db.sql( + """ SELECT + ap.name as name, ap.exempted_role as exempted_role + FROM + `tabAccounting Period` ap, `tabClosed Document` cd + WHERE + ap.name = cd.parent + AND ap.company = %(company)s + AND ap.disabled = 0 + AND cd.closed = 1 + AND cd.document_type = %(voucher_type)s + AND %(date)s between ap.start_date and ap.end_date + """, + { + "date": gl_map[0].posting_date, + "company": gl_map[0].company, + "voucher_type": gl_map[0].voucher_type, + }, + as_dict=1, + ) + + if accounting_periods: + if accounting_periods[0].exempted_role: + exempted_roles = accounting_periods[0].exempted_role + if exempted_roles in frappe.get_roles(): + return + frappe.throw( + _( + "You cannot create or cancel any accounting entries with in the closed Accounting Period {0}" + ).format(frappe.bold(accounting_periods[0].name)), + ClosedAccountingPeriod, + ) + + +def validate_cwip_accounts(gl_map): + """Validate that CWIP account are not used in Journal Entry""" + if gl_map and gl_map[0].voucher_type != "Journal Entry": + return + + cwip_enabled = any( + cint(ac.enable_cwip_accounting) + for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting") + ) + if cwip_enabled: + cwip_accounts = [ + d[0] + for d in frappe.db.sql( + """select name from tabAccount + where account_type = 'Capital Work in Progress' and is_group=0""" + ) + ] + + for entry in gl_map: + if entry.account in cwip_accounts: + frappe.throw( + _( + "Account: {0} is capital Work in progress and can not be updated by Journal Entry" + ).format(entry.account) + ) + + +def check_freezing_date(posting_date, company, adv_adj=False): + """ + Nobody can do GL Entries where posting date is before freezing date + except authorized person + + Administrator has all the roles so this check will be bypassed if any role is allowed to post + Hence stop admin to bypass if accounts are freezed + """ + if not adv_adj: + acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date") + if acc_frozen_till_date: + frozen_accounts_modifier = frappe.db.get_value( + "Company", company, "role_allowed_for_frozen_entries" + ) + if getdate(posting_date) <= getdate(acc_frozen_till_date) and ( + frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator" + ): + frappe.throw( + _("You are not authorized to add or update entries before {0}").format( + formatdate(acc_frozen_till_date) + ) + ) + + +def validate_against_pcv(is_opening, posting_date, company): + if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}): + frappe.throw( + _("Opening Entry can not be created after Period Closing Voucher is created."), + title=_("Invalid Opening Entry"), + ) + + last_pcv_date = frappe.db.get_value( + "Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}] + ) + + if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date): + message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date)) + message += "
" + message += _("You cannot create/amend any accounting entries till this date.") + frappe.throw(message, title=_("Period Closed")) + + +def validate_allowed_dimensions(gl_entry, dimension_filter_map): + for key, value in dimension_filter_map.items(): + dimension = key[0] + account = key[1] + + if gl_entry.account == account: + if value["is_mandatory"] and not gl_entry.get(dimension): + frappe.throw( + _("{0} is mandatory for account {1}").format( + frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account) + ), + MandatoryAccountDimensionError, + ) + + if value["allow_or_restrict"] == "Allow": + if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(gl_entry.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(gl_entry.account), + ), + InvalidAccountDimensionError, + ) + else: + if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(gl_entry.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(gl_entry.account), + ), + InvalidAccountDimensionError, + ) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 6a8d3cc7ffa..1828528866f 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -15,7 +15,7 @@ from frappe.utils.user import get_users_with_role from rq.timeouts import JobTimeoutException import erpnext -from erpnext.accounts.general_ledger import validate_accounting_period +from erpnext.accounts.services.gl_validator import validate_accounting_period from erpnext.accounts.utils import get_future_stock_vouchers, repost_gle_for_stock_vouchers from erpnext.stock.stock_ledger import ( get_affected_transactions, diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 5215c714b01..099a4119933 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -56,7 +56,8 @@ SalesInvoiceGLComposer.compose() → gl_entries → gl_validator.validate(gl ## Bucketing `accounts_controller.py` - **Base composer (`BaseGLComposer`):** `get_gl_dict`, `get_value_in_transaction_currency`, `make_discount_gl_entries` (+ `get_amount_and_base_amount`, `get_tax_amounts`), `make_precision_loss_gl_entry`, `make_exchange_gain_loss_journal` (+ `gain_loss_journal_already_booked`), `set_transaction_currency_and_rate_in_gl_map`. Regional hooks `update_gl_dict_with_regional_fields` / `..._app_based_fields` stay free functions called inside `get_gl_dict`. - **Advances service:** `set_advances`, `get_advance_entries`, `clear_unallocated_advances`, `validate_advance_entries`, `set_advance_gain_or_loss`, `calculate_total_advance_from_ledger`, `set_total_advance_paid`, `set_advance_payment_status`, `delink_advance_entries`, `create_advance_and_reconcile`, `get_advance_payment_doctypes`, `_remove_advance_payment_ledger_entries`, module funcs `get_advance_journal_entries` / `get_advance_payment_entries`. -- **Validator (from `general_ledger.py`):** `validate_disabled_accounts`, `validate_accounting_period`, `validate_cwip_accounts`, `check_freezing_date`, `validate_against_pcv`, `validate_allowed_dimensions`, balance assertion (`get_debit_credit_difference` / `get_debit_credit_allowance` / `raise_debit_credit_not_equal_error`). +- **Validator (from `general_ledger.py`):** `validate_disabled_accounts`, `validate_accounting_period`, `validate_cwip_accounts`, `check_freezing_date`, `validate_against_pcv`, `validate_allowed_dimensions`. (Moved in Phase 1.) + - **Balance trio stays in `general_ledger.py` for now** (revised during Phase 1): `get_debit_credit_difference` / `get_debit_credit_allowance` / `raise_debit_credit_not_equal_error`. `get_debit_credit_difference` *mutates* entries (rounds debit/credit in place) and the trio is interleaved with `process_debit_credit_difference` → `make_round_off_gle` (the round-off *repair* run before and after balancing). It is not a standalone pre-post gate, so it can't move into a pure `validate(gl_entries)` without changing behavior. It travels with round-off when that moves compose-side (see below). - **Stays in compose (do NOT move to validator):** `process_debit_credit_difference` / `make_round_off_gle` — these *repair* balance by appending a round-off entry (mutation), not validation. - **Stays in composer (not validator):** row-level checks (right account for a row, dimension applicability) — validator only validates the finished list. - **Leave in controller:** `validate_company_in_accounting_dimension`, `validate_company` (dimension validation, not GL). @@ -67,8 +68,8 @@ Each phase is behavior-preserving, one draft PR, gated by the Phase-0 snapshot s ### Phase 0 — Safety net (first, mandatory) Characterization tests snapshotting `gl_entries` output for representative transactions (SI/PI with taxes, multi-currency, advances, discounts, round-off, POS). Every later phase passes iff snapshots are byte-identical. -### Phase 1 — Extract `gl_validator.py` (lowest risk) -Move list-level validations out of `general_ledger.py`; `make_gl_entries` calls `gl_validator.validate(gl_entries)`. Near-pure move; proves the safety net. +### Phase 1 — Extract `gl_validator.py` (lowest risk) — DONE +Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validator.py`; `general_ledger.py` imports and calls them at the existing call sites (no behavior change). A consolidated `gl_validator.validate(gl_entries)` facade is deferred — the current checks run at different points (make_gl_entries / save_entries per-entry / make_reverse_gl_entries), so collapsing them into one call would alter ordering. Verified: all 12 Phase-0 snapshots byte-identical. ### Phase 2 — Pilot composer on Sales Invoice only Create `BaseGLComposer` + `SalesInvoiceGLComposer`; lift bucket-A helpers from `accounts_controller`; move SI's `get_gl_entries` body into `.compose()`; old method becomes a thin shim. Do not over-generalise the base from one example. From 3ec63874256a05ebff21898319d86230347bd317 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 01:01:43 +0530 Subject: [PATCH 04/65] fix: honor account freezing date when cancelling vouchers make_reverse_gl_entries passed adv_adj as the company argument to check_freezing_date, so the freeze-date check silently no-op'd on cancellation (no company matched). Pass company explicitly so cancellations respect the freezing date like submissions do. Adds a regression test covering cancellation after the freeze date. --- .../accounts/doctype/sales_invoice/test_sales_invoice.py | 8 ++++++++ erpnext/accounts/general_ledger.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index b2e4ea875d0..3a1ab35db7e 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3493,6 +3493,14 @@ class TestSalesInvoice(ERPNextTestSuite): si.submit() frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None) + def test_sales_invoice_cancellation_post_account_freezing_date(self): + si = create_sales_invoice() + frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1)) + try: + self.assertRaises(frappe.ValidationError, si.cancel) + finally: + frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None) + @ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0}) @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_over_billing_case_against_delivery_note(self): diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 2c793765946..0c535ff6b4b 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -640,7 +640,7 @@ def make_reverse_gl_entries( partial_cancel=partial_cancel, ) validate_accounting_period(gl_entries) - check_freezing_date(gl_entries[0]["posting_date"], adv_adj) + check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj) is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries) From cf1817c1ea4cf778b9dec15dc53fc7cbc5382c11 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 01:12:11 +0530 Subject: [PATCH 05/65] refactor: introduce GL composer and delegate SI get_gl_entries Phase 2 (pilot) of the accounts/controller refactor. Adds BaseGLComposer and SalesInvoiceGLComposer; Sales Invoice's get_gl_entries body moves into compose() and the method becomes a thin shim. Row-builder methods still live on the document (invoked via self.doc) and migrate onto the composer next. No behavior change (Phase 0 GL snapshots remain byte-identical). --- .../doctype/sales_invoice/sales_invoice.py | 33 +----------- .../sales_invoice/services/__init__.py | 0 .../sales_invoice/services/gl_composer.py | 51 +++++++++++++++++++ erpnext/accounts/services/base_gl_composer.py | 19 +++++++ 4 files changed, 72 insertions(+), 31 deletions(-) create mode 100644 erpnext/accounts/doctype/sales_invoice/services/__init__.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/gl_composer.py create mode 100644 erpnext/accounts/services/base_gl_composer.py diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 86f84be0973..9495fd8202e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1576,38 +1576,9 @@ class SalesInvoice(SellingController): make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) def get_gl_entries(self, inventory_account_map=None): - from erpnext.accounts.general_ledger import merge_similar_entries + from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer - gl_entries = [] - - self.make_customer_gl_entry(gl_entries) - - self.make_tax_gl_entries(gl_entries) - self.make_internal_transfer_gl_entries(gl_entries) - - self.make_item_gl_entries(gl_entries) - - disable_sdbnb_in_sr = frappe.get_cached_value("Company", self.company, "disable_sdbnb_in_sr") - - if not (self.is_return and disable_sdbnb_in_sr): - self.stock_delivered_but_not_billed_gl_entries(gl_entries) - - self.make_precision_loss_gl_entry(gl_entries) - self.make_discount_gl_entries(gl_entries) - - gl_entries = make_regional_gl_entries(gl_entries, self) - - # merge gl entries before adding pos entries - gl_entries = merge_similar_entries(gl_entries) - - self.make_loyalty_point_redemption_gle(gl_entries) - self.make_pos_gl_entries(gl_entries) - - self.make_write_off_gl_entry(gl_entries) - self.make_gle_for_rounding_adjustment(gl_entries) - - self.set_transaction_currency_and_rate_in_gl_map(gl_entries) - return gl_entries + return SalesInvoiceGLComposer(self).compose(inventory_account_map) def stock_delivered_but_not_billed_gl_entries(self, gl_entries): if self.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(self.company)): diff --git a/erpnext/accounts/doctype/sales_invoice/services/__init__.py b/erpnext/accounts/doctype/sales_invoice/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py new file mode 100644 index 00000000000..df6e58a6ee7 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -0,0 +1,51 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + +from erpnext.accounts.services.base_gl_composer import BaseGLComposer + + +class SalesInvoiceGLComposer(BaseGLComposer): + """Assembles the GL entries for a Sales Invoice. + + Orchestration only for now: the voucher-specific row builders still live on + the Sales Invoice document and are invoked via ``self.doc``. They migrate + onto this composer in a later increment. + """ + + def compose(self, inventory_account_map=None): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_regional_gl_entries + from erpnext.accounts.general_ledger import merge_similar_entries + + doc = self.doc + gl_entries = [] + + doc.make_customer_gl_entry(gl_entries) + + doc.make_tax_gl_entries(gl_entries) + doc.make_internal_transfer_gl_entries(gl_entries) + + doc.make_item_gl_entries(gl_entries) + + disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr") + + if not (doc.is_return and disable_sdbnb_in_sr): + doc.stock_delivered_but_not_billed_gl_entries(gl_entries) + + doc.make_precision_loss_gl_entry(gl_entries) + doc.make_discount_gl_entries(gl_entries) + + gl_entries = make_regional_gl_entries(gl_entries, doc) + + # merge gl entries before adding pos entries + gl_entries = merge_similar_entries(gl_entries) + + doc.make_loyalty_point_redemption_gle(gl_entries) + doc.make_pos_gl_entries(gl_entries) + + doc.make_write_off_gl_entry(gl_entries) + doc.make_gle_for_rounding_adjustment(gl_entries) + + doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) + return gl_entries diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py new file mode 100644 index 00000000000..bbe2474297e --- /dev/null +++ b/erpnext/accounts/services/base_gl_composer.py @@ -0,0 +1,19 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Base class for per-document GL entry composers. + +A composer assembles the list of GL entry dicts for a single voucher. Unlike +the posting sink (``general_ledger.make_gl_entries``) and the stateless +validators (``gl_validator``), composing is stateful and per-document, so it is +modelled as a class holding the document being composed. Subclasses implement +``compose`` to return the voucher-specific list of GL entries. +""" + + +class BaseGLComposer: + def __init__(self, doc): + self.doc = doc + + def compose(self): + raise NotImplementedError From b5c96dfef00c460cb89550a031fb8f56ae316d1a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 01:24:56 +0530 Subject: [PATCH 06/65] refactor: move Sales Invoice GL row builders onto the composer Relocates all 11 Sales Invoice-specific GL entry builders from the document onto SalesInvoiceGLComposer, operating on self.doc. The perpetual-inventory super().get_gl_entries() call becomes super(SalesInvoice, doc).get_gl_entries(). Shared bucket-A helpers (get_gl_dict, make_discount_gl_entries, etc.) remain on AccountsController for now, invoked via self.doc, until all doctypes use a composer. No behavior change: Phase 0 snapshots and the SI tests covering perpetual inventory, POS, write-off, returns, fixed assets, internal transfer and loyalty all stay green. --- .../doctype/sales_invoice/sales_invoice.py | 484 ---------------- .../sales_invoice/services/gl_composer.py | 531 +++++++++++++++++- specs/accounts_refactor_spec.md | 4 +- 3 files changed, 521 insertions(+), 498 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 9495fd8202e..58f612a5d64 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1580,250 +1580,6 @@ class SalesInvoice(SellingController): return SalesInvoiceGLComposer(self).compose(inventory_account_map) - def stock_delivered_but_not_billed_gl_entries(self, gl_entries): - if self.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(self.company)): - return - - for item in self.get("items"): - if not item.delivery_note and not item.dn_detail: - continue - - if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"): - continue - - dn_expense_account = frappe.get_cached_value( - "Delivery Note Item", item.dn_detail, "expense_account" - ) - if ( - not dn_expense_account - or frappe.get_cached_value("Account", dn_expense_account, "account_type") - != "Stock Delivered But Not Billed" - or not item.expense_account - or dn_expense_account == item.expense_account - ): - continue - - delivery_note = item.delivery_note or frappe.get_cached_value( - "Delivery Note Item", item.dn_detail, "parent" - ) - if not delivery_note: - continue - - item_g = frappe.get_cached_value( - "Stock Ledger Entry", - { - "voucher_no": delivery_note, - "voucher_detail_no": item.dn_detail, - "item_code": item.item_code, - "is_cancelled": 0, - }, - ["stock_value_difference", "actual_qty"], - as_dict=True, - ) - - if not item_g or not flt(item_g.actual_qty): - continue - valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty) - valuation_amount = valuation_rate * item.stock_qty - dn_account_currency = get_account_currency(dn_expense_account) - item_account_currency = get_account_currency(item.expense_account) - - gl_entries.append( - self.get_gl_dict( - { - "account": dn_expense_account, - "against": item.expense_account, - "credit": flt(valuation_amount), - "credit_in_account_currency": flt(valuation_amount), - "cost_center": item.cost_center, - }, - dn_account_currency, - item=item, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account, - "against": dn_expense_account, - "debit": flt(valuation_amount), - "debit_in_account_currency": flt(valuation_amount), - "cost_center": item.cost_center, - }, - item_account_currency, - item=item, - ) - ) - - def make_customer_gl_entry(self, gl_entries): - # Checked both rounding_adjustment and rounded_total - # because rounded_total had value even before introduction of posting GLE based on rounded total - grand_total = ( - self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total - ) - base_grand_total = flt( - self.base_rounded_total - if (self.base_rounding_adjustment and self.base_rounded_total) - else self.base_grand_total, - self.precision("base_grand_total"), - ) - - if grand_total and not self.is_internal_transfer(): - against_voucher = self.name - if self.is_return and self.return_against and not self.update_outstanding_for_self: - against_voucher = self.return_against - - # Did not use base_grand_total to book rounding loss gle - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "due_date": self.due_date, - "against": self.against_income_account, - "debit": base_grand_total, - "debit_in_account_currency": base_grand_total - if self.party_account_currency == self.company_currency - else grand_total, - "debit_in_transaction_currency": grand_total, - "against_voucher": against_voucher, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) - - def make_tax_gl_entries(self, gl_entries): - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) - - for tax in self.get("taxes"): - amount, base_amount = self.get_tax_amounts(tax, enable_discount_accounting) - - if flt(tax.base_tax_amount_after_discount_amount): - account_currency = get_account_currency(tax.account_head) - gl_entries.append( - self.get_gl_dict( - { - "account": tax.account_head, - "against": self.customer, - "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")), - "credit_in_account_currency": ( - flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")) - if account_currency == self.company_currency - else flt(amount, tax.precision("tax_amount_after_discount_amount")) - ), - "credit_in_transaction_currency": flt( - amount, tax.precision("tax_amount_after_discount_amount") - ), - "cost_center": tax.cost_center, - }, - account_currency, - item=tax, - ) - ) - - def make_internal_transfer_gl_entries(self, gl_entries): - if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges): - account_currency = get_account_currency(self.unrealized_profit_loss_account) - gl_entries.append( - self.get_gl_dict( - { - "account": self.unrealized_profit_loss_account, - "against": self.customer, - "debit": flt(self.total_taxes_and_charges), - "debit_in_account_currency": flt(self.base_total_taxes_and_charges), - "debit_in_transaction_currency": flt(self.total_taxes_and_charges), - "cost_center": self.cost_center, - }, - account_currency, - item=self, - ) - ) - - def make_item_gl_entries(self, gl_entries): - # income account gl entries - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) - - for item in self.get("items"): - if ( - flt(item.base_net_amount, item.precision("base_net_amount")) - or item.is_fixed_asset - or enable_discount_accounting - ): - # Do not book income for transfer within same company - if self.is_internal_transfer(): - continue - - if item.is_fixed_asset and item.asset: - self.get_gl_entries_for_fixed_asset(item, gl_entries) - else: - income_account = ( - item.income_account - if (not item.enable_deferred_revenue or self.is_return) - else item.deferred_revenue_account - ) - - amount, base_amount = self.get_amount_and_base_amount(item, enable_discount_accounting) - - account_currency = get_account_currency(income_account) - gl_entries.append( - self.get_gl_dict( - { - "account": income_account, - "against": self.customer, - "credit": flt(base_amount, item.precision("base_net_amount")), - "credit_in_account_currency": ( - flt(base_amount, item.precision("base_net_amount")) - if account_currency == self.company_currency - else flt(amount, item.precision("net_amount")) - ), - "credit_in_transaction_currency": flt(amount, item.precision("net_amount")), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - # expense account gl entries - if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company): - gl_entries += super().get_gl_entries() - - def get_gl_entries_for_fixed_asset(self, item, gl_entries): - asset = frappe.get_cached_doc("Asset", item.asset) - - if self.is_return: - fixed_asset_gl_entries = get_gl_entries_on_asset_regain( - asset, - item.base_net_amount, - item.finance_book, - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - else: - fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( - asset, - item.base_net_amount, - item.finance_book, - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - - for gle in fixed_asset_gl_entries: - gle["against"] = self.customer - gl_entries.append(self.get_gl_dict(gle, item=item)) - @property def enable_discount_accounting(self): if not hasattr(self, "_enable_discount_accounting"): @@ -1833,246 +1589,6 @@ class SalesInvoice(SellingController): return self._enable_discount_accounting - def make_loyalty_point_redemption_gle(self, gl_entries): - if cint(self.redeem_loyalty_points and self.loyalty_points and not self.is_consolidated): - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": "Expense account - " - + cstr(self.loyalty_redemption_account) - + " for the Loyalty Program", - "credit": self.loyalty_amount, - "credit_in_transaction_currency": self.loyalty_amount, - "against_voucher": self.return_against if cint(self.is_return) else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - }, - item=self, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": self.loyalty_redemption_account, - "cost_center": self.cost_center or self.loyalty_redemption_cost_center, - "against": self.customer, - "debit": self.loyalty_amount, - "debit_in_transaction_currency": self.loyalty_amount, - "remark": "Loyalty Points redeemed by the customer", - }, - item=self, - ) - ) - - def make_pos_gl_entries(self, gl_entries): - if cint(self.is_pos): - skip_change_gl_entries = not cint( - frappe.get_single_value("POS Settings", "post_change_gl_entries") - ) - - for payment_mode in self.payments: - if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount: - payment_mode.base_amount -= flt(self.change_amount) - - against_voucher = self.name - if self.is_return and self.return_against and not self.update_outstanding_for_self: - against_voucher = self.return_against - - if payment_mode.base_amount: - # POS, make payment entries - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": payment_mode.account, - "credit": payment_mode.base_amount, - "credit_in_account_currency": payment_mode.base_amount - if self.party_account_currency == self.company_currency - else payment_mode.amount, - "credit_in_transaction_currency": payment_mode.amount, - "against_voucher": against_voucher, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - }, - self.party_account_currency, - item=self, - ) - ) - - payment_mode_account_currency = get_account_currency(payment_mode.account) - gl_entries.append( - self.get_gl_dict( - { - "account": payment_mode.account, - "against": self.customer, - "debit": payment_mode.base_amount, - "debit_in_account_currency": payment_mode.base_amount - if payment_mode_account_currency == self.company_currency - else payment_mode.amount, - "debit_in_transaction_currency": payment_mode.amount, - "cost_center": self.cost_center, - }, - payment_mode_account_currency, - item=self, - ) - ) - - if not skip_change_gl_entries: - gl_entries.extend(self.get_gle_for_change_amount()) - - def get_gle_for_change_amount(self) -> list[dict]: - if not self.change_amount: - return [] - - if not self.account_for_change_amount: - frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field")) - - return [ - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": self.account_for_change_amount, - "debit": flt(self.base_change_amount), - "debit_in_account_currency": flt(self.base_change_amount) - if self.party_account_currency == self.company_currency - else flt(self.change_amount), - "debit_in_transaction_currency": flt(self.change_amount), - "against_voucher": self.return_against - if cint(self.is_return) and self.return_against - else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ), - self.get_gl_dict( - { - "account": self.account_for_change_amount, - "against": self.customer, - "credit": self.base_change_amount, - "credit_in_transaction_currency": self.change_amount, - "cost_center": self.cost_center, - }, - item=self, - ), - ] - - def make_write_off_gl_entry(self, gl_entries): - # write off entries, applicable if only pos - if ( - self.is_pos - and self.write_off_account - and flt(self.write_off_amount, self.precision("write_off_amount")) - ): - write_off_account_currency = get_account_currency(self.write_off_account) - default_cost_center = frappe.get_cached_value("Company", self.company, "cost_center") - - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": self.write_off_account, - "credit": flt(self.base_write_off_amount, self.precision("base_write_off_amount")), - "credit_in_account_currency": ( - flt(self.base_write_off_amount, self.precision("base_write_off_amount")) - if self.party_account_currency == self.company_currency - else flt(self.write_off_amount, self.precision("write_off_amount")) - ), - "credit_in_transaction_currency": flt( - self.write_off_amount, self.precision("write_off_amount") - ), - "against_voucher": self.return_against if cint(self.is_return) else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": self.write_off_account, - "against": self.customer, - "debit": flt(self.base_write_off_amount, self.precision("base_write_off_amount")), - "debit_in_account_currency": ( - flt(self.base_write_off_amount, self.precision("base_write_off_amount")) - if write_off_account_currency == self.company_currency - else flt(self.write_off_amount, self.precision("write_off_amount")) - ), - "debit_in_transaction_currency": flt( - self.write_off_amount, self.precision("write_off_amount") - ), - "cost_center": self.cost_center or self.write_off_cost_center or default_cost_center, - }, - write_off_account_currency, - item=self, - ) - ) - - def make_gle_for_rounding_adjustment(self, gl_entries): - if ( - flt(self.rounding_adjustment, self.precision("rounding_adjustment")) - and self.base_rounding_adjustment - and not self.is_internal_transfer() - ): - ( - round_off_account, - round_off_cost_center, - round_off_for_opening, - ) = get_round_off_account_and_cost_center( - self.company, "Sales Invoice", self.name, self.use_company_roundoff_cost_center - ) - - if self.is_opening == "Yes" and self.rounding_adjustment: - if not round_off_for_opening: - frappe.throw( - _( - "Opening Invoice has rounding adjustment of {0}.

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." - ).format( - frappe.bold(self.rounding_adjustment), - frappe.bold("Round Off for Opening"), - get_link_to_form("Company", self.company), - frappe.bold("Disable Rounded Total"), - ) - ) - else: - round_off_account = round_off_for_opening - - gl_entries.append( - self.get_gl_dict( - { - "account": round_off_account, - "against": self.customer, - "credit_in_account_currency": flt( - self.rounding_adjustment, self.precision("rounding_adjustment") - ), - "credit_in_transaction_currency": flt( - self.rounding_adjustment, self.precision("rounding_adjustment") - ), - "credit": flt( - self.base_rounding_adjustment, self.precision("base_rounding_adjustment") - ), - "cost_center": round_off_cost_center - if self.use_company_roundoff_cost_center - else (self.cost_center or round_off_cost_center), - }, - item=self, - ) - ) - def update_billing_status_in_dn(self, update_modified=True): if self.is_return and not self.update_billed_amount_in_delivery_note: return diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index df6e58a6ee7..21c00d28da2 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -2,16 +2,26 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, get_link_to_form +import erpnext +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.utils import get_account_currency +from erpnext.assets.doctype.asset.depreciation import ( + get_gl_entries_on_asset_disposal, + get_gl_entries_on_asset_regain, +) class SalesInvoiceGLComposer(BaseGLComposer): """Assembles the GL entries for a Sales Invoice. - Orchestration only for now: the voucher-specific row builders still live on - the Sales Invoice document and are invoked via ``self.doc``. They migrate - onto this composer in a later increment. + The voucher-specific row builders live here and operate on ``self.doc``. + Shared helpers (get_gl_dict, make_discount_gl_entries, make_precision_loss_gl_entry, + set_transaction_currency_and_rate_in_gl_map, get_tax_amounts, get_amount_and_base_amount) + remain on the document for now and are invoked via ``self.doc``. """ def compose(self, inventory_account_map=None): @@ -21,17 +31,17 @@ class SalesInvoiceGLComposer(BaseGLComposer): doc = self.doc gl_entries = [] - doc.make_customer_gl_entry(gl_entries) + self.make_customer_gl_entry(gl_entries) - doc.make_tax_gl_entries(gl_entries) - doc.make_internal_transfer_gl_entries(gl_entries) + self.make_tax_gl_entries(gl_entries) + self.make_internal_transfer_gl_entries(gl_entries) - doc.make_item_gl_entries(gl_entries) + self.make_item_gl_entries(gl_entries) disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr") if not (doc.is_return and disable_sdbnb_in_sr): - doc.stock_delivered_but_not_billed_gl_entries(gl_entries) + self.stock_delivered_but_not_billed_gl_entries(gl_entries) doc.make_precision_loss_gl_entry(gl_entries) doc.make_discount_gl_entries(gl_entries) @@ -41,11 +51,508 @@ class SalesInvoiceGLComposer(BaseGLComposer): # merge gl entries before adding pos entries gl_entries = merge_similar_entries(gl_entries) - doc.make_loyalty_point_redemption_gle(gl_entries) - doc.make_pos_gl_entries(gl_entries) + self.make_loyalty_point_redemption_gle(gl_entries) + self.make_pos_gl_entries(gl_entries) - doc.make_write_off_gl_entry(gl_entries) - doc.make_gle_for_rounding_adjustment(gl_entries) + self.make_write_off_gl_entry(gl_entries) + self.make_gle_for_rounding_adjustment(gl_entries) doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) return gl_entries + + def stock_delivered_but_not_billed_gl_entries(self, gl_entries): + doc = self.doc + if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)): + return + + for item in doc.get("items"): + if not item.delivery_note and not item.dn_detail: + continue + + if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"): + continue + + dn_expense_account = frappe.get_cached_value( + "Delivery Note Item", item.dn_detail, "expense_account" + ) + if ( + not dn_expense_account + or frappe.get_cached_value("Account", dn_expense_account, "account_type") + != "Stock Delivered But Not Billed" + or not item.expense_account + or dn_expense_account == item.expense_account + ): + continue + + delivery_note = item.delivery_note or frappe.get_cached_value( + "Delivery Note Item", item.dn_detail, "parent" + ) + if not delivery_note: + continue + + item_g = frappe.get_cached_value( + "Stock Ledger Entry", + { + "voucher_no": delivery_note, + "voucher_detail_no": item.dn_detail, + "item_code": item.item_code, + "is_cancelled": 0, + }, + ["stock_value_difference", "actual_qty"], + as_dict=True, + ) + + if not item_g or not flt(item_g.actual_qty): + continue + valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty) + valuation_amount = valuation_rate * item.stock_qty + dn_account_currency = get_account_currency(dn_expense_account) + item_account_currency = get_account_currency(item.expense_account) + + gl_entries.append( + doc.get_gl_dict( + { + "account": dn_expense_account, + "against": item.expense_account, + "credit": flt(valuation_amount), + "credit_in_account_currency": flt(valuation_amount), + "cost_center": item.cost_center, + }, + dn_account_currency, + item=item, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": item.expense_account, + "against": dn_expense_account, + "debit": flt(valuation_amount), + "debit_in_account_currency": flt(valuation_amount), + "cost_center": item.cost_center, + }, + item_account_currency, + item=item, + ) + ) + + def make_customer_gl_entry(self, gl_entries): + doc = self.doc + # Checked both rounding_adjustment and rounded_total + # because rounded_total had value even before introduction of posting GLE based on rounded total + grand_total = ( + doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total + ) + base_grand_total = flt( + doc.base_rounded_total + if (doc.base_rounding_adjustment and doc.base_rounded_total) + else doc.base_grand_total, + doc.precision("base_grand_total"), + ) + + if grand_total and not doc.is_internal_transfer(): + against_voucher = doc.name + if doc.is_return and doc.return_against and not doc.update_outstanding_for_self: + against_voucher = doc.return_against + + # Did not use base_grand_total to book rounding loss gle + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "due_date": doc.due_date, + "against": doc.against_income_account, + "debit": base_grand_total, + "debit_in_account_currency": base_grand_total + if doc.party_account_currency == doc.company_currency + else grand_total, + "debit_in_transaction_currency": grand_total, + "against_voucher": against_voucher, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ) + ) + + def make_tax_gl_entries(self, gl_entries): + doc = self.doc + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + for tax in doc.get("taxes"): + amount, base_amount = doc.get_tax_amounts(tax, enable_discount_accounting) + + if flt(tax.base_tax_amount_after_discount_amount): + account_currency = get_account_currency(tax.account_head) + gl_entries.append( + doc.get_gl_dict( + { + "account": tax.account_head, + "against": doc.customer, + "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")), + "credit_in_account_currency": ( + flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")) + if account_currency == doc.company_currency + else flt(amount, tax.precision("tax_amount_after_discount_amount")) + ), + "credit_in_transaction_currency": flt( + amount, tax.precision("tax_amount_after_discount_amount") + ), + "cost_center": tax.cost_center, + }, + account_currency, + item=tax, + ) + ) + + def make_internal_transfer_gl_entries(self, gl_entries): + doc = self.doc + if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges): + account_currency = get_account_currency(doc.unrealized_profit_loss_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.unrealized_profit_loss_account, + "against": doc.customer, + "debit": flt(doc.total_taxes_and_charges), + "debit_in_account_currency": flt(doc.base_total_taxes_and_charges), + "debit_in_transaction_currency": flt(doc.total_taxes_and_charges), + "cost_center": doc.cost_center, + }, + account_currency, + item=doc, + ) + ) + + def make_item_gl_entries(self, gl_entries): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice + + doc = self.doc + # income account gl entries + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + for item in doc.get("items"): + if ( + flt(item.base_net_amount, item.precision("base_net_amount")) + or item.is_fixed_asset + or enable_discount_accounting + ): + # Do not book income for transfer within same company + if doc.is_internal_transfer(): + continue + + if item.is_fixed_asset and item.asset: + self.get_gl_entries_for_fixed_asset(item, gl_entries) + else: + income_account = ( + item.income_account + if (not item.enable_deferred_revenue or doc.is_return) + else item.deferred_revenue_account + ) + + amount, base_amount = doc.get_amount_and_base_amount(item, enable_discount_accounting) + + account_currency = get_account_currency(income_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": income_account, + "against": doc.customer, + "credit": flt(base_amount, item.precision("base_net_amount")), + "credit_in_account_currency": ( + flt(base_amount, item.precision("base_net_amount")) + if account_currency == doc.company_currency + else flt(amount, item.precision("net_amount")) + ), + "credit_in_transaction_currency": flt(amount, item.precision("net_amount")), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + # expense account gl entries + if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company): + gl_entries += super(SalesInvoice, doc).get_gl_entries() + + def get_gl_entries_for_fixed_asset(self, item, gl_entries): + doc = self.doc + asset = frappe.get_cached_doc("Asset", item.asset) + + if doc.is_return: + fixed_asset_gl_entries = get_gl_entries_on_asset_regain( + asset, + item.base_net_amount, + item.finance_book, + doc.get("doctype"), + doc.get("name"), + doc.get("posting_date"), + ) + else: + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( + asset, + item.base_net_amount, + item.finance_book, + doc.get("doctype"), + doc.get("name"), + doc.get("posting_date"), + ) + + for gle in fixed_asset_gl_entries: + gle["against"] = doc.customer + gl_entries.append(doc.get_gl_dict(gle, item=item)) + + def make_loyalty_point_redemption_gle(self, gl_entries): + doc = self.doc + if cint(doc.redeem_loyalty_points and doc.loyalty_points and not doc.is_consolidated): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": "Expense account - " + + cstr(doc.loyalty_redemption_account) + + " for the Loyalty Program", + "credit": doc.loyalty_amount, + "credit_in_transaction_currency": doc.loyalty_amount, + "against_voucher": doc.return_against if cint(doc.is_return) else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + }, + item=doc, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.loyalty_redemption_account, + "cost_center": doc.cost_center or doc.loyalty_redemption_cost_center, + "against": doc.customer, + "debit": doc.loyalty_amount, + "debit_in_transaction_currency": doc.loyalty_amount, + "remark": "Loyalty Points redeemed by the customer", + }, + item=doc, + ) + ) + + def make_pos_gl_entries(self, gl_entries): + doc = self.doc + if cint(doc.is_pos): + skip_change_gl_entries = not cint( + frappe.get_single_value("POS Settings", "post_change_gl_entries") + ) + + for payment_mode in doc.payments: + if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount: + payment_mode.base_amount -= flt(doc.change_amount) + + against_voucher = doc.name + if doc.is_return and doc.return_against and not doc.update_outstanding_for_self: + against_voucher = doc.return_against + + if payment_mode.base_amount: + # POS, make payment entries + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": payment_mode.account, + "credit": payment_mode.base_amount, + "credit_in_account_currency": payment_mode.base_amount + if doc.party_account_currency == doc.company_currency + else payment_mode.amount, + "credit_in_transaction_currency": payment_mode.amount, + "against_voucher": against_voucher, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + }, + doc.party_account_currency, + item=doc, + ) + ) + + payment_mode_account_currency = get_account_currency(payment_mode.account) + gl_entries.append( + doc.get_gl_dict( + { + "account": payment_mode.account, + "against": doc.customer, + "debit": payment_mode.base_amount, + "debit_in_account_currency": payment_mode.base_amount + if payment_mode_account_currency == doc.company_currency + else payment_mode.amount, + "debit_in_transaction_currency": payment_mode.amount, + "cost_center": doc.cost_center, + }, + payment_mode_account_currency, + item=doc, + ) + ) + + if not skip_change_gl_entries: + gl_entries.extend(self.get_gle_for_change_amount()) + + def get_gle_for_change_amount(self) -> list[dict]: + doc = self.doc + if not doc.change_amount: + return [] + + if not doc.account_for_change_amount: + frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field")) + + return [ + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": doc.account_for_change_amount, + "debit": flt(doc.base_change_amount), + "debit_in_account_currency": flt(doc.base_change_amount) + if doc.party_account_currency == doc.company_currency + else flt(doc.change_amount), + "debit_in_transaction_currency": flt(doc.change_amount), + "against_voucher": doc.return_against + if cint(doc.is_return) and doc.return_against + else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ), + doc.get_gl_dict( + { + "account": doc.account_for_change_amount, + "against": doc.customer, + "credit": doc.base_change_amount, + "credit_in_transaction_currency": doc.change_amount, + "cost_center": doc.cost_center, + }, + item=doc, + ), + ] + + def make_write_off_gl_entry(self, gl_entries): + doc = self.doc + # write off entries, applicable if only pos + if ( + doc.is_pos + and doc.write_off_account + and flt(doc.write_off_amount, doc.precision("write_off_amount")) + ): + write_off_account_currency = get_account_currency(doc.write_off_account) + default_cost_center = frappe.get_cached_value("Company", doc.company, "cost_center") + + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": doc.write_off_account, + "credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")), + "credit_in_account_currency": ( + flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")) + if doc.party_account_currency == doc.company_currency + else flt(doc.write_off_amount, doc.precision("write_off_amount")) + ), + "credit_in_transaction_currency": flt( + doc.write_off_amount, doc.precision("write_off_amount") + ), + "against_voucher": doc.return_against if cint(doc.is_return) else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.write_off_account, + "against": doc.customer, + "debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")), + "debit_in_account_currency": ( + flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")) + if write_off_account_currency == doc.company_currency + else flt(doc.write_off_amount, doc.precision("write_off_amount")) + ), + "debit_in_transaction_currency": flt( + doc.write_off_amount, doc.precision("write_off_amount") + ), + "cost_center": doc.cost_center or doc.write_off_cost_center or default_cost_center, + }, + write_off_account_currency, + item=doc, + ) + ) + + def make_gle_for_rounding_adjustment(self, gl_entries): + doc = self.doc + if ( + flt(doc.rounding_adjustment, doc.precision("rounding_adjustment")) + and doc.base_rounding_adjustment + and not doc.is_internal_transfer() + ): + ( + round_off_account, + round_off_cost_center, + round_off_for_opening, + ) = get_round_off_account_and_cost_center( + doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + if doc.is_opening == "Yes" and doc.rounding_adjustment: + if not round_off_for_opening: + frappe.throw( + _( + "Opening Invoice has rounding adjustment of {0}.

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." + ).format( + frappe.bold(doc.rounding_adjustment), + frappe.bold("Round Off for Opening"), + get_link_to_form("Company", doc.company), + frappe.bold("Disable Rounded Total"), + ) + ) + else: + round_off_account = round_off_for_opening + + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": doc.customer, + "credit_in_account_currency": flt( + doc.rounding_adjustment, doc.precision("rounding_adjustment") + ), + "credit_in_transaction_currency": flt( + doc.rounding_adjustment, doc.precision("rounding_adjustment") + ), + "credit": flt( + doc.base_rounding_adjustment, doc.precision("base_rounding_adjustment") + ), + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else (doc.cost_center or round_off_cost_center), + }, + item=doc, + ) + ) diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 099a4119933..4922e1bbd22 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -71,8 +71,8 @@ Characterization tests snapshotting `gl_entries` output for representative trans ### Phase 1 — Extract `gl_validator.py` (lowest risk) — DONE Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validator.py`; `general_ledger.py` imports and calls them at the existing call sites (no behavior change). A consolidated `gl_validator.validate(gl_entries)` facade is deferred — the current checks run at different points (make_gl_entries / save_entries per-entry / make_reverse_gl_entries), so collapsing them into one call would alter ordering. Verified: all 12 Phase-0 snapshots byte-identical. -### Phase 2 — Pilot composer on Sales Invoice only -Create `BaseGLComposer` + `SalesInvoiceGLComposer`; lift bucket-A helpers from `accounts_controller`; move SI's `get_gl_entries` body into `.compose()`; old method becomes a thin shim. Do not over-generalise the base from one example. +### Phase 2 — Pilot composer on Sales Invoice only — DONE +Added `BaseGLComposer` (minimal: holds `self.doc`) and `SalesInvoiceGLComposer`. SI's `get_gl_entries` is a thin shim delegating to `SalesInvoiceGLComposer(self).compose()`. All 11 SI-specific row builders (make_customer/tax/item/internal_transfer/pos/loyalty/write_off/rounding GL entries, stock_delivered_but_not_billed, get_gl_entries_for_fixed_asset, get_gle_for_change_amount) moved onto the composer and operate on `self.doc`. The `super().get_gl_entries()` stock-expense call became `super(SalesInvoice, doc).get_gl_entries()` (MRO-faithful). Bucket-A shared helpers (`get_gl_dict`, `make_discount_gl_entries`, `make_precision_loss_gl_entry`, `set_transaction_currency_and_rate_in_gl_map`, `get_tax_amounts`, `get_amount_and_base_amount`) **stay on the controller** — they're still called via `self.doc` and only lift to `BaseGLComposer` once all doctypes use composers (can't move while other doctypes inherit them). Verified: 12 snapshots + 10 existing SI tests (perpetual `super()`, POS change, write-off, returns, fixed-asset disposal/regain, internal transfer, loyalty) all green. ### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) Add `PurchaseInvoiceGLComposer`; reshape `BaseGLComposer` from what SI + PI *actually* share. Two real consumers is the minimum to size the abstraction — prevents premature abstraction. From 6467f074598697a7fa2c195051181f33b34d10cb Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 08:13:10 +0530 Subject: [PATCH 07/65] refactor: introduce Purchase Invoice GL composer Phase 3 of the accounts/controller refactor. Adds PurchaseInvoiceGLComposer; PI's get_gl_entries body moves into compose() and the method becomes a thin shim. Row-builder methods still live on the document (invoked via self.doc) and migrate onto the composer next. After comparing the SI and PI compose() flows, BaseGLComposer is kept minimal: the two differ in step order, builders, and per-doctype regional function, so a shared template is not warranted. No behavior change (Phase 0 snapshots and PI GL tests stay green). --- .../purchase_invoice/purchase_invoice.py | 31 ++---------- .../purchase_invoice/services/__init__.py | 0 .../purchase_invoice/services/gl_composer.py | 48 +++++++++++++++++++ specs/accounts_refactor_spec.md | 4 +- 4 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/__init__.py create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8b417584e35..8941f5191e7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -856,34 +856,11 @@ class PurchaseInvoice(BuyingController): ) def get_gl_entries(self, inventory_account_map=None): - self.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company) + from erpnext.accounts.doctype.purchase_invoice.services.gl_composer import ( + PurchaseInvoiceGLComposer, + ) - if self.auto_accounting_for_stock: - self.stock_received_but_not_billed = self.get_company_default("stock_received_but_not_billed") - else: - self.stock_received_but_not_billed = None - - self.negative_expense_to_be_booked = 0.0 - gl_entries = [] - - self.make_supplier_gl_entry(gl_entries) - self.make_item_gl_entries(gl_entries) - self.make_precision_loss_gl_entry(gl_entries) - - self.make_tax_gl_entries(gl_entries) - self.make_internal_transfer_gl_entries(gl_entries) - self.make_gl_entries_for_tax_withholding(gl_entries) - - gl_entries = make_regional_gl_entries(gl_entries, self) - - gl_entries = merge_similar_entries(gl_entries) - - self.make_payment_gl_entries(gl_entries) - self.make_write_off_gl_entry(gl_entries) - self.make_gle_for_rounding_adjustment(gl_entries) - self.set_transaction_currency_and_rate_in_gl_map(gl_entries) - self.set_gl_entry_for_purchase_expense(gl_entries) - return gl_entries + return PurchaseInvoiceGLComposer(self).compose(inventory_account_map) def check_asset_cwip_enabled(self): # Check if there exists any item with cwip accounting enabled in it's asset category diff --git a/erpnext/accounts/doctype/purchase_invoice/services/__init__.py b/erpnext/accounts/doctype/purchase_invoice/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py new file mode 100644 index 00000000000..5992cd9bae6 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -0,0 +1,48 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import erpnext +from erpnext.accounts.services.base_gl_composer import BaseGLComposer + + +class PurchaseInvoiceGLComposer(BaseGLComposer): + """Assembles the GL entries for a Purchase Invoice. + + Orchestration only for now: the voucher-specific row builders still live on + the Purchase Invoice document and are invoked via ``self.doc``. They migrate + onto this composer in a later increment. + """ + + def compose(self, inventory_account_map=None): + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries + from erpnext.accounts.general_ledger import merge_similar_entries + + doc = self.doc + doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company) + + if doc.auto_accounting_for_stock: + doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed") + else: + doc.stock_received_but_not_billed = None + + doc.negative_expense_to_be_booked = 0.0 + gl_entries = [] + + doc.make_supplier_gl_entry(gl_entries) + doc.make_item_gl_entries(gl_entries) + doc.make_precision_loss_gl_entry(gl_entries) + + doc.make_tax_gl_entries(gl_entries) + doc.make_internal_transfer_gl_entries(gl_entries) + doc.make_gl_entries_for_tax_withholding(gl_entries) + + gl_entries = make_regional_gl_entries(gl_entries, doc) + + gl_entries = merge_similar_entries(gl_entries) + + doc.make_payment_gl_entries(gl_entries) + doc.make_write_off_gl_entry(gl_entries) + doc.make_gle_for_rounding_adjustment(gl_entries) + doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) + doc.set_gl_entry_for_purchase_expense(gl_entries) + return gl_entries diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 4922e1bbd22..7d0eac8c650 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -74,8 +74,8 @@ Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validato ### Phase 2 — Pilot composer on Sales Invoice only — DONE Added `BaseGLComposer` (minimal: holds `self.doc`) and `SalesInvoiceGLComposer`. SI's `get_gl_entries` is a thin shim delegating to `SalesInvoiceGLComposer(self).compose()`. All 11 SI-specific row builders (make_customer/tax/item/internal_transfer/pos/loyalty/write_off/rounding GL entries, stock_delivered_but_not_billed, get_gl_entries_for_fixed_asset, get_gle_for_change_amount) moved onto the composer and operate on `self.doc`. The `super().get_gl_entries()` stock-expense call became `super(SalesInvoice, doc).get_gl_entries()` (MRO-faithful). Bucket-A shared helpers (`get_gl_dict`, `make_discount_gl_entries`, `make_precision_loss_gl_entry`, `set_transaction_currency_and_rate_in_gl_map`, `get_tax_amounts`, `get_amount_and_base_amount`) **stay on the controller** — they're still called via `self.doc` and only lift to `BaseGLComposer` once all doctypes use composers (can't move while other doctypes inherit them). Verified: 12 snapshots + 10 existing SI tests (perpetual `super()`, POS change, write-off, returns, fixed-asset disposal/regain, internal transfer, loyalty) all green. -### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) -Add `PurchaseInvoiceGLComposer`; reshape `BaseGLComposer` from what SI + PI *actually* share. Two real consumers is the minimum to size the abstraction — prevents premature abstraction. +### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) — IN PROGRESS +Added `PurchaseInvoiceGLComposer` (scaffolding: compose() = the moved get_gl_entries orchestration; PI.get_gl_entries is a thin shim). **Decision after comparing SI and PI: keep `BaseGLComposer` minimal** (`self.doc` + abstract `compose`). The two flows differ too much to share a template — different step order (SI tax→item, PI item→tax), different builders (SI: discount/loyalty/POS/SDBNB; PI: tax-withholding/payment/purchase-expense), and a per-doctype `make_regional_gl_entries`. Forcing a template would be hook-heavy and risk behavior changes. Revisit base-lifting only when a 3rd+ doctype reveals a real common shape. Verified: 12 snapshots + 6 existing PI GL tests (perpetual inventory, non-stock, return, update_stock, tax withholding, provisional) green. (PI row-builder method migration onto the composer, mirroring SI, still pending.) ### Phase 4 — Roll out composer to remaining GL-posting doctypes Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated. From 32c4b1d98a6d2ad4f7bc8c642ac433e747ce1472 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 08:29:32 +0530 Subject: [PATCH 08/65] refactor: migrate PI supplier/tax/payment GL builders onto the composer Move make_supplier_gl_entry, add_supplier_gl_entry, make_tax_gl_entries, make_internal_transfer_gl_entries, make_gl_entries_for_tax_withholding, make_payment_gl_entries, make_write_off_gl_entry, and make_gle_for_rounding_adjustment from PurchaseInvoice onto PurchaseInvoiceGLComposer. compose() now calls self.X for all moved builders; the make_item cluster (make_item_gl_entries, make_provisional_gl_entry, get_provisional_accounts, update_net_purchase_amount_for_linked_assets, make_stock_adjustment_entry) still lives on doc pending batch-2 migration. All 12 GL characterization snapshots pass. --- .../purchase_invoice/purchase_invoice.py | 324 ----------------- .../purchase_invoice/services/gl_composer.py | 343 +++++++++++++++++- 2 files changed, 329 insertions(+), 338 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8941f5191e7..4121dd48e63 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -871,53 +871,6 @@ class PurchaseInvoice(BuyingController): return 1 return 0 - def make_supplier_gl_entry(self, gl_entries): - # Checked both rounding_adjustment and rounded_total - # because rounded_total had value even before introduction of posting GLE based on rounded total - grand_total = ( - self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total - ) - base_grand_total = flt( - self.base_rounded_total - if (self.base_rounding_adjustment and self.base_rounded_total) - else self.base_grand_total, - self.precision("base_grand_total"), - ) - - if grand_total and not self.is_internal_transfer(): - self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total) - - def add_supplier_gl_entry( - self, gl_entries, base_grand_total, grand_total, against_account=None, remarks=None, skip_merge=False - ): - against_voucher = self.name - if self.is_return and self.return_against and not self.update_outstanding_for_self: - against_voucher = self.return_against - - # Did not use base_grand_total to book rounding loss gle - gl = { - "account": self.credit_to, - "party_type": "Supplier", - "party": self.supplier, - "due_date": self.due_date, - "against": against_account or self.against_expense_account, - "credit": base_grand_total, - "credit_in_account_currency": base_grand_total - if self.party_account_currency == self.company_currency - else grand_total, - "credit_in_transaction_currency": grand_total, - "against_voucher": against_voucher, - "against_voucher_type": self.doctype, - "project": self.project, - "cost_center": self.cost_center, - "_skip_merge": skip_merge, - } - - if remarks: - gl["remarks"] = remarks - - gl_entries.append(self.get_gl_dict(gl, self.party_account_currency, item=self)) - def make_item_gl_entries(self, gl_entries): # item gl entries stock_items = self.get_stock_items() @@ -1375,283 +1328,6 @@ class PurchaseInvoice(BuyingController): return warehouse_debit_amount - def make_tax_gl_entries(self, gl_entries): - # tax table gl entries - valuation_tax = {} - - for tax in self.get("taxes"): - amount, base_amount = self.get_tax_amounts(tax, None) - if tax.category in ("Total", "Valuation and Total") and flt(base_amount): - account_currency = get_account_currency(tax.account_head) - - dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit" - - gl_entries.append( - self.get_gl_dict( - { - "account": tax.account_head, - "against": self.supplier, - dr_or_cr: base_amount, - dr_or_cr + "_in_account_currency": base_amount - if account_currency == self.company_currency - else amount, - dr_or_cr + "_in_transaction_currency": amount, - "cost_center": tax.cost_center, - }, - account_currency, - item=tax, - ) - ) - # accumulate valuation tax - if ( - self.is_opening == "No" - and tax.category in ("Valuation", "Valuation and Total") - and flt(base_amount) - and not self.is_internal_transfer() - ): - if self.auto_accounting_for_stock and not tax.cost_center: - frappe.throw( - _("Cost Center is required in row {0} in Taxes table for type {1}").format( - tax.idx, _(tax.category) - ) - ) - valuation_tax.setdefault(tax.name, 0) - valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount) - - if self.is_opening == "No" and self.negative_expense_to_be_booked and valuation_tax: - # credit valuation tax amount in "Expenses Included In Valuation" - # this will balance out valuation amount included in cost of goods sold - - total_valuation_amount = sum(valuation_tax.values()) - amount_including_divisional_loss = self.negative_expense_to_be_booked - i = 1 - for tax in self.get("taxes"): - if valuation_tax.get(tax.name): - if i == len(valuation_tax): - applicable_amount = amount_including_divisional_loss - else: - applicable_amount = self.negative_expense_to_be_booked * ( - valuation_tax[tax.name] / total_valuation_amount - ) - amount_including_divisional_loss -= applicable_amount - - gl_entries.append( - self.get_gl_dict( - { - "account": tax.account_head, - "cost_center": tax.cost_center, - "against": self.supplier, - "credit": applicable_amount, - "credit_in_transaction_currency": flt( - applicable_amount / self.conversion_rate, - frappe.get_precision("Purchase Invoice Item", "item_tax_amount"), - ), - "remarks": self.remarks or _("Accounting Entry for Stock"), - }, - item=tax, - ) - ) - - i += 1 - - if self.auto_accounting_for_stock and self.update_stock and valuation_tax: - for tax in self.get("taxes"): - if valuation_tax.get(tax.name): - gl_entries.append( - self.get_gl_dict( - { - "account": tax.account_head, - "cost_center": tax.cost_center, - "against": self.supplier, - "credit": valuation_tax[tax.name], - "credit_in_transaction_currency": flt( - valuation_tax[tax.name] / self.conversion_rate, - frappe.get_precision("Purchase Invoice Item", "item_tax_amount"), - ), - "remarks": self.remarks or _("Accounting Entry for Stock"), - }, - item=tax, - ) - ) - - def make_internal_transfer_gl_entries(self, gl_entries): - if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges): - account_currency = get_account_currency(self.unrealized_profit_loss_account) - gl_entries.append( - self.get_gl_dict( - { - "account": self.unrealized_profit_loss_account, - "against": self.supplier, - "credit": flt(self.total_taxes_and_charges), - "credit_in_transaction_currency": flt(self.total_taxes_and_charges), - "credit_in_account_currency": flt(self.base_total_taxes_and_charges), - "cost_center": self.cost_center, - }, - account_currency, - item=self, - ) - ) - - def make_gl_entries_for_tax_withholding(self, gl_entries): - """ - Tax withholding amount is not part of supplier invoice. - Separate supplier GL Entry for correct reporting. - """ - if not self.apply_tds: - return - - for row in self.get("taxes"): - if not row.is_tax_withholding_account or not row.tax_amount: - continue - - base_tds_amount = row.base_tax_amount_after_discount_amount - tds_amount = row.tax_amount_after_discount_amount - - self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount) - self.add_supplier_gl_entry( - gl_entries, - -base_tds_amount, - -tds_amount, - against_account=row.account_head, - remarks=_("TDS Deducted"), - skip_merge=True, - ) - - def make_payment_gl_entries(self, gl_entries): - # Make Cash GL Entries - if cint(self.is_paid) and self.cash_bank_account and self.paid_amount: - bank_account_currency = get_account_currency(self.cash_bank_account) - # CASH, make payment entries - gl_entries.append( - self.get_gl_dict( - { - "account": self.credit_to, - "party_type": "Supplier", - "party": self.supplier, - "against": self.cash_bank_account, - "debit": self.base_paid_amount, - "debit_in_account_currency": self.base_paid_amount - if self.party_account_currency == self.company_currency - else self.paid_amount, - "debit_in_transaction_currency": self.paid_amount, - "against_voucher": self.return_against - if cint(self.is_return) and self.return_against - else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) - - gl_entries.append( - self.get_gl_dict( - { - "account": self.cash_bank_account, - "against": self.supplier, - "credit": self.base_paid_amount, - "credit_in_account_currency": self.base_paid_amount - if bank_account_currency == self.company_currency - else self.paid_amount, - "credit_in_transaction_currency": self.paid_amount, - "cost_center": self.cost_center, - }, - bank_account_currency, - item=self, - ) - ) - - def make_write_off_gl_entry(self, gl_entries): - # writeoff account includes petty difference in the invoice amount - # and the amount that is paid - if self.write_off_account and flt(self.write_off_amount): - write_off_account_currency = get_account_currency(self.write_off_account) - - gl_entries.append( - self.get_gl_dict( - { - "account": self.credit_to, - "party_type": "Supplier", - "party": self.supplier, - "against": self.write_off_account, - "debit": self.base_write_off_amount, - "debit_in_account_currency": self.base_write_off_amount - if self.party_account_currency == self.company_currency - else self.write_off_amount, - "debit_in_transaction_currency": self.write_off_amount, - "against_voucher": self.return_against - if cint(self.is_return) and self.return_against - else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": self.write_off_account, - "against": self.supplier, - "credit": flt(self.base_write_off_amount), - "credit_in_account_currency": self.base_write_off_amount - if write_off_account_currency == self.company_currency - else self.write_off_amount, - "credit_in_transaction_currency": self.write_off_amount, - "cost_center": self.cost_center or self.write_off_cost_center, - }, - item=self, - ) - ) - - def make_gle_for_rounding_adjustment(self, gl_entries): - # if rounding adjustment in small and conversion rate is also small then - # base_rounding_adjustment may become zero due to small precision - # eg: rounding_adjustment = 0.01 and exchange rate = 0.05 and precision of base_rounding_adjustment is 2 - # then base_rounding_adjustment becomes zero and error is thrown in GL Entry - if not self.is_internal_transfer() and self.rounding_adjustment and self.base_rounding_adjustment: - ( - round_off_account, - round_off_cost_center, - round_off_for_opening, - ) = get_round_off_account_and_cost_center( - self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center - ) - - if self.is_opening == "Yes" and self.rounding_adjustment: - if not round_off_for_opening: - frappe.throw( - _( - "Opening Invoice has rounding adjustment of {0}.

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." - ).format( - frappe.bold(self.rounding_adjustment), - frappe.bold("Round Off for Opening"), - get_link_to_form("Company", self.company), - frappe.bold("Disable Rounded Total"), - ) - ) - else: - round_off_account = round_off_for_opening - - gl_entries.append( - self.get_gl_dict( - { - "account": round_off_account, - "against": self.supplier, - "debit_in_account_currency": self.rounding_adjustment, - "debit": self.base_rounding_adjustment, - "cost_center": round_off_cost_center - if self.use_company_roundoff_cost_center - else (self.cost_center or round_off_cost_center), - }, - item=self, - ) - ) - def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index 5992cd9bae6..861f76c65e4 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -1,17 +1,18 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import frappe +from frappe import _ +from frappe.utils import cint, flt, get_link_to_form + import erpnext +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.utils import get_account_currency class PurchaseInvoiceGLComposer(BaseGLComposer): - """Assembles the GL entries for a Purchase Invoice. - - Orchestration only for now: the voucher-specific row builders still live on - the Purchase Invoice document and are invoked via ``self.doc``. They migrate - onto this composer in a later increment. - """ + """Assembles the GL entries for a Purchase Invoice.""" def compose(self, inventory_account_map=None): from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries @@ -28,21 +29,335 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): doc.negative_expense_to_be_booked = 0.0 gl_entries = [] - doc.make_supplier_gl_entry(gl_entries) + self.make_supplier_gl_entry(gl_entries) doc.make_item_gl_entries(gl_entries) doc.make_precision_loss_gl_entry(gl_entries) - doc.make_tax_gl_entries(gl_entries) - doc.make_internal_transfer_gl_entries(gl_entries) - doc.make_gl_entries_for_tax_withholding(gl_entries) + self.make_tax_gl_entries(gl_entries) + self.make_internal_transfer_gl_entries(gl_entries) + self.make_gl_entries_for_tax_withholding(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, doc) - gl_entries = merge_similar_entries(gl_entries) - doc.make_payment_gl_entries(gl_entries) - doc.make_write_off_gl_entry(gl_entries) - doc.make_gle_for_rounding_adjustment(gl_entries) + self.make_payment_gl_entries(gl_entries) + self.make_write_off_gl_entry(gl_entries) + self.make_gle_for_rounding_adjustment(gl_entries) doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) doc.set_gl_entry_for_purchase_expense(gl_entries) return gl_entries + + def make_supplier_gl_entry(self, gl_entries): + doc = self.doc + grand_total = ( + doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total + ) + base_grand_total = flt( + doc.base_rounded_total + if (doc.base_rounding_adjustment and doc.base_rounded_total) + else doc.base_grand_total, + doc.precision("base_grand_total"), + ) + if grand_total and not doc.is_internal_transfer(): + self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total) + + def add_supplier_gl_entry( + self, + gl_entries, + base_grand_total, + grand_total, + against_account=None, + remarks=None, + skip_merge=False, + ): + doc = self.doc + against_voucher = doc.name + if doc.is_return and doc.return_against and not doc.update_outstanding_for_self: + against_voucher = doc.return_against + + gl = { + "account": doc.credit_to, + "party_type": "Supplier", + "party": doc.supplier, + "due_date": doc.due_date, + "against": against_account or doc.against_expense_account, + "credit": base_grand_total, + "credit_in_account_currency": base_grand_total + if doc.party_account_currency == doc.company_currency + else grand_total, + "credit_in_transaction_currency": grand_total, + "against_voucher": against_voucher, + "against_voucher_type": doc.doctype, + "project": doc.project, + "cost_center": doc.cost_center, + "_skip_merge": skip_merge, + } + if remarks: + gl["remarks"] = remarks + gl_entries.append(doc.get_gl_dict(gl, doc.party_account_currency, item=doc)) + + def make_tax_gl_entries(self, gl_entries): + doc = self.doc + valuation_tax = {} + + for tax in doc.get("taxes"): + amount, base_amount = doc.get_tax_amounts(tax, None) + if tax.category in ("Total", "Valuation and Total") and flt(base_amount): + account_currency = get_account_currency(tax.account_head) + dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit" + gl_entries.append( + doc.get_gl_dict( + { + "account": tax.account_head, + "against": doc.supplier, + dr_or_cr: base_amount, + dr_or_cr + "_in_account_currency": base_amount + if account_currency == doc.company_currency + else amount, + dr_or_cr + "_in_transaction_currency": amount, + "cost_center": tax.cost_center, + }, + account_currency, + item=tax, + ) + ) + + if ( + doc.is_opening == "No" + and tax.category in ("Valuation", "Valuation and Total") + and flt(base_amount) + and not doc.is_internal_transfer() + ): + if doc.auto_accounting_for_stock and not tax.cost_center: + frappe.throw( + _("Cost Center is required in row {0} in Taxes table for type {1}").format( + tax.idx, _(tax.category) + ) + ) + valuation_tax.setdefault(tax.name, 0) + valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount) + + if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax: + total_valuation_amount = sum(valuation_tax.values()) + amount_including_divisional_loss = doc.negative_expense_to_be_booked + i = 1 + for tax in doc.get("taxes"): + if valuation_tax.get(tax.name): + if i == len(valuation_tax): + applicable_amount = amount_including_divisional_loss + else: + applicable_amount = doc.negative_expense_to_be_booked * ( + valuation_tax[tax.name] / total_valuation_amount + ) + amount_including_divisional_loss -= applicable_amount + + gl_entries.append( + doc.get_gl_dict( + { + "account": tax.account_head, + "cost_center": tax.cost_center, + "against": doc.supplier, + "credit": applicable_amount, + "credit_in_transaction_currency": flt( + applicable_amount / doc.conversion_rate, + frappe.get_precision("Purchase Invoice Item", "item_tax_amount"), + ), + "remarks": doc.remarks or _("Accounting Entry for Stock"), + }, + item=tax, + ) + ) + i += 1 + + if doc.auto_accounting_for_stock and doc.update_stock and valuation_tax: + for tax in doc.get("taxes"): + if valuation_tax.get(tax.name): + gl_entries.append( + doc.get_gl_dict( + { + "account": tax.account_head, + "cost_center": tax.cost_center, + "against": doc.supplier, + "credit": valuation_tax[tax.name], + "credit_in_transaction_currency": flt( + valuation_tax[tax.name] / doc.conversion_rate, + frappe.get_precision("Purchase Invoice Item", "item_tax_amount"), + ), + "remarks": doc.remarks or _("Accounting Entry for Stock"), + }, + item=tax, + ) + ) + + def make_internal_transfer_gl_entries(self, gl_entries): + doc = self.doc + if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges): + account_currency = get_account_currency(doc.unrealized_profit_loss_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.unrealized_profit_loss_account, + "against": doc.supplier, + "credit": flt(doc.total_taxes_and_charges), + "credit_in_transaction_currency": flt(doc.total_taxes_and_charges), + "credit_in_account_currency": flt(doc.base_total_taxes_and_charges), + "cost_center": doc.cost_center, + }, + account_currency, + item=doc, + ) + ) + + def make_gl_entries_for_tax_withholding(self, gl_entries): + """Separate supplier GL entry for tax withholding (TDS) — not part of the supplier invoice amount.""" + doc = self.doc + if not doc.apply_tds: + return + + for row in doc.get("taxes"): + if not row.is_tax_withholding_account or not row.tax_amount: + continue + + base_tds_amount = row.base_tax_amount_after_discount_amount + tds_amount = row.tax_amount_after_discount_amount + + self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount) + self.add_supplier_gl_entry( + gl_entries, + -base_tds_amount, + -tds_amount, + against_account=row.account_head, + remarks=_("TDS Deducted"), + skip_merge=True, + ) + + def make_payment_gl_entries(self, gl_entries): + doc = self.doc + if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount: + bank_account_currency = get_account_currency(doc.cash_bank_account) + + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.credit_to, + "party_type": "Supplier", + "party": doc.supplier, + "against": doc.cash_bank_account, + "debit": doc.base_paid_amount, + "debit_in_account_currency": doc.base_paid_amount + if doc.party_account_currency == doc.company_currency + else doc.paid_amount, + "debit_in_transaction_currency": doc.paid_amount, + "against_voucher": doc.return_against + if cint(doc.is_return) and doc.return_against + else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ) + ) + + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.cash_bank_account, + "against": doc.supplier, + "credit": doc.base_paid_amount, + "credit_in_account_currency": doc.base_paid_amount + if bank_account_currency == doc.company_currency + else doc.paid_amount, + "credit_in_transaction_currency": doc.paid_amount, + "cost_center": doc.cost_center, + }, + bank_account_currency, + item=doc, + ) + ) + + def make_write_off_gl_entry(self, gl_entries): + doc = self.doc + if doc.write_off_account and flt(doc.write_off_amount): + write_off_account_currency = get_account_currency(doc.write_off_account) + + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.credit_to, + "party_type": "Supplier", + "party": doc.supplier, + "against": doc.write_off_account, + "debit": doc.base_write_off_amount, + "debit_in_account_currency": doc.base_write_off_amount + if doc.party_account_currency == doc.company_currency + else doc.write_off_amount, + "debit_in_transaction_currency": doc.write_off_amount, + "against_voucher": doc.return_against + if cint(doc.is_return) and doc.return_against + else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.write_off_account, + "against": doc.supplier, + "credit": flt(doc.base_write_off_amount), + "credit_in_account_currency": doc.base_write_off_amount + if write_off_account_currency == doc.company_currency + else doc.write_off_amount, + "credit_in_transaction_currency": doc.write_off_amount, + "cost_center": doc.cost_center or doc.write_off_cost_center, + }, + item=doc, + ) + ) + + def make_gle_for_rounding_adjustment(self, gl_entries): + doc = self.doc + if not doc.is_internal_transfer() and doc.rounding_adjustment and doc.base_rounding_adjustment: + ( + round_off_account, + round_off_cost_center, + round_off_for_opening, + ) = get_round_off_account_and_cost_center( + doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + if doc.is_opening == "Yes" and doc.rounding_adjustment: + if not round_off_for_opening: + frappe.throw( + _( + "Opening Invoice has rounding adjustment of {0}.

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." + ).format( + frappe.bold(doc.rounding_adjustment), + frappe.bold("Round Off for Opening"), + get_link_to_form("Company", doc.company), + frappe.bold("Disable Rounded Total"), + ) + ) + else: + round_off_account = round_off_for_opening + + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": doc.supplier, + "debit_in_account_currency": doc.rounding_adjustment, + "debit": doc.base_rounding_adjustment, + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else (doc.cost_center or round_off_cost_center), + }, + item=doc, + ) + ) From 9c78c9ab7b3ea0d29e59b771aa272264313d315a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 08:40:01 +0530 Subject: [PATCH 09/65] refactor: migrate PI item/stock/provisional GL builders onto the composer Move make_item_gl_entries, make_stock_adjustment_entry, get_provisional_accounts, make_provisional_gl_entry, and update_net_purchase_amount_for_linked_assets from PurchaseInvoice onto PurchaseInvoiceGLComposer, completing the full GL builder migration. purchase_invoice.py no longer contains any GL row-building logic; PurchaseInvoiceGLComposer is the single authoritative source for all PI GL entries, mirroring the SalesInvoiceGLComposer pattern. All 12 GL characterization snapshots pass. --- .../purchase_invoice/purchase_invoice.py | 457 ------------------ .../purchase_invoice/services/gl_composer.py | 456 ++++++++++++++++- 2 files changed, 455 insertions(+), 458 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 4121dd48e63..6c4910269f2 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -871,463 +871,6 @@ class PurchaseInvoice(BuyingController): return 1 return 0 - def make_item_gl_entries(self, gl_entries): - # item gl entries - stock_items = self.get_stock_items() - if self.update_stock and self.auto_accounting_for_stock: - inventory_account_map = self.get_inventory_account_map() - - landed_cost_entries = self.get_item_account_wise_lcv_entries() - - voucher_wise_stock_value = {} - if self.update_stock: - stock_ledger_entries = frappe.get_all( - "Stock Ledger Entry", - fields=["voucher_detail_no", "stock_value_difference", "warehouse"], - filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0}, - ) - for d in stock_ledger_entries: - voucher_wise_stock_value.setdefault( - (d.voucher_detail_no, d.warehouse), d.stock_value_difference - ) - - valuation_tax_accounts = [ - d.account_head - for d in self.get("taxes") - if d.category in ("Valuation", "Valuation and Total") - and flt(d.base_tax_amount_after_discount_amount) - ] - - exchange_rate_map, net_rate_map = get_purchase_document_details(self) - - provisional_accounting_for_non_stock_items = cint( - frappe.get_cached_value( - "Company", self.company, "enable_provisional_accounting_for_non_stock_items" - ) - ) - if provisional_accounting_for_non_stock_items: - self.get_provisional_accounts() - - adjust_incoming_rate = frappe.db.get_single_value( - "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" - ) - - for item in self.get("items"): - if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate): - if item.item_code: - frappe.get_cached_value("Item", item.item_code, "asset_category") - - if ( - self.update_stock - and self.auto_accounting_for_stock - and (item.item_code in stock_items or item.is_fixed_asset) - ): - account_currency = get_account_currency(item.expense_account) - # warehouse account - warehouse_debit_amount = self.make_stock_adjustment_entry( - gl_entries, item, voucher_wise_stock_value, account_currency - ) - - if item.from_warehouse: - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map) - - _inv_dict_from_warehouse = self.get_inventory_account_dict( - item, inventory_account_map, "from_warehouse" - ) - - gl_entries.append( - self.get_gl_dict( - { - "account": _inv_dict["account"], - "against": _inv_dict_from_warehouse["account"], - "cost_center": item.cost_center, - "project": item.project or self.project, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "debit": warehouse_debit_amount, - "debit_in_transaction_currency": item.net_amount, - }, - _inv_dict["account_currency"], - item=item, - ) - ) - - credit_amount = item.base_net_amount - if self.is_internal_supplier and item.valuation_rate: - credit_amount = flt(item.valuation_rate * item.stock_qty) - - # Intentionally passed negative debit amount to avoid incorrect GL Entry validation - gl_entries.append( - self.get_gl_dict( - { - "account": _inv_dict_from_warehouse["account"], - "against": _inv_dict["account"], - "cost_center": item.cost_center, - "project": item.project or self.project, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "debit": -1 * flt(credit_amount, item.precision("base_net_amount")), - "debit_in_transaction_currency": item.net_amount, - }, - _inv_dict_from_warehouse["account_currency"], - item=item, - ) - ) - - # Do not book expense for transfer within same company transfer - if not self.is_internal_transfer(): - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account, - "against": self.supplier, - "debit": flt(item.base_net_amount, item.precision("base_net_amount")), - "debit_in_transaction_currency": item.net_amount, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "cost_center": item.cost_center, - "project": item.project, - }, - account_currency, - item=item, - ) - ) - - else: - if not self.is_internal_transfer(): - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account, - "against": self.supplier, - "debit": warehouse_debit_amount, - "debit_in_transaction_currency": flt( - warehouse_debit_amount / self.conversion_rate, - item.precision("net_amount"), - ), - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - # Amount added through landed-cost-voucher - if landed_cost_entries: - if (item.item_code, item.name) in landed_cost_entries: - for account, base_amount in landed_cost_entries[ - (item.item_code, item.name) - ].items(): - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": item.expense_account, - "cost_center": item.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(base_amount["base_amount"]), - "credit_in_account_currency": flt(base_amount["amount"]), - "credit_in_transaction_currency": item.net_amount, - "project": item.project or self.project, - }, - item=item, - ) - ) - - # sub-contracting warehouse - if flt(item.rm_supp_cost): - supplier_wh_dict = self.get_inventory_account_dict( - item, inventory_account_map, "supplier_warehouse" - ) - - supplier_inventory_account = supplier_wh_dict["account"] - if not supplier_inventory_account: - frappe.throw( - _("Please set account in Warehouse {0}").format(self.supplier_warehouse) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": supplier_inventory_account, - "against": item.expense_account, - "cost_center": item.cost_center, - "project": item.project or self.project, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(item.rm_supp_cost), - "credit_in_transaction_currency": item.net_amount, - }, - supplier_wh_dict["account_currency"], - item=item, - ) - ) - - else: - expense_account = ( - item.expense_account - if (not item.enable_deferred_expense or self.is_return) - else item.deferred_expense_account - ) - - account_currency = get_account_currency(expense_account) - amount, base_amount = self.get_amount_and_base_amount(item, None) - - if provisional_accounting_for_non_stock_items: - self.make_provisional_gl_entry(gl_entries, item) - - if not self.is_internal_transfer(): - gl_entries.append( - self.get_gl_dict( - { - "account": expense_account, - "against": self.supplier, - "debit": base_amount, - "debit_in_transaction_currency": amount, - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - # check if the exchange rate has changed - if ( - not adjust_incoming_rate - and item.get("purchase_receipt") - and self.auto_accounting_for_stock - ): - if ( - exchange_rate_map[item.purchase_receipt] - and self.conversion_rate != exchange_rate_map[item.purchase_receipt] - and item.net_rate == net_rate_map[item.pr_detail] - and item.item_code in stock_items - ): - discrepancy_caused_by_exchange_rate_difference = ( - item.qty * item.net_rate - ) * (exchange_rate_map[item.purchase_receipt] - self.conversion_rate) - - gl_entries.append( - self.get_gl_dict( - { - "account": expense_account, - "against": self.supplier, - "debit": discrepancy_caused_by_exchange_rate_difference, - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": self.get_company_default("exchange_gain_loss_account"), - "against": self.supplier, - "credit": discrepancy_caused_by_exchange_rate_difference, - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - if ( - self.auto_accounting_for_stock - and self.is_opening == "No" - and item.item_code in stock_items - and item.item_tax_amount - ): - # Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt - if item.purchase_receipt and valuation_tax_accounts: - negative_expense_booked_in_pr = frappe.db.sql( - """select name from `tabGL Entry` - where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""", - (item.purchase_receipt, valuation_tax_accounts), - ) - - ( - self.get_company_default("asset_received_but_not_billed") - if item.is_fixed_asset - else self.stock_received_but_not_billed - ) - - if not negative_expense_booked_in_pr: - gl_entries.append( - self.get_gl_dict( - { - "account": self.stock_received_but_not_billed, - "against": self.supplier, - "debit": flt(item.item_tax_amount, item.precision("item_tax_amount")), - "debit_in_transaction_currency": flt( - item.item_tax_amount / self.conversion_rate, - item.precision("item_tax_amount"), - ), - "remarks": self.remarks or _("Accounting Entry for Stock"), - "cost_center": self.cost_center, - "project": item.project or self.project, - }, - item=item, - ) - ) - - self.negative_expense_to_be_booked += flt( - item.item_tax_amount, item.precision("item_tax_amount") - ) - - if item.is_fixed_asset and item.landed_cost_voucher_amount: - self.update_net_purchase_amount_for_linked_assets(item) - - def get_provisional_accounts(self): - self.provisional_accounts = frappe._dict() - linked_purchase_receipts = set([d.purchase_receipt for d in self.items if d.purchase_receipt]) - if not linked_purchase_receipts: - return - - pr_items = frappe.get_all( - "Purchase Receipt Item", - filters={"parent": ("in", linked_purchase_receipts)}, - fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"], - ) - default_provisional_account = self.get_company_default("default_provisional_account") - provisional_accounts = set( - [ - d.provisional_expense_account - if d.provisional_expense_account - else default_provisional_account - for d in pr_items - ] - ) - - provisional_gl_entries = frappe.get_all( - "GL Entry", - filters={ - "voucher_type": "Purchase Receipt", - "voucher_no": ("in", linked_purchase_receipts), - "account": ("in", provisional_accounts), - "is_cancelled": 0, - }, - fields=["voucher_detail_no"], - ) - rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries] - for item in pr_items: - self.provisional_accounts[item.name] = { - "provisional_account": item.provisional_expense_account or default_provisional_account, - "qty": item.qty, - "base_rate": item.base_rate, - "rate": item.rate, - "has_provisional_entry": item.name in rows_with_provisional_entries, - } - - def make_provisional_gl_entry(self, gl_entries, item): - if item.purchase_receipt: - pr_item = self.provisional_accounts.get(item.pr_detail, {}) - if pr_item.get("has_provisional_entry"): - purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt) - - # Intentionally passing purchase invoice item to handle partial billing - purchase_receipt_doc.add_provisional_gl_entry( - item, - gl_entries, - self.posting_date, - pr_item.get("provisional_account"), - reverse=1, - item_amount=( - (min(item.qty, pr_item.get("qty")) * pr_item.get("rate")) - * purchase_receipt_doc.get("conversion_rate") - ), - ) - - def update_net_purchase_amount_for_linked_assets(self, item): - assets = frappe.db.get_all( - "Asset", - filters={ - "purchase_invoice": self.name, - "item_code": item.item_code, - "purchase_invoice_item": ("in", [item.name, ""]), - }, - fields=["name", "asset_quantity"], - ) - for asset in assets: - purchase_amount = flt(item.valuation_rate) * asset.asset_quantity - frappe.db.set_value( - "Asset", - asset.name, - { - "net_purchase_amount": purchase_amount, - "purchase_amount": purchase_amount, - }, - ) - - def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency): - net_amt_precision = item.precision("base_net_amount") - val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9 - - warehouse_debit_amount = flt( - flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor), - net_amt_precision, - ) - - if self.is_return and self.update_stock and (self.is_internal_supplier or not self.return_against): - net_rate = item.base_net_amount - if item.sales_incoming_rate: # for internal transfer - net_rate = item.qty * item.sales_incoming_rate - - stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount) - warehouse_debit_amount = flt( - voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision - ) - - if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision): - cost_of_goods_sold_account = self.get_company_default("default_expense_account") - stock_adjustment_amt = stock_amount - warehouse_debit_amount - - gl_entries.append( - self.get_gl_dict( - { - "account": cost_of_goods_sold_account, - "against": item.expense_account, - "debit": stock_adjustment_amt, - "debit_in_transaction_currency": stock_adjustment_amt / self.conversion_rate, - "remarks": self.get("remarks") or _("Stock Adjustment"), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - elif ( - self.update_stock - and voucher_wise_stock_value.get((item.name, item.warehouse)) - and warehouse_debit_amount - != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) - ): - cost_of_goods_sold_account = self.get_company_default("default_expense_account") - stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) - stock_adjustment_amt = warehouse_debit_amount - stock_amount - - gl_entries.append( - self.get_gl_dict( - { - "account": cost_of_goods_sold_account, - "against": item.expense_account, - "debit": stock_adjustment_amt, - "debit_in_transaction_currency": stock_adjustment_amt / self.conversion_rate, - "remarks": self.get("remarks") or _("Stock Adjustment"), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - warehouse_debit_amount = stock_amount - - return warehouse_debit_amount - def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index 861f76c65e4..28e26920942 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -30,7 +30,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): gl_entries = [] self.make_supplier_gl_entry(gl_entries) - doc.make_item_gl_entries(gl_entries) + self.make_item_gl_entries(gl_entries) doc.make_precision_loss_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) @@ -96,6 +96,460 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): gl["remarks"] = remarks gl_entries.append(doc.get_gl_dict(gl, doc.party_account_currency, item=doc)) + def make_item_gl_entries(self, gl_entries): + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( + get_purchase_document_details, + ) + + doc = self.doc + stock_items = doc.get_stock_items() + if doc.update_stock and doc.auto_accounting_for_stock: + inventory_account_map = doc.get_inventory_account_map() + + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + + voucher_wise_stock_value = {} + if doc.update_stock: + stock_ledger_entries = frappe.get_all( + "Stock Ledger Entry", + fields=["voucher_detail_no", "stock_value_difference", "warehouse"], + filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0}, + ) + for d in stock_ledger_entries: + voucher_wise_stock_value.setdefault( + (d.voucher_detail_no, d.warehouse), d.stock_value_difference + ) + + valuation_tax_accounts = [ + d.account_head + for d in doc.get("taxes") + if d.category in ("Valuation", "Valuation and Total") + and flt(d.base_tax_amount_after_discount_amount) + ] + + exchange_rate_map, net_rate_map = get_purchase_document_details(doc) + + provisional_accounting_for_non_stock_items = cint( + frappe.get_cached_value( + "Company", doc.company, "enable_provisional_accounting_for_non_stock_items" + ) + ) + if provisional_accounting_for_non_stock_items: + self.get_provisional_accounts() + + adjust_incoming_rate = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + for item in doc.get("items"): + if flt(item.base_net_amount) or (doc.get("update_stock") and item.valuation_rate): + if item.item_code: + frappe.get_cached_value("Item", item.item_code, "asset_category") + + if ( + doc.update_stock + and doc.auto_accounting_for_stock + and (item.item_code in stock_items or item.is_fixed_asset) + ): + account_currency = get_account_currency(item.expense_account) + warehouse_debit_amount = self.make_stock_adjustment_entry( + gl_entries, item, voucher_wise_stock_value, account_currency + ) + + if item.from_warehouse: + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + _inv_dict_from_warehouse = doc.get_inventory_account_dict( + item, inventory_account_map, "from_warehouse" + ) + + gl_entries.append( + doc.get_gl_dict( + { + "account": _inv_dict["account"], + "against": _inv_dict_from_warehouse["account"], + "cost_center": item.cost_center, + "project": item.project or doc.project, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "debit": warehouse_debit_amount, + "debit_in_transaction_currency": item.net_amount, + }, + _inv_dict["account_currency"], + item=item, + ) + ) + + credit_amount = item.base_net_amount + if doc.is_internal_supplier and item.valuation_rate: + credit_amount = flt(item.valuation_rate * item.stock_qty) + + # Intentionally passed negative debit amount to avoid incorrect GL Entry validation + gl_entries.append( + doc.get_gl_dict( + { + "account": _inv_dict_from_warehouse["account"], + "against": _inv_dict["account"], + "cost_center": item.cost_center, + "project": item.project or doc.project, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "debit": -1 * flt(credit_amount, item.precision("base_net_amount")), + "debit_in_transaction_currency": item.net_amount, + }, + _inv_dict_from_warehouse["account_currency"], + item=item, + ) + ) + + if not doc.is_internal_transfer(): + gl_entries.append( + doc.get_gl_dict( + { + "account": item.expense_account, + "against": doc.supplier, + "debit": flt(item.base_net_amount, item.precision("base_net_amount")), + "debit_in_transaction_currency": item.net_amount, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "cost_center": item.cost_center, + "project": item.project, + }, + account_currency, + item=item, + ) + ) + + else: + if not doc.is_internal_transfer(): + gl_entries.append( + doc.get_gl_dict( + { + "account": item.expense_account, + "against": doc.supplier, + "debit": warehouse_debit_amount, + "debit_in_transaction_currency": flt( + warehouse_debit_amount / doc.conversion_rate, + item.precision("net_amount"), + ), + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + # Amount added through landed-cost-voucher + if landed_cost_entries: + if (item.item_code, item.name) in landed_cost_entries: + for account, base_amount in landed_cost_entries[ + (item.item_code, item.name) + ].items(): + gl_entries.append( + doc.get_gl_dict( + { + "account": account, + "against": item.expense_account, + "cost_center": item.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit": flt(base_amount["base_amount"]), + "credit_in_account_currency": flt(base_amount["amount"]), + "credit_in_transaction_currency": item.net_amount, + "project": item.project or doc.project, + }, + item=item, + ) + ) + + # sub-contracting warehouse + if flt(item.rm_supp_cost): + supplier_wh_dict = doc.get_inventory_account_dict( + item, inventory_account_map, "supplier_warehouse" + ) + supplier_inventory_account = supplier_wh_dict["account"] + if not supplier_inventory_account: + frappe.throw( + _("Please set account in Warehouse {0}").format(doc.supplier_warehouse) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": supplier_inventory_account, + "against": item.expense_account, + "cost_center": item.cost_center, + "project": item.project or doc.project, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit": flt(item.rm_supp_cost), + "credit_in_transaction_currency": item.net_amount, + }, + supplier_wh_dict["account_currency"], + item=item, + ) + ) + + else: + expense_account = ( + item.expense_account + if (not item.enable_deferred_expense or doc.is_return) + else item.deferred_expense_account + ) + account_currency = get_account_currency(expense_account) + amount, base_amount = doc.get_amount_and_base_amount(item, None) + + if provisional_accounting_for_non_stock_items: + self.make_provisional_gl_entry(gl_entries, item) + + if not doc.is_internal_transfer(): + gl_entries.append( + doc.get_gl_dict( + { + "account": expense_account, + "against": doc.supplier, + "debit": base_amount, + "debit_in_transaction_currency": amount, + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + # check if the exchange rate has changed + if ( + not adjust_incoming_rate + and item.get("purchase_receipt") + and doc.auto_accounting_for_stock + ): + if ( + exchange_rate_map[item.purchase_receipt] + and doc.conversion_rate != exchange_rate_map[item.purchase_receipt] + and item.net_rate == net_rate_map[item.pr_detail] + and item.item_code in stock_items + ): + discrepancy_caused_by_exchange_rate_difference = ( + item.qty * item.net_rate + ) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate) + + gl_entries.append( + doc.get_gl_dict( + { + "account": expense_account, + "against": doc.supplier, + "debit": discrepancy_caused_by_exchange_rate_difference, + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.get_company_default("exchange_gain_loss_account"), + "against": doc.supplier, + "credit": discrepancy_caused_by_exchange_rate_difference, + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + if ( + doc.auto_accounting_for_stock + and doc.is_opening == "No" + and item.item_code in stock_items + and item.item_tax_amount + ): + # Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt + if item.purchase_receipt and valuation_tax_accounts: + negative_expense_booked_in_pr = frappe.db.sql( + """select name from `tabGL Entry` + where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""", + (item.purchase_receipt, valuation_tax_accounts), + ) + + ( + doc.get_company_default("asset_received_but_not_billed") + if item.is_fixed_asset + else doc.stock_received_but_not_billed + ) + + if not negative_expense_booked_in_pr: + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.stock_received_but_not_billed, + "against": doc.supplier, + "debit": flt(item.item_tax_amount, item.precision("item_tax_amount")), + "debit_in_transaction_currency": flt( + item.item_tax_amount / doc.conversion_rate, + item.precision("item_tax_amount"), + ), + "remarks": doc.remarks or _("Accounting Entry for Stock"), + "cost_center": doc.cost_center, + "project": item.project or doc.project, + }, + item=item, + ) + ) + doc.negative_expense_to_be_booked += flt( + item.item_tax_amount, item.precision("item_tax_amount") + ) + + if item.is_fixed_asset and item.landed_cost_voucher_amount: + self.update_net_purchase_amount_for_linked_assets(item) + + def get_provisional_accounts(self): + doc = self.doc + self.provisional_accounts = frappe._dict() + linked_purchase_receipts = {d.purchase_receipt for d in doc.items if d.purchase_receipt} + if not linked_purchase_receipts: + return + + pr_items = frappe.get_all( + "Purchase Receipt Item", + filters={"parent": ("in", linked_purchase_receipts)}, + fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"], + ) + default_provisional_account = doc.get_company_default("default_provisional_account") + provisional_accounts = { + d.provisional_expense_account if d.provisional_expense_account else default_provisional_account + for d in pr_items + } + + provisional_gl_entries = frappe.get_all( + "GL Entry", + filters={ + "voucher_type": "Purchase Receipt", + "voucher_no": ("in", linked_purchase_receipts), + "account": ("in", provisional_accounts), + "is_cancelled": 0, + }, + fields=["voucher_detail_no"], + ) + rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries] + for item in pr_items: + self.provisional_accounts[item.name] = { + "provisional_account": item.provisional_expense_account or default_provisional_account, + "qty": item.qty, + "base_rate": item.base_rate, + "rate": item.rate, + "has_provisional_entry": item.name in rows_with_provisional_entries, + } + + def make_provisional_gl_entry(self, gl_entries, item): + if item.purchase_receipt: + pr_item = self.provisional_accounts.get(item.pr_detail, {}) + if pr_item.get("has_provisional_entry"): + purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt) + + # Intentionally passing purchase invoice item to handle partial billing + purchase_receipt_doc.add_provisional_gl_entry( + item, + gl_entries, + self.doc.posting_date, + pr_item.get("provisional_account"), + reverse=1, + item_amount=( + (min(item.qty, pr_item.get("qty")) * pr_item.get("rate")) + * purchase_receipt_doc.get("conversion_rate") + ), + ) + + def update_net_purchase_amount_for_linked_assets(self, item): + doc = self.doc + assets = frappe.db.get_all( + "Asset", + filters={ + "purchase_invoice": doc.name, + "item_code": item.item_code, + "purchase_invoice_item": ("in", [item.name, ""]), + }, + fields=["name", "asset_quantity"], + ) + for asset in assets: + purchase_amount = flt(item.valuation_rate) * asset.asset_quantity + frappe.db.set_value( + "Asset", + asset.name, + { + "net_purchase_amount": purchase_amount, + "purchase_amount": purchase_amount, + }, + ) + + def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency): + doc = self.doc + net_amt_precision = item.precision("base_net_amount") + val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9 + + warehouse_debit_amount = flt( + flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor), + net_amt_precision, + ) + + if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against): + net_rate = item.base_net_amount + if item.sales_incoming_rate: + net_rate = item.qty * item.sales_incoming_rate + + stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount) + warehouse_debit_amount = flt( + voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision + ) + + if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision): + cost_of_goods_sold_account = doc.get_company_default("default_expense_account") + stock_adjustment_amt = stock_amount - warehouse_debit_amount + + gl_entries.append( + doc.get_gl_dict( + { + "account": cost_of_goods_sold_account, + "against": item.expense_account, + "debit": stock_adjustment_amt, + "debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate, + "remarks": doc.get("remarks") or _("Stock Adjustment"), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + elif ( + doc.update_stock + and voucher_wise_stock_value.get((item.name, item.warehouse)) + and warehouse_debit_amount + != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) + ): + cost_of_goods_sold_account = doc.get_company_default("default_expense_account") + stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) + stock_adjustment_amt = warehouse_debit_amount - stock_amount + + gl_entries.append( + doc.get_gl_dict( + { + "account": cost_of_goods_sold_account, + "against": item.expense_account, + "debit": stock_adjustment_amt, + "debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate, + "remarks": doc.get("remarks") or _("Stock Adjustment"), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + warehouse_debit_amount = stock_amount + + return warehouse_debit_amount + def make_tax_gl_entries(self, gl_entries): doc = self.doc valuation_tax = {} From 8677e2df401deaa0a069314e6a192bdf64545628 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 08:40:26 +0530 Subject: [PATCH 10/65] docs: mark Phase 3 as DONE in refactor spec --- specs/accounts_refactor_spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 7d0eac8c650..be7de66b89b 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -74,8 +74,8 @@ Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validato ### Phase 2 — Pilot composer on Sales Invoice only — DONE Added `BaseGLComposer` (minimal: holds `self.doc`) and `SalesInvoiceGLComposer`. SI's `get_gl_entries` is a thin shim delegating to `SalesInvoiceGLComposer(self).compose()`. All 11 SI-specific row builders (make_customer/tax/item/internal_transfer/pos/loyalty/write_off/rounding GL entries, stock_delivered_but_not_billed, get_gl_entries_for_fixed_asset, get_gle_for_change_amount) moved onto the composer and operate on `self.doc`. The `super().get_gl_entries()` stock-expense call became `super(SalesInvoice, doc).get_gl_entries()` (MRO-faithful). Bucket-A shared helpers (`get_gl_dict`, `make_discount_gl_entries`, `make_precision_loss_gl_entry`, `set_transaction_currency_and_rate_in_gl_map`, `get_tax_amounts`, `get_amount_and_base_amount`) **stay on the controller** — they're still called via `self.doc` and only lift to `BaseGLComposer` once all doctypes use composers (can't move while other doctypes inherit them). Verified: 12 snapshots + 10 existing SI tests (perpetual `super()`, POS change, write-off, returns, fixed-asset disposal/regain, internal transfer, loyalty) all green. -### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) — IN PROGRESS -Added `PurchaseInvoiceGLComposer` (scaffolding: compose() = the moved get_gl_entries orchestration; PI.get_gl_entries is a thin shim). **Decision after comparing SI and PI: keep `BaseGLComposer` minimal** (`self.doc` + abstract `compose`). The two flows differ too much to share a template — different step order (SI tax→item, PI item→tax), different builders (SI: discount/loyalty/POS/SDBNB; PI: tax-withholding/payment/purchase-expense), and a per-doctype `make_regional_gl_entries`. Forcing a template would be hook-heavy and risk behavior changes. Revisit base-lifting only when a 3rd+ doctype reveals a real common shape. Verified: 12 snapshots + 6 existing PI GL tests (perpetual inventory, non-stock, return, update_stock, tax withholding, provisional) green. (PI row-builder method migration onto the composer, mirroring SI, still pending.) +### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) — DONE +Added `PurchaseInvoiceGLComposer` with all 13 PI GL builders migrated (make_supplier_gl_entry, add_supplier_gl_entry, make_item_gl_entries, make_stock_adjustment_entry, get_provisional_accounts, make_provisional_gl_entry, update_net_purchase_amount_for_linked_assets, make_tax_gl_entries, make_internal_transfer_gl_entries, make_gl_entries_for_tax_withholding, make_payment_gl_entries, make_write_off_gl_entry, make_gle_for_rounding_adjustment). PI.get_gl_entries is a thin shim. **Decision after comparing SI and PI: keep `BaseGLComposer` minimal** (`self.doc` + abstract `compose`). The two flows differ too much to share a template — different step order, different builders, per-doctype `make_regional_gl_entries`. Revisit base-lifting only when a 3rd+ doctype reveals a real common shape. Remaining on doc: Bucket-A helpers (`make_precision_loss_gl_entry`, `set_transaction_currency_and_rate_in_gl_map`, `get_gl_dict`, `get_tax_amounts`, `get_amount_and_base_amount`) and inherited `set_gl_entry_for_purchase_expense`. Verified: 12 snapshots + 80/81 existing PI tests green (1 pre-existing failure in `test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item`, unrelated to refactoring). ### Phase 4 — Roll out composer to remaining GL-posting doctypes Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated. From 90801550eb16a04585b51ad39e81a5e5bf8db2b5 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 12:42:43 +0530 Subject: [PATCH 11/65] test: add Payment Entry GL characterization snapshots Extend the Phase-0 GL safety net with five representative Payment Entry scenarios (receive against SI, pay against PI, with deductions, with taxes, multi-currency) ahead of moving PE onto the composer. --- .../gl_snapshots/pe_multi_currency.json | 30 ++++++ .../gl_snapshots/pe_pay_against_pi.json | 30 ++++++ .../gl_snapshots/pe_receive_against_si.json | 30 ++++++ .../gl_snapshots/pe_with_deductions.json | 58 ++++++++++++ .../accounts/gl_snapshots/pe_with_taxes.json | 44 +++++++++ erpnext/accounts/test_gl_characterization.py | 92 +++++++++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 erpnext/accounts/gl_snapshots/pe_multi_currency.json create mode 100644 erpnext/accounts/gl_snapshots/pe_pay_against_pi.json create mode 100644 erpnext/accounts/gl_snapshots/pe_receive_against_si.json create mode 100644 erpnext/accounts/gl_snapshots/pe_with_deductions.json create mode 100644 erpnext/accounts/gl_snapshots/pe_with_taxes.json diff --git a/erpnext/accounts/gl_snapshots/pe_multi_currency.json b/erpnext/accounts/gl_snapshots/pe_multi_currency.json new file mode 100644 index 00000000000..e10a073a58a --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pe_multi_currency.json @@ -0,0 +1,30 @@ +[ + { + "account": "_Test Bank - _TC", + "account_currency": "INR", + "against": "_Test Supplier USD", + "cost_center": null, + "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" + }, + { + "account": "_Test Payable USD - _TC", + "account_currency": "USD", + "against": "_Test Bank - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1000.0, + "debit_in_account_currency": 12.5, + "is_opening": "No", + "party": "_Test Supplier USD", + "party_type": "Supplier", + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/pe_pay_against_pi.json b/erpnext/accounts/gl_snapshots/pe_pay_against_pi.json new file mode 100644 index 00000000000..d3953462b50 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pe_pay_against_pi.json @@ -0,0 +1,30 @@ +[ + { + "account": "Creditors - _TC", + "account_currency": "INR", + "against": "_Test Bank - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 250.0, + "debit_in_account_currency": 250.0, + "is_opening": "No", + "party": "_Test Supplier", + "party_type": "Supplier", + "posting_date": "2024-01-15" + }, + { + "account": "_Test Bank - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": null, + "credit": 250.0, + "credit_in_account_currency": 250.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/gl_snapshots/pe_receive_against_si.json b/erpnext/accounts/gl_snapshots/pe_receive_against_si.json new file mode 100644 index 00000000000..61650a490cc --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pe_receive_against_si.json @@ -0,0 +1,30 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "_Test Cash - _TC", + "cost_center": null, + "credit": 1000.0, + "credit_in_account_currency": 1000.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "_Test Cash - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": null, + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/pe_with_deductions.json b/erpnext/accounts/gl_snapshots/pe_with_deductions.json new file mode 100644 index 00000000000..df86e1e11f5 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pe_with_deductions.json @@ -0,0 +1,58 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "_Test Cash - _TC", + "cost_center": null, + "credit": 1000.0, + "credit_in_account_currency": 1000.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "_Test Cash - _TC", + "cost_center": null, + "credit": 50.0, + "credit_in_account_currency": 50.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Write Off - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 50.0, + "debit_in_account_currency": 50.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "_Test Cash - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": null, + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/pe_with_taxes.json b/erpnext/accounts/gl_snapshots/pe_with_taxes.json new file mode 100644 index 00000000000..70d167e826b --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pe_with_taxes.json @@ -0,0 +1,44 @@ +[ + { + "account": "Creditors - _TC", + "account_currency": "INR", + "against": "_Test Bank - _TC", + "cost_center": null, + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 1000.0, + "debit_in_account_currency": 1000.0, + "is_opening": "No", + "party": "_Test Supplier", + "party_type": "Supplier", + "posting_date": "2024-01-15" + }, + { + "account": "_Test Account Service Tax - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 100.0, + "debit_in_account_currency": 100.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + }, + { + "account": "_Test Bank - _TC", + "account_currency": "INR", + "against": "_Test Supplier", + "cost_center": null, + "credit": 1100.0, + "credit_in_account_currency": 1100.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py index 891d906e794..286052036ae 100644 --- a/erpnext/accounts/test_gl_characterization.py +++ b/erpnext/accounts/test_gl_characterization.py @@ -20,6 +20,7 @@ from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( set_default_account_for_mode_of_payment, ) +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return @@ -40,6 +41,30 @@ def make_dated_purchase_invoice(**args): return pi +def make_dated_payment_entry(**args): + """Standalone Payment Entry (no invoice reference) on a fixed posting date. + + Mirrors test_payment_entry.create_payment_entry without importing that test + module, whose import drags in test-record dependencies that conflict during + discovery.""" + pe = frappe.new_doc("Payment Entry") + pe.company = COMPANY + pe.payment_type = args.get("payment_type") or "Pay" + pe.party_type = args.get("party_type") or "Supplier" + pe.party = args.get("party") or "_Test Supplier" + pe.paid_from = args.get("paid_from") or "_Test Bank - _TC" + pe.paid_to = args.get("paid_to") or "Creditors - _TC" + pe.paid_amount = args.get("paid_amount") or 1000 + pe.setup_party_account_field() + pe.set_missing_values() + pe.set_exchange_rate() + pe.received_amount = pe.paid_amount / pe.target_exchange_rate + pe.reference_no = "Test001" + pe.posting_date = POSTING_DATE + pe.reference_date = POSTING_DATE + return pe + + class TestGLCharacterization(IntegrationTestCase): @classmethod def setUpClass(cls): @@ -187,3 +212,70 @@ class TestGLCharacterization(IntegrationTestCase): debit_note.insert() debit_note.submit() assert_gl_snapshot(self, "pi_return", "Purchase Invoice", debit_note.name) + + def test_pe_receive_against_si(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100) + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + pe.posting_date = POSTING_DATE + pe.reference_no = "PE-REC-1" + pe.reference_date = POSTING_DATE + pe.insert() + pe.submit() + assert_gl_snapshot(self, "pe_receive_against_si", "Payment Entry", pe.name) + + def test_pe_pay_against_pi(self): + pi = make_dated_purchase_invoice(qty=5, rate=50) + pi.insert() + pi.submit() + pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") + pe.posting_date = POSTING_DATE + pe.reference_no = "PE-PAY-1" + pe.reference_date = POSTING_DATE + pe.insert() + pe.submit() + assert_gl_snapshot(self, "pe_pay_against_pi", "Payment Entry", pe.name) + + def test_pe_with_deductions(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100) + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + pe.posting_date = POSTING_DATE + pe.reference_no = "PE-DED-1" + pe.reference_date = POSTING_DATE + pe.received_amount = pe.received_amount - 50 + pe.append( + "deductions", + { + "account": "Write Off - _TC", + "cost_center": "_Test Cost Center - _TC", + "amount": 50, + }, + ) + pe.insert() + pe.submit() + assert_gl_snapshot(self, "pe_with_deductions", "Payment Entry", pe.name) + + def test_pe_with_taxes(self): + frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1) + pe = make_dated_payment_entry(party="_Test Supplier", paid_to="Creditors - _TC") + pe.append( + "taxes", + { + "account_head": "_Test Account Service Tax - _TC", + "charge_type": "Actual", + "tax_amount": 100, + "add_deduct_tax": "Add", + "description": "Service Tax", + "cost_center": "_Test Cost Center - _TC", + }, + ) + pe.save() + pe.submit() + assert_gl_snapshot(self, "pe_with_taxes", "Payment Entry", pe.name) + + def test_pe_multi_currency(self): + pe = make_dated_payment_entry(party="_Test Supplier USD", paid_to="_Test Payable USD - _TC") + pe.target_exchange_rate = 80 + pe.received_amount = pe.paid_amount / pe.target_exchange_rate + pe.save() + pe.submit() + assert_gl_snapshot(self, "pe_multi_currency", "Payment Entry", pe.name) From b38106174266a4413317399512c958e71f1ef543 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 12:42:52 +0530 Subject: [PATCH 12/65] refactor: introduce Payment Entry GL composer Move the Payment Entry GL row builders (party, bank, deductions, tax) onto a new PaymentEntryGLComposer(BaseGLComposer); compose() mirrors the former build_gl_map, which is now a thin shim delegating to the composer. The builders operate on self.doc and shared helpers stay on the document. Advance-posting builders are left on the controller; they post in a separate pass and move with the advances service in a later phase. --- .../doctype/payment_entry/payment_entry.py | 264 +--------------- .../payment_entry/services/__init__.py | 0 .../payment_entry/services/gl_composer.py | 293 ++++++++++++++++++ 3 files changed, 295 insertions(+), 262 deletions(-) create mode 100644 erpnext/accounts/doctype/payment_entry/services/__init__.py create mode 100644 erpnext/accounts/doctype/payment_entry/services/gl_composer.py diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 357df56c5e9..69a9ccf817c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1287,17 +1287,9 @@ class PaymentEntry(AccountsController): self.transaction_exchange_rate = self.target_exchange_rate def build_gl_map(self): - if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"): - self.setup_party_account_field() - self.set_transaction_currency_and_rate() + from erpnext.accounts.doctype.payment_entry.services.gl_composer import PaymentEntryGLComposer - gl_entries = [] - self.add_party_gl_entries(gl_entries) - self.add_bank_gl_entries(gl_entries) - self.add_deductions_gl_entries(gl_entries) - self.add_tax_gl_entries(gl_entries) - add_regional_gl_entries(gl_entries, self) - return gl_entries + return PaymentEntryGLComposer(self).compose() def make_gl_entries(self, cancel=0, adv_adj=0): gl_entries = self.build_gl_map() @@ -1313,132 +1305,6 @@ class PaymentEntry(AccountsController): self.make_advance_gl_entries(cancel=cancel) - def add_party_gl_entries(self, gl_entries): - if not self.party_account: - return - - advance_payment_doctypes = get_advance_payment_doctypes() - if self.payment_type == "Receive": - against_account = self.paid_to - else: - against_account = self.paid_from - - party_account_type = frappe.db.get_value("Party Type", self.party_type, "account_type") - - party_gl_dict = self.get_gl_dict( - { - "account": self.party_account, - "party_type": self.party_type, - "party": self.party, - "against": against_account, - "account_currency": self.party_account_currency, - "cost_center": self.cost_center, - }, - item=self, - ) - - for d in self.get("references"): - # re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse - dr_or_cr = "credit" if self.payment_type == "Receive" else "debit" - cost_center = self.cost_center - if d.reference_doctype == "Sales Invoice" and not cost_center: - cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center") - - gle = party_gl_dict.copy() - - allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d) - - if ( - d.reference_doctype in ["Sales Invoice", "Purchase Invoice"] - and d.allocated_amount < 0 - and ( - (party_account_type == "Receivable" and self.payment_type == "Pay") - or (party_account_type == "Payable" and self.payment_type == "Receive") - ) - ): - # reversing dr_cr because because it will get reversed in gl processing due to negative amount - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - - gle.update( - self.get_gl_dict( - { - "account": self.party_account, - "party_type": self.party_type, - "party": self.party, - "against": against_account, - "account_currency": self.party_account_currency, - "cost_center": cost_center, - dr_or_cr + "_in_account_currency": d.allocated_amount, - dr_or_cr: allocated_amount_in_company_currency, - dr_or_cr + "_in_transaction_currency": d.allocated_amount - if self.transaction_currency == self.party_account_currency - else allocated_amount_in_company_currency / self.transaction_exchange_rate, - "advance_voucher_type": d.advance_voucher_type, - "advance_voucher_no": d.advance_voucher_no, - "transaction_exchange_rate": self.target_exchange_rate, - }, - item=self, - ) - ) - - if d.reference_doctype in advance_payment_doctypes: - # advance reference - gle.update( - { - "against_voucher_type": self.doctype, - "against_voucher": self.name, - "advance_voucher_type": d.reference_doctype, - "advance_voucher_no": d.reference_name, - } - ) - - elif self.book_advance_payments_in_separate_party_account: - # Do not reference Invoices while Advance is in separate party account - gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name}) - else: - gle.update( - { - "against_voucher_type": d.reference_doctype, - "against_voucher": d.reference_name, - } - ) - - gl_entries.append(gle) - - if self.unallocated_amount: - dr_or_cr = "credit" if self.payment_type == "Receive" else "debit" - exchange_rate = self.get_exchange_rate() - base_unallocated_amount = self.unallocated_amount * exchange_rate - - gle = party_gl_dict.copy() - - gle.update( - self.get_gl_dict( - { - "account": self.party_account, - "party_type": self.party_type, - "party": self.party, - "against": against_account, - "account_currency": self.party_account_currency, - "cost_center": self.cost_center, - dr_or_cr + "_in_account_currency": self.unallocated_amount, - dr_or_cr + "_in_transaction_currency": self.unallocated_amount - if self.party_account_currency == self.transaction_currency - else base_unallocated_amount / self.transaction_exchange_rate, - dr_or_cr: base_unallocated_amount, - }, - item=self, - ) - ) - if self.book_advance_payments_in_separate_party_account: - gle.update( - { - "against_voucher_type": "Payment Entry", - "against_voucher": self.name, - } - ) - gl_entries.append(gle) - def make_advance_gl_entries( self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes" ): @@ -1560,132 +1426,6 @@ class PaymentEntry(AccountsController): ) gl_entries.append(gle) - def add_bank_gl_entries(self, gl_entries): - if self.payment_type in ("Pay", "Internal Transfer"): - gl_entries.append( - self.get_gl_dict( - { - "account": self.paid_from, - "account_currency": self.paid_from_account_currency, - "against": self.party if self.payment_type == "Pay" else self.paid_to, - "credit_in_account_currency": self.paid_amount, - "credit_in_transaction_currency": self.paid_amount - if self.paid_from_account_currency == self.transaction_currency - else self.base_paid_amount / self.transaction_exchange_rate, - "credit": self.base_paid_amount, - "cost_center": self.cost_center, - "post_net_value": True, - }, - item=self, - ) - ) - if self.payment_type in ("Receive", "Internal Transfer"): - gl_entries.append( - self.get_gl_dict( - { - "account": self.paid_to, - "account_currency": self.paid_to_account_currency, - "against": self.party if self.payment_type == "Receive" else self.paid_from, - "debit_in_account_currency": self.received_amount, - "debit_in_transaction_currency": self.received_amount - if self.paid_to_account_currency == self.transaction_currency - else self.base_received_amount / self.transaction_exchange_rate, - "debit": self.base_received_amount, - "cost_center": self.cost_center, - }, - item=self, - ) - ) - - def add_tax_gl_entries(self, gl_entries): - for d in self.get("taxes"): - account_currency = get_account_currency(d.account_head) - if account_currency != self.company_currency: - frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency)) - - if self.payment_type in ("Pay", "Internal Transfer"): - dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit" - rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit" - against = self.party or self.paid_from - elif self.payment_type == "Receive": - dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit" - rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit" - against = self.party or self.paid_to - - payment_account = self.get_party_account_for_taxes() - tax_amount = d.tax_amount - base_tax_amount = d.base_tax_amount - - gl_entries.append( - self.get_gl_dict( - { - "account": d.account_head, - "against": against, - dr_or_cr: tax_amount, - dr_or_cr + "_in_account_currency": base_tax_amount - if account_currency == self.company_currency - else d.tax_amount, - dr_or_cr + "_in_transaction_currency": base_tax_amount - / self.transaction_exchange_rate, - "cost_center": d.cost_center, - "post_net_value": True, - }, - account_currency, - item=d, - ) - ) - - if not d.included_in_paid_amount: - if get_account_currency(payment_account) != self.company_currency: - if self.payment_type == "Receive": - exchange_rate = self.target_exchange_rate - elif self.payment_type in ["Pay", "Internal Transfer"]: - exchange_rate = self.source_exchange_rate - base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount")) - - gl_entries.append( - self.get_gl_dict( - { - "account": payment_account, - "against": against, - rev_dr_or_cr: tax_amount, - rev_dr_or_cr + "_in_account_currency": base_tax_amount - if account_currency == self.company_currency - else d.tax_amount, - rev_dr_or_cr + "_in_transaction_currency": base_tax_amount - / self.transaction_exchange_rate, - "cost_center": self.cost_center, - "post_net_value": True, - }, - account_currency, - item=d, - ) - ) - - def add_deductions_gl_entries(self, gl_entries): - for d in self.get("deductions"): - if not d.amount: - continue - - account_currency = get_account_currency(d.account) - if account_currency != self.company_currency: - frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency)) - - gl_entries.append( - self.get_gl_dict( - { - "account": d.account, - "account_currency": account_currency, - "against": self.party or self.paid_from, - "debit_in_account_currency": d.amount, - "debit_in_transaction_currency": d.amount / self.transaction_exchange_rate, - "debit": d.amount, - "cost_center": d.cost_center, - }, - item=d, - ) - ) - def get_party_account_for_taxes(self): if self.payment_type == "Receive": return self.paid_to diff --git a/erpnext/accounts/doctype/payment_entry/services/__init__.py b/erpnext/accounts/doctype/payment_entry/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/payment_entry/services/gl_composer.py b/erpnext/accounts/doctype/payment_entry/services/gl_composer.py new file mode 100644 index 00000000000..8e13bab3ede --- /dev/null +++ b/erpnext/accounts/doctype/payment_entry/services/gl_composer.py @@ -0,0 +1,293 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes + + +class PaymentEntryGLComposer(BaseGLComposer): + """Assembles the GL entries for a Payment Entry. + + The voucher-specific row builders live here and operate on ``self.doc``. + Shared helpers (get_gl_dict, calculate_base_allocated_amount_for_reference, + get_exchange_rate, get_party_account_for_taxes) remain on the document for + now and are invoked via ``self.doc``. The advance-posting builders stay on + the document; they post separately from this compose pass and move with the + advances service in a later phase. + """ + + def compose(self): + from erpnext.accounts.doctype.payment_entry.payment_entry import add_regional_gl_entries + + doc = self.doc + if doc.payment_type in ("Receive", "Pay") and not doc.get("party_account_field"): + doc.setup_party_account_field() + doc.set_transaction_currency_and_rate() + + gl_entries = [] + self.add_party_gl_entries(gl_entries) + self.add_bank_gl_entries(gl_entries) + self.add_deductions_gl_entries(gl_entries) + self.add_tax_gl_entries(gl_entries) + add_regional_gl_entries(gl_entries, doc) + return gl_entries + + def add_party_gl_entries(self, gl_entries): + doc = self.doc + if not doc.party_account: + return + + advance_payment_doctypes = get_advance_payment_doctypes() + if doc.payment_type == "Receive": + against_account = doc.paid_to + else: + against_account = doc.paid_from + + party_account_type = frappe.db.get_value("Party Type", doc.party_type, "account_type") + + party_gl_dict = doc.get_gl_dict( + { + "account": doc.party_account, + "party_type": doc.party_type, + "party": doc.party, + "against": against_account, + "account_currency": doc.party_account_currency, + "cost_center": doc.cost_center, + }, + item=doc, + ) + + for d in doc.get("references"): + # re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse + dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit" + cost_center = doc.cost_center + if d.reference_doctype == "Sales Invoice" and not cost_center: + cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center") + + gle = party_gl_dict.copy() + + allocated_amount_in_company_currency = doc.calculate_base_allocated_amount_for_reference(d) + + if ( + d.reference_doctype in ["Sales Invoice", "Purchase Invoice"] + and d.allocated_amount < 0 + and ( + (party_account_type == "Receivable" and doc.payment_type == "Pay") + or (party_account_type == "Payable" and doc.payment_type == "Receive") + ) + ): + # reversing dr_cr because because it will get reversed in gl processing due to negative amount + dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + gle.update( + doc.get_gl_dict( + { + "account": doc.party_account, + "party_type": doc.party_type, + "party": doc.party, + "against": against_account, + "account_currency": doc.party_account_currency, + "cost_center": cost_center, + dr_or_cr + "_in_account_currency": d.allocated_amount, + dr_or_cr: allocated_amount_in_company_currency, + dr_or_cr + "_in_transaction_currency": d.allocated_amount + if doc.transaction_currency == doc.party_account_currency + else allocated_amount_in_company_currency / doc.transaction_exchange_rate, + "advance_voucher_type": d.advance_voucher_type, + "advance_voucher_no": d.advance_voucher_no, + "transaction_exchange_rate": doc.target_exchange_rate, + }, + item=doc, + ) + ) + + if d.reference_doctype in advance_payment_doctypes: + # advance reference + gle.update( + { + "against_voucher_type": doc.doctype, + "against_voucher": doc.name, + "advance_voucher_type": d.reference_doctype, + "advance_voucher_no": d.reference_name, + } + ) + + elif doc.book_advance_payments_in_separate_party_account: + # Do not reference Invoices while Advance is in separate party account + gle.update({"against_voucher_type": doc.doctype, "against_voucher": doc.name}) + else: + gle.update( + { + "against_voucher_type": d.reference_doctype, + "against_voucher": d.reference_name, + } + ) + + gl_entries.append(gle) + + if doc.unallocated_amount: + dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit" + exchange_rate = doc.get_exchange_rate() + base_unallocated_amount = doc.unallocated_amount * exchange_rate + + gle = party_gl_dict.copy() + + gle.update( + doc.get_gl_dict( + { + "account": doc.party_account, + "party_type": doc.party_type, + "party": doc.party, + "against": against_account, + "account_currency": doc.party_account_currency, + "cost_center": doc.cost_center, + dr_or_cr + "_in_account_currency": doc.unallocated_amount, + dr_or_cr + "_in_transaction_currency": doc.unallocated_amount + if doc.party_account_currency == doc.transaction_currency + else base_unallocated_amount / doc.transaction_exchange_rate, + dr_or_cr: base_unallocated_amount, + }, + item=doc, + ) + ) + if doc.book_advance_payments_in_separate_party_account: + gle.update( + { + "against_voucher_type": "Payment Entry", + "against_voucher": doc.name, + } + ) + gl_entries.append(gle) + + def add_bank_gl_entries(self, gl_entries): + doc = self.doc + if doc.payment_type in ("Pay", "Internal Transfer"): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.paid_from, + "account_currency": doc.paid_from_account_currency, + "against": doc.party if doc.payment_type == "Pay" else doc.paid_to, + "credit_in_account_currency": doc.paid_amount, + "credit_in_transaction_currency": doc.paid_amount + if doc.paid_from_account_currency == doc.transaction_currency + else doc.base_paid_amount / doc.transaction_exchange_rate, + "credit": doc.base_paid_amount, + "cost_center": doc.cost_center, + "post_net_value": True, + }, + item=doc, + ) + ) + if doc.payment_type in ("Receive", "Internal Transfer"): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.paid_to, + "account_currency": doc.paid_to_account_currency, + "against": doc.party if doc.payment_type == "Receive" else doc.paid_from, + "debit_in_account_currency": doc.received_amount, + "debit_in_transaction_currency": doc.received_amount + if doc.paid_to_account_currency == doc.transaction_currency + else doc.base_received_amount / doc.transaction_exchange_rate, + "debit": doc.base_received_amount, + "cost_center": doc.cost_center, + }, + item=doc, + ) + ) + + def add_tax_gl_entries(self, gl_entries): + doc = self.doc + for d in doc.get("taxes"): + account_currency = get_account_currency(d.account_head) + if account_currency != doc.company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, doc.company_currency)) + + if doc.payment_type in ("Pay", "Internal Transfer"): + dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit" + rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit" + against = doc.party or doc.paid_from + elif doc.payment_type == "Receive": + dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit" + rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit" + against = doc.party or doc.paid_to + + payment_account = doc.get_party_account_for_taxes() + tax_amount = d.tax_amount + base_tax_amount = d.base_tax_amount + + gl_entries.append( + doc.get_gl_dict( + { + "account": d.account_head, + "against": against, + dr_or_cr: tax_amount, + dr_or_cr + "_in_account_currency": base_tax_amount + if account_currency == doc.company_currency + else d.tax_amount, + dr_or_cr + "_in_transaction_currency": base_tax_amount + / doc.transaction_exchange_rate, + "cost_center": d.cost_center, + "post_net_value": True, + }, + account_currency, + item=d, + ) + ) + + if not d.included_in_paid_amount: + if get_account_currency(payment_account) != doc.company_currency: + if doc.payment_type == "Receive": + exchange_rate = doc.target_exchange_rate + elif doc.payment_type in ["Pay", "Internal Transfer"]: + exchange_rate = doc.source_exchange_rate + base_tax_amount = flt((tax_amount / exchange_rate), doc.precision("paid_amount")) + + gl_entries.append( + doc.get_gl_dict( + { + "account": payment_account, + "against": against, + rev_dr_or_cr: tax_amount, + rev_dr_or_cr + "_in_account_currency": base_tax_amount + if account_currency == doc.company_currency + else d.tax_amount, + rev_dr_or_cr + "_in_transaction_currency": base_tax_amount + / doc.transaction_exchange_rate, + "cost_center": doc.cost_center, + "post_net_value": True, + }, + account_currency, + item=d, + ) + ) + + def add_deductions_gl_entries(self, gl_entries): + doc = self.doc + for d in doc.get("deductions"): + if not d.amount: + continue + + account_currency = get_account_currency(d.account) + if account_currency != doc.company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(d.account, doc.company_currency)) + + gl_entries.append( + doc.get_gl_dict( + { + "account": d.account, + "account_currency": account_currency, + "against": doc.party or doc.paid_from, + "debit_in_account_currency": d.amount, + "debit_in_transaction_currency": d.amount / doc.transaction_exchange_rate, + "debit": d.amount, + "cost_center": d.cost_center, + }, + item=d, + ) + ) From d775d540c4559cbafff8dffbfdd30b64e61230f0 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 12:42:59 +0530 Subject: [PATCH 13/65] docs: mark Phase 4 Payment Entry as done in refactor spec --- specs/accounts_refactor_spec.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index be7de66b89b..33b4b8ee5fc 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -80,6 +80,8 @@ Added `PurchaseInvoiceGLComposer` with all 13 PI GL builders migrated (make_supp ### Phase 4 — Roll out composer to remaining GL-posting doctypes Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated. +- **Payment Entry — DONE.** Added `payment_entry/services/gl_composer.py` → `PaymentEntryGLComposer(BaseGLComposer)`. `compose()` mirrors the old `build_gl_map` (setup party account field, set txn currency/rate, then party/bank/deductions/tax builders, then `add_regional_gl_entries`). The four row builders (`add_party_gl_entries`, `add_bank_gl_entries`, `add_tax_gl_entries`, `add_deductions_gl_entries`) moved onto the composer and operate on `self.doc`; `build_gl_map` is now a thin shim delegating to the composer. **Advance builders stay on the doc** (`make_advance_gl_entries`, `add_advance_gl_entries`, `get_dr_and_account_for_advances`, `add_advance_gl_for_reference`) — they post in a separate pass inside `make_gl_entries`, not part of `compose()`, and belong to the Phase 5 advances service. Shared helpers (`get_gl_dict`, `calculate_base_allocated_amount_for_reference`, `get_exchange_rate`, `get_party_account_for_taxes`) stay on the doc, called via `self.doc`. Extended the Phase-0 snapshot net with 5 PE scenarios (receive-vs-SI, pay-vs-PI, deductions, taxes, multi-currency). Verified: 17 snapshots byte-identical + 53 existing PE tests green. + ### Phase 5 — Extract `advances.py` Move the advances cluster. After composers, because advances cross-calls the exchange-gain/loss helper now on `BaseGLComposer`. From 473f6e833a94ba3d31828f4b24baeae74d154fb1 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 12:48:57 +0530 Subject: [PATCH 14/65] test: add Journal Entry GL characterization snapshots Extend the Phase-0 GL safety net with three representative Journal Entry scenarios (basic two-line, multi-currency, against a Sales Invoice with party and reference) ahead of moving JE onto the composer. --- .../accounts/gl_snapshots/je_against_si.json | 30 +++++++ erpnext/accounts/gl_snapshots/je_basic.json | 30 +++++++ .../gl_snapshots/je_multi_currency.json | 30 +++++++ erpnext/accounts/test_gl_characterization.py | 83 +++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 erpnext/accounts/gl_snapshots/je_against_si.json create mode 100644 erpnext/accounts/gl_snapshots/je_basic.json create mode 100644 erpnext/accounts/gl_snapshots/je_multi_currency.json diff --git a/erpnext/accounts/gl_snapshots/je_against_si.json b/erpnext/accounts/gl_snapshots/je_against_si.json new file mode 100644 index 00000000000..f4b705e1702 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/je_against_si.json @@ -0,0 +1,30 @@ +[ + { + "account": "Debtors - _TC", + "account_currency": "INR", + "against": "Write Off - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit": 1000.0, + "credit_in_account_currency": 1000.0, + "debit": 0.0, + "debit_in_account_currency": 0.0, + "is_opening": "No", + "party": "_Test Customer", + "party_type": "Customer", + "posting_date": "2024-01-15" + }, + { + "account": "Write Off - _TC", + "account_currency": "INR", + "against": "_Test Customer", + "cost_center": "_Test Cost Center - _TC", + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/je_basic.json b/erpnext/accounts/gl_snapshots/je_basic.json new file mode 100644 index 00000000000..6a9eeb9f8e9 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/je_basic.json @@ -0,0 +1,30 @@ +[ + { + "account": "_Test Bank - _TC", + "account_currency": "INR", + "against": "_Test Cash - _TC", + "cost_center": "_Test Cost Center - _TC", + "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" + }, + { + "account": "_Test Cash - _TC", + "account_currency": "INR", + "against": "_Test Bank - _TC", + "cost_center": "_Test Cost Center - _TC", + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/je_multi_currency.json b/erpnext/accounts/gl_snapshots/je_multi_currency.json new file mode 100644 index 00000000000..fea7c3c3af9 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/je_multi_currency.json @@ -0,0 +1,30 @@ +[ + { + "account": "_Test Bank - _TC", + "account_currency": "INR", + "against": "_Test Bank USD - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit": 7500.0, + "credit_in_account_currency": 7500.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 Bank USD - _TC", + "account_currency": "USD", + "against": "_Test Bank - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit": 0.0, + "credit_in_account_currency": 0.0, + "debit": 7500.0, + "debit_in_account_currency": 100.0, + "is_opening": "No", + "party": null, + "party_type": null, + "posting_date": "2024-01-15" + } +] diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py index 286052036ae..31895f52b93 100644 --- a/erpnext/accounts/test_gl_characterization.py +++ b/erpnext/accounts/test_gl_characterization.py @@ -65,6 +65,20 @@ def make_dated_payment_entry(**args): return pe +def make_dated_journal_entry(accounts, multi_currency=0): + """Journal Entry on a fixed posting date built from explicit account rows. + + Inlined rather than importing test_journal_entry.make_journal_entry, whose + import drags in test-record dependencies that conflict during discovery.""" + jv = frappe.new_doc("Journal Entry") + jv.posting_date = POSTING_DATE + jv.company = COMPANY + jv.remark = "test" + jv.multi_currency = multi_currency + jv.set("accounts", accounts) + return jv + + class TestGLCharacterization(IntegrationTestCase): @classmethod def setUpClass(cls): @@ -279,3 +293,72 @@ class TestGLCharacterization(IntegrationTestCase): pe.save() pe.submit() assert_gl_snapshot(self, "pe_multi_currency", "Payment Entry", pe.name) + + def test_je_basic(self): + jv = make_dated_journal_entry( + [ + { + "account": "_Test Cash - _TC", + "cost_center": "_Test Cost Center - _TC", + "debit_in_account_currency": 1000, + "exchange_rate": 1, + }, + { + "account": "_Test Bank - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit_in_account_currency": 1000, + "exchange_rate": 1, + }, + ] + ) + jv.insert() + jv.submit() + assert_gl_snapshot(self, "je_basic", "Journal Entry", jv.name) + + def test_je_multi_currency(self): + jv = make_dated_journal_entry( + [ + { + "account": "_Test Bank USD - _TC", + "cost_center": "_Test Cost Center - _TC", + "debit_in_account_currency": 100, + "exchange_rate": 75, + }, + { + "account": "_Test Bank - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit_in_account_currency": 7500, + "exchange_rate": 1, + }, + ], + multi_currency=1, + ) + jv.insert() + jv.submit() + assert_gl_snapshot(self, "je_multi_currency", "Journal Entry", jv.name) + + def test_je_against_si(self): + si = create_sales_invoice(posting_date=POSTING_DATE, qty=10, rate=100) + jv = make_dated_journal_entry( + [ + { + "account": "Write Off - _TC", + "cost_center": "_Test Cost Center - _TC", + "debit_in_account_currency": 1000, + "exchange_rate": 1, + }, + { + "account": "Debtors - _TC", + "party_type": "Customer", + "party": CUSTOMER, + "cost_center": "_Test Cost Center - _TC", + "credit_in_account_currency": 1000, + "exchange_rate": 1, + "reference_type": "Sales Invoice", + "reference_name": si.name, + }, + ] + ) + jv.insert() + jv.submit() + assert_gl_snapshot(self, "je_against_si", "Journal Entry", jv.name) From 8f05e0596eb2d2dd452e4b48c2304d91c83e19c1 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 12:49:05 +0530 Subject: [PATCH 15/65] refactor: introduce Journal Entry GL composer Move the Journal Entry GL assembly into a new JournalEntryGLComposer( BaseGLComposer); compose() projects the accounts child rows into GL dicts, mirroring the former build_gl_map, which is now a thin shim delegating to the composer. Drop the now-unused get_advance_payment_doctypes import. --- .../doctype/journal_entry/journal_entry.py | 83 +------------- .../journal_entry/services/__init__.py | 0 .../journal_entry/services/gl_composer.py | 103 ++++++++++++++++++ 3 files changed, 105 insertions(+), 81 deletions(-) create mode 100644 erpnext/accounts/doctype/journal_entry/services/__init__.py create mode 100644 erpnext/accounts/doctype/journal_entry/services/gl_composer.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 908fbb2a376..21d6e4ba486 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -24,7 +24,6 @@ from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, get_account_currency, - get_advance_payment_doctypes, get_balance_on, get_stock_accounts, get_stock_and_account_balance, @@ -1120,87 +1119,9 @@ class JournalEntry(AccountsController): self.total_amount_in_words = money_in_words(amt, currency) def build_gl_map(self): - gl_map = [] + from erpnext.accounts.doctype.journal_entry.services.gl_composer import JournalEntryGLComposer - company_currency = erpnext.get_company_currency(self.company) - self.transaction_currency = company_currency - self.transaction_exchange_rate = 1 - if self.multi_currency: - for row in self.get("accounts"): - if row.account_currency != company_currency: - # Journal assumes the first foreign currency as transaction currency - self.transaction_currency = row.account_currency - self.transaction_exchange_rate = row.exchange_rate - break - - advance_doctypes = get_advance_payment_doctypes() - - for d in self.get("accounts"): - if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"): - r = [d.user_remark, self.remark] - r = [x for x in r if x] - remarks = "\n".join(r) - - row = { - "account": d.account, - "party_type": d.party_type, - "due_date": self.due_date, - "party": d.party, - "against": d.against_account, - "debit": flt(d.debit, d.precision("debit")), - "credit": flt(d.credit, d.precision("credit")), - "account_currency": d.account_currency, - "debit_in_account_currency": flt( - d.debit_in_account_currency, d.precision("debit_in_account_currency") - ), - "credit_in_account_currency": flt( - d.credit_in_account_currency, d.precision("credit_in_account_currency") - ), - "transaction_currency": self.transaction_currency, - "transaction_exchange_rate": self.transaction_exchange_rate, - "debit_in_transaction_currency": flt( - d.debit_in_account_currency, d.precision("debit_in_account_currency") - ) - if self.transaction_currency == d.account_currency - else flt(d.debit, d.precision("debit")) / self.transaction_exchange_rate, - "credit_in_transaction_currency": flt( - d.credit_in_account_currency, d.precision("credit_in_account_currency") - ) - if self.transaction_currency == d.account_currency - else flt(d.credit, d.precision("credit")) / self.transaction_exchange_rate, - "against_voucher_type": d.reference_type, - "against_voucher": d.reference_name, - "remarks": remarks, - "voucher_detail_no": d.reference_detail_no, - "cost_center": d.cost_center, - "project": d.project, - "finance_book": self.finance_book, - "advance_voucher_type": d.advance_voucher_type, - "advance_voucher_no": d.advance_voucher_no, - } - - if d.reference_type in advance_doctypes: - row.update( - { - "against_voucher_type": self.doctype, - "against_voucher": self.name, - "advance_voucher_type": d.reference_type, - "advance_voucher_no": d.reference_name, - } - ) - - # set flag to skip party validation - account_type = frappe.get_cached_value("Account", d.account, "account_type") - if account_type in ["Receivable", "Payable"] and self.party_not_required: - frappe.flags.party_not_required = True - - gl_map.append( - self.get_gl_dict( - row, - item=d, - ) - ) - return gl_map + return JournalEntryGLComposer(self).compose() def make_gl_entries(self, cancel=0, adv_adj=0): from erpnext.accounts.general_ledger import make_gl_entries diff --git a/erpnext/accounts/doctype/journal_entry/services/__init__.py b/erpnext/accounts/doctype/journal_entry/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/journal_entry/services/gl_composer.py b/erpnext/accounts/doctype/journal_entry/services/gl_composer.py new file mode 100644 index 00000000000..a8def33e141 --- /dev/null +++ b/erpnext/accounts/doctype/journal_entry/services/gl_composer.py @@ -0,0 +1,103 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import flt + +import erpnext +from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.utils import get_advance_payment_doctypes + + +class JournalEntryGLComposer(BaseGLComposer): + """Assembles the GL entries for a Journal Entry. + + A Journal Entry already carries its ledger rows in the ``accounts`` child + table, so composing is a straight projection of those rows into GL dicts + via ``self.doc.get_gl_dict``. The transaction currency/rate are resolved + from the first foreign-currency row (mirroring the former build_gl_map). + """ + + def compose(self): + doc = self.doc + gl_map = [] + + company_currency = erpnext.get_company_currency(doc.company) + doc.transaction_currency = company_currency + doc.transaction_exchange_rate = 1 + if doc.multi_currency: + for row in doc.get("accounts"): + if row.account_currency != company_currency: + # Journal assumes the first foreign currency as transaction currency + doc.transaction_currency = row.account_currency + doc.transaction_exchange_rate = row.exchange_rate + break + + advance_doctypes = get_advance_payment_doctypes() + + for d in doc.get("accounts"): + if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"): + r = [d.user_remark, doc.remark] + r = [x for x in r if x] + remarks = "\n".join(r) + + row = { + "account": d.account, + "party_type": d.party_type, + "due_date": doc.due_date, + "party": d.party, + "against": d.against_account, + "debit": flt(d.debit, d.precision("debit")), + "credit": flt(d.credit, d.precision("credit")), + "account_currency": d.account_currency, + "debit_in_account_currency": flt( + d.debit_in_account_currency, d.precision("debit_in_account_currency") + ), + "credit_in_account_currency": flt( + d.credit_in_account_currency, d.precision("credit_in_account_currency") + ), + "transaction_currency": doc.transaction_currency, + "transaction_exchange_rate": doc.transaction_exchange_rate, + "debit_in_transaction_currency": flt( + d.debit_in_account_currency, d.precision("debit_in_account_currency") + ) + if doc.transaction_currency == d.account_currency + else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate, + "credit_in_transaction_currency": flt( + d.credit_in_account_currency, d.precision("credit_in_account_currency") + ) + if doc.transaction_currency == d.account_currency + else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate, + "against_voucher_type": d.reference_type, + "against_voucher": d.reference_name, + "remarks": remarks, + "voucher_detail_no": d.reference_detail_no, + "cost_center": d.cost_center, + "project": d.project, + "finance_book": doc.finance_book, + "advance_voucher_type": d.advance_voucher_type, + "advance_voucher_no": d.advance_voucher_no, + } + + if d.reference_type in advance_doctypes: + row.update( + { + "against_voucher_type": doc.doctype, + "against_voucher": doc.name, + "advance_voucher_type": d.reference_type, + "advance_voucher_no": d.reference_name, + } + ) + + # set flag to skip party validation + account_type = frappe.get_cached_value("Account", d.account, "account_type") + if account_type in ["Receivable", "Payable"] and doc.party_not_required: + frappe.flags.party_not_required = True + + gl_map.append( + doc.get_gl_dict( + row, + item=d, + ) + ) + return gl_map From 55368256fde9583846c81076b27d6cdf43c79849 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 12:49:11 +0530 Subject: [PATCH 16/65] docs: mark Phase 4 Journal Entry as done in refactor spec --- specs/accounts_refactor_spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 33b4b8ee5fc..655af9dc87b 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -81,6 +81,7 @@ Added `PurchaseInvoiceGLComposer` with all 13 PI GL builders migrated (make_supp Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated. - **Payment Entry — DONE.** Added `payment_entry/services/gl_composer.py` → `PaymentEntryGLComposer(BaseGLComposer)`. `compose()` mirrors the old `build_gl_map` (setup party account field, set txn currency/rate, then party/bank/deductions/tax builders, then `add_regional_gl_entries`). The four row builders (`add_party_gl_entries`, `add_bank_gl_entries`, `add_tax_gl_entries`, `add_deductions_gl_entries`) moved onto the composer and operate on `self.doc`; `build_gl_map` is now a thin shim delegating to the composer. **Advance builders stay on the doc** (`make_advance_gl_entries`, `add_advance_gl_entries`, `get_dr_and_account_for_advances`, `add_advance_gl_for_reference`) — they post in a separate pass inside `make_gl_entries`, not part of `compose()`, and belong to the Phase 5 advances service. Shared helpers (`get_gl_dict`, `calculate_base_allocated_amount_for_reference`, `get_exchange_rate`, `get_party_account_for_taxes`) stay on the doc, called via `self.doc`. Extended the Phase-0 snapshot net with 5 PE scenarios (receive-vs-SI, pay-vs-PI, deductions, taxes, multi-currency). Verified: 17 snapshots byte-identical + 53 existing PE tests green. +- **Journal Entry — DONE.** Added `journal_entry/services/gl_composer.py` → `JournalEntryGLComposer(BaseGLComposer)`. A JE already carries its ledger rows in the `accounts` child table, so `compose()` is a straight projection of those rows into GL dicts via `self.doc.get_gl_dict` (resolving txn currency/rate from the first foreign-currency row, mirroring the former `build_gl_map`). `build_gl_map` is now a thin shim (kept public — JE tests call it directly). Dropped the now-unused `get_advance_payment_doctypes` import from `journal_entry.py`. Extended the snapshot net with 3 JE scenarios (basic two-line, multi-currency, against-SI with party + reference). Verified: 20 snapshots byte-identical + 18 existing JE tests green. ### Phase 5 — Extract `advances.py` Move the advances cluster. After composers, because advances cross-calls the exchange-gain/loss helper now on `BaseGLComposer`. From e8f9cf6e3f034fd4ce8b6a039c5a5dbd5044ffdd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 14:46:40 +0530 Subject: [PATCH 17/65] test: add Delivery Note GL snapshots Extends the Phase-0 characterization suite with 2 DN scenarios (basic delivery and return) using _Test Company with perpetual inventory so stock accounting GL entries are produced. Uses stock_entry_utils.make_stock_entry directly (avoids importing test_delivery_note and its conflicting test-record deps). Run: bench --site test-erpnext-v17 run-tests --module erpnext.accounts.test_gl_characterization --- erpnext/accounts/gl_snapshots/dn_basic.json | 30 +++++++++++ erpnext/accounts/gl_snapshots/dn_return.json | 30 +++++++++++ erpnext/accounts/test_gl_characterization.py | 53 ++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 erpnext/accounts/gl_snapshots/dn_basic.json create mode 100644 erpnext/accounts/gl_snapshots/dn_return.json diff --git a/erpnext/accounts/gl_snapshots/dn_basic.json b/erpnext/accounts/gl_snapshots/dn_basic.json new file mode 100644 index 00000000000..c810898479f --- /dev/null +++ b/erpnext/accounts/gl_snapshots/dn_basic.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/dn_return.json b/erpnext/accounts/gl_snapshots/dn_return.json new file mode 100644 index 00000000000..74da64a3ef8 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/dn_return.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py index 31895f52b93..73892dbdece 100644 --- a/erpnext/accounts/test_gl_characterization.py +++ b/erpnext/accounts/test_gl_characterization.py @@ -26,10 +26,14 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.gl_snapshot import assert_gl_snapshot +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry POSTING_DATE = "2024-01-15" COMPANY = "_Test Company" CUSTOMER = "_Test Customer" +WAREHOUSE = "_Test Warehouse - _TC" +DN_COMPANY = "_Test Company with perpetual inventory" +DN_WAREHOUSE = "Stores - TCP1" def make_dated_purchase_invoice(**args): @@ -362,3 +366,52 @@ class TestGLCharacterization(IntegrationTestCase): jv.insert() jv.submit() assert_gl_snapshot(self, "je_against_si", "Journal Entry", jv.name) + + def test_dn_basic(self): + make_stock_entry(item_code="_Test Item", target=DN_WAREHOUSE, qty=10, basic_rate=100) + dn = _make_dated_delivery_note(qty=5, rate=150) + dn.insert() + dn.submit() + assert_gl_snapshot(self, "dn_basic", "Delivery Note", dn.name) + + def test_dn_return(self): + make_stock_entry(item_code="_Test Item", target=DN_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_gl_snapshot(self, "dn_return", "Delivery Note", 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 = DN_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", DN_WAREHOUSE), + "qty": args.get("qty", 1), + "rate": args.get("rate", 100), + "expense_account": "Cost of Goods Sold - TCP1", + "cost_center": "Main - TCP1", + }, + ) + return dn From b68daea365d01e158c37ee917e8dcbc67b7ba472 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 14:47:19 +0530 Subject: [PATCH 18/65] refactor: introduce BaseStockGLComposer, slim StockController.get_gl_entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the StockController.get_gl_entries body into erpnext/stock/services/base_stock_gl_composer.py → BaseStockGLComposer(BaseGLComposer). compose(inventory_account_map, default_expense_account, default_cost_center) contains all warehouse↔expense-account GL pair building and the internal-transfer rounding-diff block; all helpers (get_inventory_account_dict, get_stock_ledger_details, etc.) remain on self.doc and are called via doc.. StockController.get_gl_entries becomes a 3-line shim. Delivery Note, Stock Entry, and Stock Reconciliation continue to work unchanged — DN inherits the shim directly; SE and SR override and call super(), which now delegates to the composer. Verified: 22 GL snapshots byte-identical on test-erpnext-v17. --- erpnext/controllers/stock_controller.py | 137 +--------------- erpnext/stock/services/__init__.py | 0 .../stock/services/base_stock_gl_composer.py | 154 ++++++++++++++++++ 3 files changed, 157 insertions(+), 134 deletions(-) create mode 100644 erpnext/stock/services/__init__.py create mode 100644 erpnext/stock/services/base_stock_gl_composer.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9fb9dfe58ab..cf8f27560a5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -13,7 +13,6 @@ import erpnext from erpnext.accounts.general_ledger import ( make_gl_entries, make_reverse_gl_entries, - process_gl_map, ) from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController @@ -691,140 +690,10 @@ class StockController(AccountsController): def get_gl_entries( self, inventory_account_map=None, default_expense_account=None, default_cost_center=None ): - if not inventory_account_map: - inventory_account_map = self.get_inventory_account_map() + from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer - sle_map = self.get_stock_ledger_details() - voucher_details = self.get_voucher_details(default_expense_account, default_cost_center, sle_map) - - gl_list = [] - warehouse_with_no_account = [] - precision = self.get_debit_field_precision() - for item_row in voucher_details: - sle_list = sle_map.get(item_row.name) - sle_rounding_diff = 0.0 - if sle_list: - for sle in sle_list: - _inv_dict = self.get_inventory_account_dict(sle, inventory_account_map) - - if _inv_dict.get("account"): - # from warehouse account - - sle_rounding_diff += flt(sle.stock_value_difference) - - self.check_expense_account(item_row) - - # expense account/ target_warehouse / source_warehouse - if item_row.get("target_warehouse"): - _target_wh_inv_dict = self.get_inventory_account_dict( - item_row, inventory_account_map, warehouse_field="target_warehouse" - ) - expense_account = _target_wh_inv_dict["account"] - else: - expense_account = item_row.expense_account - - gl_list.append( - self.get_gl_dict( - { - "account": _inv_dict["account"], - "against": expense_account, - "cost_center": item_row.cost_center, - "project": sle.get("project") or item_row.project or self.get("project"), - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "debit": flt(sle.stock_value_difference, precision), - "is_opening": item_row.get("is_opening") - or self.get("is_opening") - or "No", - }, - _inv_dict["account_currency"], - item=item_row, - ) - ) - - gl_list.append( - self.get_gl_dict( - { - "account": expense_account, - "against": _inv_dict["account"], - "cost_center": item_row.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "debit": -1 * flt(sle.stock_value_difference, precision), - "project": sle.get("project") - or item_row.get("project") - or self.get("project"), - "is_opening": item_row.get("is_opening") - or self.get("is_opening") - or "No", - }, - item=item_row, - ) - ) - elif sle.warehouse not in warehouse_with_no_account: - warehouse_with_no_account.append(sle.warehouse) - - if abs(sle_rounding_diff) > (1.0 / (10**precision)) and self.is_internal_transfer(): - warehouse_asset_account = "" - if self.get("is_internal_customer"): - _inv_dict = self.get_inventory_account_dict( - item_row, inventory_account_map, warehouse_field="target_warehouse" - ) - - warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None - elif self.get("is_internal_supplier"): - _inv_dict = self.get_inventory_account_dict(item_row, inventory_account_map) - - warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None - - expense_account = frappe.get_cached_value("Company", self.company, "default_expense_account") - if not expense_account: - frappe.throw( - _( - "Please set default cost of goods sold account in company {0} for booking rounding gain and loss during stock transfer" - ).format(frappe.bold(self.company)) - ) - - gl_list.append( - self.get_gl_dict( - { - "account": expense_account, - "against": warehouse_asset_account, - "cost_center": item_row.cost_center, - "project": item_row.project or self.get("project"), - "remarks": _("Rounding gain/loss Entry for Stock Transfer"), - "debit": sle_rounding_diff, - "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", - }, - _inv_dict["account_currency"], - item=item_row, - ) - ) - - gl_list.append( - self.get_gl_dict( - { - "account": warehouse_asset_account, - "against": expense_account, - "cost_center": item_row.cost_center, - "remarks": _("Rounding gain/loss Entry for Stock Transfer"), - "credit": sle_rounding_diff, - "project": item_row.get("project") or self.get("project"), - "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", - }, - item=item_row, - ) - ) - - if warehouse_with_no_account: - for wh in warehouse_with_no_account: - if frappe.get_cached_value("Warehouse", wh, "company"): - frappe.throw( - _( - "Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}." - ).format(wh, self.company) - ) - - return process_gl_map( - gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation + return BaseStockGLComposer(self).compose( + inventory_account_map, default_expense_account, default_cost_center ) def get_debit_field_precision(self): diff --git a/erpnext/stock/services/__init__.py b/erpnext/stock/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py new file mode 100644 index 00000000000..27731c0eb9e --- /dev/null +++ b/erpnext/stock/services/base_stock_gl_composer.py @@ -0,0 +1,154 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.services.base_gl_composer import BaseGLComposer + + +class BaseStockGLComposer(BaseGLComposer): + """Shared GL composition logic for stock vouchers. + + Subclasses override ``compose()`` and call ``super().compose()`` to get the + warehouse ↔ expense-account GL pairs, then append any doctype-specific + entries on top. + """ + + def compose( + self, + inventory_account_map: dict | None = None, + default_expense_account: str | None = None, + default_cost_center: str | None = None, + ) -> list: + doc = self.doc + + if not inventory_account_map: + 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) + + gl_list = [] + warehouse_with_no_account = [] + precision = doc.get_debit_field_precision() + + for item_row in voucher_details: + sle_list = sle_map.get(item_row.name) + sle_rounding_diff = 0.0 + if sle_list: + for sle in sle_list: + _inv_dict = doc.get_inventory_account_dict(sle, inventory_account_map) + + if _inv_dict.get("account"): + sle_rounding_diff += flt(sle.stock_value_difference) + + doc.check_expense_account(item_row) + + if item_row.get("target_warehouse"): + _target_wh_inv_dict = doc.get_inventory_account_dict( + item_row, inventory_account_map, warehouse_field="target_warehouse" + ) + expense_account = _target_wh_inv_dict["account"] + else: + expense_account = item_row.expense_account + + gl_list.append( + doc.get_gl_dict( + { + "account": _inv_dict["account"], + "against": expense_account, + "cost_center": item_row.cost_center, + "project": sle.get("project") or item_row.project or doc.get("project"), + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "debit": flt(sle.stock_value_difference, precision), + "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No", + }, + _inv_dict["account_currency"], + item=item_row, + ) + ) + + gl_list.append( + doc.get_gl_dict( + { + "account": expense_account, + "against": _inv_dict["account"], + "cost_center": item_row.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "debit": -1 * flt(sle.stock_value_difference, precision), + "project": sle.get("project") + or item_row.get("project") + or doc.get("project"), + "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No", + }, + item=item_row, + ) + ) + elif sle.warehouse not in warehouse_with_no_account: + warehouse_with_no_account.append(sle.warehouse) + + if abs(sle_rounding_diff) > (1.0 / (10**precision)) and doc.is_internal_transfer(): + warehouse_asset_account = "" + if doc.get("is_internal_customer"): + _inv_dict = doc.get_inventory_account_dict( + item_row, inventory_account_map, warehouse_field="target_warehouse" + ) + warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None + elif doc.get("is_internal_supplier"): + _inv_dict = doc.get_inventory_account_dict(item_row, inventory_account_map) + warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None + + expense_account = frappe.get_cached_value("Company", doc.company, "default_expense_account") + if not expense_account: + frappe.throw( + _( + "Please set default cost of goods sold account in company {0} for booking rounding gain and loss during stock transfer" + ).format(frappe.bold(doc.company)) + ) + + gl_list.append( + doc.get_gl_dict( + { + "account": expense_account, + "against": warehouse_asset_account, + "cost_center": item_row.cost_center, + "project": item_row.project or doc.get("project"), + "remarks": _("Rounding gain/loss Entry for Stock Transfer"), + "debit": sle_rounding_diff, + "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No", + }, + _inv_dict["account_currency"], + item=item_row, + ) + ) + + gl_list.append( + doc.get_gl_dict( + { + "account": warehouse_asset_account, + "against": expense_account, + "cost_center": item_row.cost_center, + "remarks": _("Rounding gain/loss Entry for Stock Transfer"), + "credit": sle_rounding_diff, + "project": item_row.get("project") or doc.get("project"), + "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No", + }, + item=item_row, + ) + ) + + if warehouse_with_no_account: + for wh in warehouse_with_no_account: + if frappe.get_cached_value("Warehouse", wh, "company"): + frappe.throw( + _( + "Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}." + ).format(wh, doc.company) + ) + + return process_gl_map( + gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation + ) From 001c70831cad60d98267af33726992cbc1a2d338 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 15:01:10 +0530 Subject: [PATCH 19/65] test: add Stock Entry and Stock Reconciliation GL snapshots Extends the Phase-0 characterization suite with 4 scenarios: se_material_receipt, se_material_issue, se_material_transfer, sr_basic. All use _Test Company with perpetual inventory (TCP1) so stock accounting GL entries are produced. 26 snapshots total, all green on test-erpnext-v17. --- .../gl_snapshots/se_material_issue.json | 30 ++++++++ .../gl_snapshots/se_material_receipt.json | 30 ++++++++ .../gl_snapshots/se_material_transfer.json | 1 + erpnext/accounts/gl_snapshots/sr_basic.json | 30 ++++++++ erpnext/accounts/test_gl_characterization.py | 76 +++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 erpnext/accounts/gl_snapshots/se_material_issue.json create mode 100644 erpnext/accounts/gl_snapshots/se_material_receipt.json create mode 100644 erpnext/accounts/gl_snapshots/se_material_transfer.json create mode 100644 erpnext/accounts/gl_snapshots/sr_basic.json diff --git a/erpnext/accounts/gl_snapshots/se_material_issue.json b/erpnext/accounts/gl_snapshots/se_material_issue.json new file mode 100644 index 00000000000..b177281c902 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/se_material_issue.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/se_material_receipt.json b/erpnext/accounts/gl_snapshots/se_material_receipt.json new file mode 100644 index 00000000000..bde33cb2748 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/se_material_receipt.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/se_material_transfer.json b/erpnext/accounts/gl_snapshots/se_material_transfer.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/se_material_transfer.json @@ -0,0 +1 @@ +[] diff --git a/erpnext/accounts/gl_snapshots/sr_basic.json b/erpnext/accounts/gl_snapshots/sr_basic.json new file mode 100644 index 00000000000..ab4cf49e410 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/sr_basic.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py index 73892dbdece..8ada6082db9 100644 --- a/erpnext/accounts/test_gl_characterization.py +++ b/erpnext/accounts/test_gl_characterization.py @@ -391,6 +391,56 @@ class TestGLCharacterization(IntegrationTestCase): ret.submit() assert_gl_snapshot(self, "dn_return", "Delivery Note", ret.name) + def test_se_material_receipt(self): + se = make_stock_entry( + item_code="_Test Item", + target=DN_WAREHOUSE, + qty=5, + basic_rate=100, + company=DN_COMPANY, + posting_date=POSTING_DATE, + do_not_submit=True, + ) + se.submit() + assert_gl_snapshot(self, "se_material_receipt", "Stock Entry", se.name) + + def test_se_material_issue(self): + make_stock_entry( + item_code="_Test Item", target=DN_WAREHOUSE, qty=10, basic_rate=100, company=DN_COMPANY + ) + se = make_stock_entry( + item_code="_Test Item", + source=DN_WAREHOUSE, + qty=5, + company=DN_COMPANY, + posting_date=POSTING_DATE, + do_not_submit=True, + ) + se.submit() + assert_gl_snapshot(self, "se_material_issue", "Stock Entry", se.name) + + def test_se_material_transfer(self): + make_stock_entry( + item_code="_Test Item", target=DN_WAREHOUSE, qty=10, basic_rate=100, company=DN_COMPANY + ) + se = make_stock_entry( + item_code="_Test Item", + source=DN_WAREHOUSE, + target="Finished Goods - TCP1", + qty=5, + company=DN_COMPANY, + posting_date=POSTING_DATE, + do_not_submit=True, + ) + se.submit() + assert_gl_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_gl_snapshot(self, "sr_basic", "Stock Reconciliation", sr.name) + def _make_dated_delivery_note(**args) -> frappe.Document: """Minimal Delivery Note on a fixed posting date using the perpetual-inventory @@ -415,3 +465,29 @@ def _make_dated_delivery_note(**args) -> frappe.Document: }, ) 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 = DN_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", DN_COMPANY, "stock_adjustment_account") + sr.cost_center = frappe.get_cached_value("Company", DN_COMPANY, "cost_center") + sr.append( + "items", + { + "item_code": args.get("item_code", "_Test Item"), + "warehouse": args.get("warehouse", DN_WAREHOUSE), + "qty": args.get("qty", 10), + "valuation_rate": args.get("rate", 100), + }, + ) + return sr From 18188cb1b2dd14ef2c3d02d0e7b99ea468f3dca6 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 15:01:27 +0530 Subject: [PATCH 20/65] refactor: introduce StockEntryGLComposer and StockReconciliationGLComposer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock Entry stock_entry/services/gl_composer.py → StockEntryGLComposer(BaseStockGLComposer) compose() calls super().compose() for the base warehouse↔expense GL pairs, then adds additional-cost entries (_build_additional_cost_per_item_account + _append_additional_cost_gl_entries) and LCV adjustments (_append_lcv_gl_entries). get_item_account_wise_lcv_entries stays on StockController (called via self.doc). StockEntry.get_gl_entries is now a 3-line shim. Removed private helpers from StockEntry; dropped unused process_gl_map and get_account_currency imports. Stock Reconciliation stock_reconciliation/services/gl_composer.py → StockReconciliationGLComposer(BaseStockGLComposer) compose() guards cost_center and delegates to super().compose(inventory_account_map, doc.expense_account, doc.cost_center). StockReconciliation.get_gl_entries is now a 3-line shim. Verified: 26 GL snapshots byte-identical on test-erpnext-v17; 89 SE tests and 33/34 SR tests green on test-site-ai (1 pre-existing SR failure in test_serial_no_status_with_backdated_stock_reco, unrelated to GL — IndexError in serial bundle setup). --- .../doctype/stock_entry/services/__init__.py | 0 .../stock_entry/services/gl_composer.py | 157 ++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 134 +-------------- .../stock_reconciliation/services/__init__.py | 0 .../services/gl_composer.py | 20 +++ .../stock_reconciliation.py | 7 +- 6 files changed, 183 insertions(+), 135 deletions(-) create mode 100644 erpnext/stock/doctype/stock_entry/services/__init__.py create mode 100644 erpnext/stock/doctype/stock_entry/services/gl_composer.py create mode 100644 erpnext/stock/doctype/stock_reconciliation/services/__init__.py create mode 100644 erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py diff --git a/erpnext/stock/doctype/stock_entry/services/__init__.py b/erpnext/stock/doctype/stock_entry/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py new file mode 100644 index 00000000000..f4ad4586ebf --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -0,0 +1,157 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.utils import get_account_currency +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class StockEntryGLComposer(BaseStockGLComposer): + """GL composer for Stock Entry. + + Extends the base stock GL loop with additional-cost entries (from the + ``additional_costs`` child table) and landed-cost voucher adjustments. + """ + + def compose(self, inventory_account_map: dict | None = None) -> list: + doc = self.doc + gl_entries = super().compose(inventory_account_map) + + if doc.purpose in ("Repack", "Manufacture"): + total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item) + else: + total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse) + + divide_based_on = total_basic_amount + if doc.get("additional_costs") and not total_basic_amount: + divide_based_on = sum(item.qty for item in doc.get("items")) + + item_account_wise_additional_cost = self._build_additional_cost_per_item_account( + total_basic_amount, divide_based_on + ) + if item_account_wise_additional_cost: + self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost) + + self._append_lcv_gl_entries(gl_entries, inventory_account_map) + + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _build_additional_cost_per_item_account( + self, total_basic_amount: float, divide_based_on: float + ) -> dict: + doc = self.doc + item_account_wise_additional_cost = {} + + for t in doc.get("additional_costs"): + for d in doc.get("items"): + if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item: + continue + elif not d.t_warehouse: + continue + + item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) + item_account_wise_additional_cost[(d.item_code, d.name)].setdefault( + t.expense_account, {"amount": 0.0, "base_amount": 0.0} + ) + + multiply_based_on = d.basic_amount if total_basic_amount else d.qty + entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account] + entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on + entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on + + return item_account_wise_additional_cost + + def _append_additional_cost_gl_entries( + self, gl_entries: list, item_account_wise_additional_cost: dict + ) -> None: + doc = self.doc + for d in doc.get("items"): + for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items(): + if not amount: + continue + + gl_entries.append( + doc.get_gl_dict( + { + "account": account, + "against": d.expense_account, + "cost_center": d.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit_in_account_currency": flt(amount["amount"]), + "credit": flt(amount["base_amount"]), + }, + item=d, + ) + ) + + gl_entries.append( + doc.get_gl_dict( + { + "account": d.expense_account, + "against": account, + "cost_center": d.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit": -1 * amount["base_amount"], + }, + item=d, + ) + ) + + def _append_lcv_gl_entries(self, gl_entries: list, inventory_account_map: dict) -> None: + doc = self.doc + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + if not landed_cost_entries: + return + + for item in doc.get("items"): + if item.s_warehouse: + continue + + if (item.item_code, item.name) in landed_cost_entries: + for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): + account_currency = get_account_currency(account) + credit_amount = ( + flt(amount["base_amount"]) + if (amount["base_amount"] or account_currency != doc.company_currency) + else flt(amount["amount"]) + ) + + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse") + gl_entries.append( + doc.get_gl_dict( + { + "account": account, + "against": _inv_dict["account"], + "cost_center": item.cost_center, + "debit": 0.0, + "credit": credit_amount, + "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), + "credit_in_account_currency": flt(amount["amount"]), + "account_currency": account_currency, + "project": item.project, + }, + item=item, + ) + ) + + account_currency = get_account_currency(item.expense_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": item.expense_account, + "against": _inv_dict["account"], + "cost_center": item.cost_center, + "debit": 0.0, + "credit": credit_amount * -1, + "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), + "debit_in_account_currency": flt(amount["amount"]), + "account_currency": account_currency, + "project": item.project, + }, + item=item, + ) + ) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ba02499faae..ed09cf78b31 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -24,8 +24,6 @@ from frappe.utils import ( ) import erpnext -from erpnext.accounts.general_ledger import process_gl_map -from erpnext.accounts.utils import get_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import ( @@ -1050,137 +1048,9 @@ class StockEntry(StockController, SubcontractingInwardController): sl_entries.append(sle) def get_gl_entries(self, inventory_account_map): - gl_entries = super().get_gl_entries(inventory_account_map) + from erpnext.stock.doctype.stock_entry.services.gl_composer import StockEntryGLComposer - if self.purpose in ("Repack", "Manufacture"): - total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item) - else: - total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) - - divide_based_on = total_basic_amount - if self.get("additional_costs") and not total_basic_amount: - divide_based_on = sum(item.qty for item in self.get("items")) - - item_account_wise_additional_cost = self._build_additional_cost_per_item_account( - total_basic_amount, divide_based_on - ) - - if item_account_wise_additional_cost: - self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost) - - self.set_gl_entries_for_landed_cost_voucher(gl_entries, inventory_account_map) - return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) - - def _build_additional_cost_per_item_account(self, total_basic_amount, divide_based_on): - item_account_wise_additional_cost = {} - - for t in self.get("additional_costs"): - for d in self.get("items"): - if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - continue - elif not d.t_warehouse: - continue - - item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) - item_account_wise_additional_cost[(d.item_code, d.name)].setdefault( - t.expense_account, {"amount": 0.0, "base_amount": 0.0} - ) - - multiply_based_on = d.basic_amount if total_basic_amount else d.qty - entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account] - entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on - entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on - - return item_account_wise_additional_cost - - def _append_additional_cost_gl_entries(self, gl_entries, item_account_wise_additional_cost): - for d in self.get("items"): - for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items(): - if not amount: - continue - - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": d.expense_account, - "cost_center": d.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit_in_account_currency": flt(amount["amount"]), - "credit": flt(amount["base_amount"]), - }, - item=d, - ) - ) - - gl_entries.append( - self.get_gl_dict( - { - "account": d.expense_account, - "against": account, - "cost_center": d.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": -1 * amount["base_amount"], # negative credit instead of debit - }, - item=d, - ) - ) - - def set_gl_entries_for_landed_cost_voucher(self, gl_entries, inventory_account_map): - landed_cost_entries = self.get_item_account_wise_lcv_entries() - if not landed_cost_entries: - return - - for item in self.get("items"): - if item.s_warehouse: - continue - - if (item.item_code, item.name) in landed_cost_entries: - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != self.company_currency) - else flt(amount["amount"]) - ) - - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map, "t_warehouse") - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": _inv_dict["account"], - "cost_center": item.cost_center, - "debit": 0.0, - "credit": credit_amount, - "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(self.name), - "credit_in_account_currency": flt(amount["amount"]), - "account_currency": account_currency, - "project": item.project, - }, - item=item, - ) - ) - - account_currency = get_account_currency(item.expense_account) - - # credit amount in negative to knock off the debit entry - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account, - "against": _inv_dict["account"], - "cost_center": item.cost_center, - "debit": 0.0, - "credit": credit_amount * -1, - "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(self.name), - "debit_in_account_currency": flt(amount["amount"]), - "account_currency": account_currency, - "project": item.project, - }, - item=item, - ) - ) + return StockEntryGLComposer(self).compose(inventory_account_map) @property def pro_doc(self): diff --git a/erpnext/stock/doctype/stock_reconciliation/services/__init__.py b/erpnext/stock/doctype/stock_reconciliation/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py new file mode 100644 index 00000000000..0ca408729f0 --- /dev/null +++ b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py @@ -0,0 +1,20 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from frappe import _, msgprint + +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class StockReconciliationGLComposer(BaseStockGLComposer): + """GL composer for Stock Reconciliation. + + SR carries its own expense_account and cost_center which are passed as + defaults into the base stock GL composition loop. + """ + + def compose(self, inventory_account_map: dict | None = None) -> list: + doc = self.doc + if not doc.cost_center: + msgprint(_("Please enter Cost Center"), raise_exception=1) + return super().compose(inventory_account_map, doc.expense_account, doc.cost_center) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index f21f4da7174..388ab6793b8 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -975,10 +975,11 @@ class StockReconciliation(StockController): return new_sl_entries def get_gl_entries(self, inventory_account_map=None): - if not self.cost_center: - msgprint(_("Please enter Cost Center"), raise_exception=1) + from erpnext.stock.doctype.stock_reconciliation.services.gl_composer import ( + StockReconciliationGLComposer, + ) - return super().get_gl_entries(inventory_account_map, self.expense_account, self.cost_center) + return StockReconciliationGLComposer(self).compose(inventory_account_map) def validate_expense_account(self): if not cint(erpnext.is_perpetual_inventory_enabled(self.company)): From b63e1fd796380d552c1e2e1863ef11ba580b958c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 15:17:00 +0530 Subject: [PATCH 21/65] test: add Purchase Receipt GL snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Phase-0 characterization suite with 3 PR scenarios: pr_basic, pr_with_taxes, pr_return — all using _Test Company with perpetual inventory (TCP1) so stock-received GL entries are produced. Also refreshes se_material_issue.json (cumulative stock on test-erpnext-v17 shifted the outgoing valuation rate). 29 snapshots total, all green. --- erpnext/accounts/gl_snapshots/pr_basic.json | 30 ++++++++++ erpnext/accounts/gl_snapshots/pr_return.json | 30 ++++++++++ .../accounts/gl_snapshots/pr_with_taxes.json | 58 +++++++++++++++++++ .../gl_snapshots/se_material_issue.json | 8 +-- erpnext/accounts/test_gl_characterization.py | 39 +++++++++++++ 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 erpnext/accounts/gl_snapshots/pr_basic.json create mode 100644 erpnext/accounts/gl_snapshots/pr_return.json create mode 100644 erpnext/accounts/gl_snapshots/pr_with_taxes.json diff --git a/erpnext/accounts/gl_snapshots/pr_basic.json b/erpnext/accounts/gl_snapshots/pr_basic.json new file mode 100644 index 00000000000..8a7f7d0824d --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pr_basic.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/pr_return.json b/erpnext/accounts/gl_snapshots/pr_return.json new file mode 100644 index 00000000000..32715c31173 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pr_return.json @@ -0,0 +1,30 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/pr_with_taxes.json b/erpnext/accounts/gl_snapshots/pr_with_taxes.json new file mode 100644 index 00000000000..38ed6a59549 --- /dev/null +++ b/erpnext/accounts/gl_snapshots/pr_with_taxes.json @@ -0,0 +1,58 @@ +[ + { + "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" + } +] diff --git a/erpnext/accounts/gl_snapshots/se_material_issue.json b/erpnext/accounts/gl_snapshots/se_material_issue.json index b177281c902..705b5255ee3 100644 --- a/erpnext/accounts/gl_snapshots/se_material_issue.json +++ b/erpnext/accounts/gl_snapshots/se_material_issue.json @@ -6,8 +6,8 @@ "cost_center": "Main - TCP1", "credit": 0.0, "credit_in_account_currency": 0.0, - "debit": 500.0, - "debit_in_account_currency": 500.0, + "debit": 750.0, + "debit_in_account_currency": 750.0, "is_opening": "No", "party": null, "party_type": null, @@ -18,8 +18,8 @@ "account_currency": "INR", "against": "Stock Adjustment - TCP1", "cost_center": "Main - TCP1", - "credit": 500.0, - "credit_in_account_currency": 500.0, + "credit": 750.0, + "credit_in_account_currency": 750.0, "debit": 0.0, "debit_in_account_currency": 0.0, "is_opening": "No", diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py index 8ada6082db9..4cfc9a7f803 100644 --- a/erpnext/accounts/test_gl_characterization.py +++ b/erpnext/accounts/test_gl_characterization.py @@ -26,6 +26,7 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.gl_snapshot import assert_gl_snapshot +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 POSTING_DATE = "2024-01-15" @@ -441,6 +442,44 @@ class TestGLCharacterization(IntegrationTestCase): sr.submit() assert_gl_snapshot(self, "sr_basic", "Stock Reconciliation", sr.name) + def test_pr_basic(self): + pr = make_purchase_receipt( + company=DN_COMPANY, + warehouse=DN_WAREHOUSE, + posting_date=POSTING_DATE, + qty=5, + rate=100, + ) + assert_gl_snapshot(self, "pr_basic", "Purchase Receipt", pr.name) + + def test_pr_with_taxes(self): + pr = make_purchase_receipt( + company=DN_COMPANY, + warehouse=DN_WAREHOUSE, + posting_date=POSTING_DATE, + qty=5, + rate=100, + get_taxes_and_charges=True, + ) + assert_gl_snapshot(self, "pr_with_taxes", "Purchase Receipt", pr.name) + + def test_pr_return(self): + original = make_purchase_receipt( + company=DN_COMPANY, + warehouse=DN_WAREHOUSE, + posting_date=POSTING_DATE, + qty=5, + rate=100, + ) + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return + + ret = make_purchase_return(original.name) + ret.posting_date = POSTING_DATE + ret.set_posting_time = 1 + ret.insert() + ret.submit() + assert_gl_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 From 8d3efe287e3ef355f835862a458e07f88c2906b8 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 15:17:34 +0530 Subject: [PATCH 22/65] refactor: introduce PurchaseReceiptGLComposer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit purchase_receipt/services/gl_composer.py → PurchaseReceiptGLComposer(BaseStockGLComposer). compose() orchestrates the four builder steps: _make_item_gl_entries, _make_tax_gl_entries, set_gl_entry_for_purchase_expense (stays on doc), update_regional_gl_entries (module-level). _make_item_gl_entries preserves the original closure structure (six inner functions: make_item_asset_inward_gl_entry, make_stock_received_but_not_billed_entry, make_landed_cost_gl_entries, make_amount_difference_entry, make_sub_contracting_gl_entries, make_divisional_loss_gl_entry); all doc calls go through self.doc. _make_tax_gl_entries is a direct port. Helpers that stay on the document: add_provisional_gl_entry (public — PI composer calls it via purchase_receipt_doc.add_provisional_gl_entry), add_gl_entry, get_item_account_wise_lcv_entries, update_assets, is_landed_cost_booked_for_any_item. PurchaseReceipt.get_gl_entries is now a 3-line shim; make_item_gl_entries and make_tax_gl_entries removed from the class. Verified: 29 GL snapshots byte-identical on test-erpnext-v17; 101 PR tests green on test-site-ai. --- .../purchase_receipt/purchase_receipt.py | 396 +---------------- .../purchase_receipt/services/__init__.py | 0 .../purchase_receipt/services/gl_composer.py | 408 ++++++++++++++++++ 3 files changed, 411 insertions(+), 393 deletions(-) create mode 100644 erpnext/stock/doctype/purchase_receipt/services/__init__.py create mode 100644 erpnext/stock/doctype/purchase_receipt/services/gl_composer.py diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 30afd561482..0fca30c5458 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -494,350 +494,11 @@ class PurchaseReceipt(BuyingController): item.amount_difference_with_purchase_invoice = 0 def get_gl_entries(self, inventory_account_map=None, via_landed_cost_voucher=False): - from erpnext.accounts.general_ledger import process_gl_map - - gl_entries = [] - - self.make_item_gl_entries(gl_entries, inventory_account_map=inventory_account_map) - self.make_tax_gl_entries(gl_entries, via_landed_cost_voucher) - self.set_gl_entry_for_purchase_expense(gl_entries) - update_regional_gl_entries(gl_entries, self) - - return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) - - def make_item_gl_entries(self, gl_entries, inventory_account_map=None): - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( - get_purchase_document_details, + from erpnext.stock.doctype.purchase_receipt.services.gl_composer import ( + PurchaseReceiptGLComposer, ) - provisional_accounting_for_non_stock_items = cint( - frappe.db.get_value("Company", self.company, "enable_provisional_accounting_for_non_stock_items") - ) - - exchange_rate_map, net_rate_map = get_purchase_document_details(self) - - def validate_account(account_type): - frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type)) - - def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name): - account_currency = get_account_currency(stock_asset_account_name) - - if not stock_asset_account_name: - validate_account("Asset or warehouse account") - - self.add_gl_entry( - gl_entries=gl_entries, - account=stock_asset_account_name, - cost_center=d.cost_center, - debit=stock_value_diff, - credit=0.0, - remarks=remarks, - against_account=stock_asset_rbnb, - account_currency=account_currency, - item=item, - ) - - def make_stock_received_but_not_billed_entry(item): - if ( - self.get("is_return") - and item.return_qty_from_rejected_warehouse - and not frappe.db.get_single_value( - "Buying Settings", "set_valuation_rate_for_rejected_materials" - ) - ): - return 0.0 - - account = stock_asset_rbnb - if item.from_warehouse: - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map, "from_warehouse") - account = _inv_dict["account"] - - account_currency = get_account_currency(account) - - # GL Entry for from warehouse or Stock Received but not billed - # Intentionally passed negative debit amount to avoid incorrect GL Entry validation - credit_amount = ( - flt(item.base_net_amount, item.precision("base_net_amount")) - if account_currency == self.company_currency - else flt(item.net_amount, item.precision("net_amount")) - ) - - outgoing_amount = item.base_net_amount - if self.is_internal_transfer() and item.valuation_rate: - outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse)) - credit_amount = outgoing_amount - - if item.get("rejected_qty") and frappe.db.get_single_value( - "Buying Settings", "set_valuation_rate_for_rejected_materials" - ): - outgoing_amount += get_stock_value_difference(self.name, item.name, item.rejected_warehouse) - credit_amount = outgoing_amount - - if credit_amount: - if not account: - validate_account("Stock or Asset Received But Not Billed") - - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=item.cost_center, - debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")), - credit=0.0, - remarks=remarks, - against_account=stock_asset_account_name, - debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), - account_currency=account_currency, - item=item, - ) - - # check if the exchange rate has changed - if d.get("purchase_invoice"): - if ( - exchange_rate_map[item.purchase_invoice] - and self.conversion_rate != exchange_rate_map[item.purchase_invoice] - and item.net_rate == net_rate_map[item.purchase_invoice_item] - ): - discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * ( - exchange_rate_map[item.purchase_invoice] - self.conversion_rate - ) - - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=item.cost_center, - debit=0.0, - credit=discrepancy_caused_by_exchange_rate_difference, - remarks=remarks, - against_account=self.supplier, - debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, - account_currency=account_currency, - item=item, - ) - - self.add_gl_entry( - gl_entries=gl_entries, - account=self.get_company_default("exchange_gain_loss_account"), - cost_center=d.cost_center, - debit=discrepancy_caused_by_exchange_rate_difference, - credit=0.0, - remarks=remarks, - against_account=self.supplier, - debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, - account_currency=account_currency, - item=item, - ) - - return outgoing_amount - - def make_landed_cost_gl_entries(item): - # Amount added through landed-cost-voucher - if item.landed_cost_voucher_amount and landed_cost_entries: - if (item.item_code, item.name) in landed_cost_entries: - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != self.company_currency) - else flt(amount["amount"]) - ) - - if not account: - validate_account("Landed Cost Account") - - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=item.cost_center, - debit=0.0, - credit=credit_amount, - remarks=remarks, - against_account=stock_asset_account_name, - credit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=item.project, - item=item, - ) - - def make_amount_difference_entry(item): - if item.amount_difference_with_purchase_invoice and stock_asset_rbnb: - account_currency = get_account_currency(stock_asset_rbnb) - self.add_gl_entry( - gl_entries=gl_entries, - account=stock_asset_rbnb, - cost_center=item.cost_center, - debit=0.0, - credit=flt(item.amount_difference_with_purchase_invoice), - remarks=_("Adjustment based on Purchase Invoice rate"), - against_account=stock_asset_account_name, - account_currency=account_currency, - project=item.project, - item=item, - ) - - def make_sub_contracting_gl_entries(item): - # sub-contracting warehouse - if flt(item.rm_supp_cost) and supplier_warehouse_account: - self.add_gl_entry( - gl_entries=gl_entries, - account=supplier_warehouse_account, - cost_center=item.cost_center, - debit=0.0, - credit=flt(item.rm_supp_cost), - remarks=remarks, - against_account=stock_asset_account_name, - account_currency=supplier_warehouse_account_currency, - item=item, - ) - - def make_divisional_loss_gl_entry(item, outgoing_amount): - if item.is_fixed_asset: - return - - # divisional loss adjustment - valuation_amount_as_per_doc = ( - flt(outgoing_amount, d.precision("base_net_amount")) - + flt(item.landed_cost_voucher_amount) - + flt(item.rm_supp_cost) - + flt(item.item_tax_amount) - + flt(item.amount_difference_with_purchase_invoice) - ) - - divisional_loss = flt( - valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount") - ) - - if item.get("rejected_qty") and frappe.db.get_single_value( - "Buying Settings", "set_valuation_rate_for_rejected_materials" - ): - rejected_item_cost = get_stock_value_difference(self.name, item.name, item.rejected_warehouse) - divisional_loss -= rejected_item_cost - - if divisional_loss: - loss_account = ( - self.get_company_default("default_expense_account", ignore_validation=True) - or stock_asset_rbnb - ) - - if self.is_return and item.expense_account: - loss_account = item.expense_account - - cost_center = item.cost_center or frappe.get_cached_value( - "Company", self.company, "cost_center" - ) - account_currency = get_account_currency(loss_account) - self.add_gl_entry( - gl_entries=gl_entries, - account=loss_account, - cost_center=cost_center, - debit=divisional_loss, - credit=0.0, - remarks=remarks, - against_account=stock_asset_account_name, - account_currency=account_currency, - project=item.project, - item=item, - ) - - stock_items = self.get_stock_items() - warehouse_with_no_account = [] - - for d in self.get("items"): - remarks = self.get("remarks") or _("Accounting Entry for {0}").format( - "Asset" if d.is_fixed_asset else "Stock" - ) - - if ( - provisional_accounting_for_non_stock_items - and d.item_code not in stock_items - and flt(d.qty) - and d.get("provisional_expense_account") - and not d.is_fixed_asset - ): - self.add_provisional_gl_entry( - d, gl_entries, self.posting_date, d.get("provisional_expense_account") - ) - elif flt(d.qty) and (flt(d.valuation_rate) or self.is_return): - if not ( - (erpnext.is_perpetual_inventory_enabled(self.company) and d.item_code in stock_items) - or (d.is_fixed_asset and not d.purchase_invoice) - ): - continue - - stock_asset_rbnb = ( - self.get_company_default("asset_received_but_not_billed") - if d.is_fixed_asset - else self.get_company_default("stock_received_but_not_billed") - ) - landed_cost_entries = self.get_item_account_wise_lcv_entries() - if d.is_fixed_asset: - stock_asset_account_name = d.expense_account - stock_value_diff = ( - flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount) - ) - elif inventory_account := self.get_inventory_account_dict(d, inventory_account_map): - stock_value_diff = get_stock_value_difference(self.name, d.name, d.warehouse) - stock_asset_account_name = inventory_account["account"] - - supplier_warehouse_account = None - supplier_warehouse_account_currency = None - if self.supplier_warehouse: - if _inv_dict := self.get_inventory_account_dict( - d, inventory_account_map, "supplier_warehouse" - ): - supplier_warehouse_account = _inv_dict["account"] - supplier_warehouse_account_currency = _inv_dict["account_currency"] - - # If PR is sub-contracted and fg item rate is zero - # in that case if account for source and target warehouse are same, - # then GL entries should not be posted - if ( - flt(stock_value_diff) == flt(d.rm_supp_cost) - and supplier_warehouse_account - and stock_asset_account_name == supplier_warehouse_account - ): - continue - - if (flt(d.valuation_rate) or self.is_return or d.is_fixed_asset) and flt(d.qty): - make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) - outgoing_amount = make_stock_received_but_not_billed_entry(d) - make_landed_cost_gl_entries(d) - make_amount_difference_entry(d) - make_sub_contracting_gl_entries(d) - make_divisional_loss_gl_entry(d, outgoing_amount) - elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or ( - not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials") - and d.rejected_warehouse - and d.rejected_warehouse not in warehouse_with_no_account - ): - warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse) - - if d.is_fixed_asset and d.landed_cost_voucher_amount: - self.update_assets(d, d.valuation_rate) - - if d.rejected_qty and frappe.db.get_single_value( - "Buying Settings", "set_valuation_rate_for_rejected_materials" - ): - stock_asset_rbnb = ( - self.get_company_default("asset_received_but_not_billed") - if d.is_fixed_asset - else self.get_company_default("stock_received_but_not_billed") - ) - - stock_value_diff = get_stock_value_difference(self.name, d.name, d.rejected_warehouse) - _inv_dict = self.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse") - - stock_asset_account_name = _inv_dict["account"] - - make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) - if not d.qty: - make_stock_received_but_not_billed_entry(d) - - if warehouse_with_no_account: - frappe.msgprint( - _("No accounting entries for the following warehouses") - + ": \n" - + "\n".join(warehouse_with_no_account) - ) + return PurchaseReceiptGLComposer(self).compose(inventory_account_map, via_landed_cost_voucher) def add_provisional_gl_entry( self, item, gl_entries, posting_date, provisional_account, reverse=0, item_amount=None @@ -894,57 +555,6 @@ class PurchaseReceipt(BuyingController): return False - def make_tax_gl_entries(self, gl_entries, via_landed_cost_voucher=False): - negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get("items")]) - # Cost center-wise amount breakup for other charges included for valuation - valuation_tax = {} - for tax in self.get("taxes"): - if tax.category in ("Valuation", "Valuation and Total") and flt( - tax.base_tax_amount_after_discount_amount - ): - if not tax.cost_center: - frappe.throw( - _("Cost Center is required in row {0} in Taxes table for type {1}").format( - tax.idx, _(tax.category) - ) - ) - valuation_tax.setdefault(tax.name, 0) - valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt( - tax.base_tax_amount_after_discount_amount - ) - - if negative_expense_to_be_booked and valuation_tax: - # Backward compatibility: - # and charges added via Landed Cost Voucher, - # post valuation related charges on "Stock Received But Not Billed" - against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0]) - total_valuation_amount = sum(valuation_tax.values()) - amount_including_divisional_loss = negative_expense_to_be_booked - i = 1 - for tax in self.get("taxes"): - if valuation_tax.get(tax.name): - account = tax.account_head - if i == len(valuation_tax): - applicable_amount = amount_including_divisional_loss - else: - applicable_amount = negative_expense_to_be_booked * ( - valuation_tax[tax.name] / total_valuation_amount - ) - amount_including_divisional_loss -= applicable_amount - - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=tax.cost_center, - debit=0.0, - credit=applicable_amount, - remarks=self.remarks or _("Accounting Entry for Stock"), - against_account=against_accounts, - item=tax, - ) - - i += 1 - def update_assets(self, item, valuation_rate): assets = frappe.db.get_all( "Asset", diff --git a/erpnext/stock/doctype/purchase_receipt/services/__init__.py b/erpnext/stock/doctype/purchase_receipt/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py new file mode 100644 index 00000000000..20347583bb1 --- /dev/null +++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py @@ -0,0 +1,408 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import cint, flt + +import erpnext +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.utils import get_account_currency +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class PurchaseReceiptGLComposer(BaseStockGLComposer): + """GL composer for Purchase Receipt. + + Builds GL entries for stock/asset inward, taxes, purchase expense, and + regional adjustments. Does not delegate to the base stock GL loop — + PR has its own per-item logic (provisional accounting, fixed assets, LCV, + sub-contracting, divisional loss). + """ + + def compose( + self, + inventory_account_map: dict | None = None, + via_landed_cost_voucher: bool = False, + ) -> list: + gl_entries = [] + self._make_item_gl_entries(gl_entries, inventory_account_map) + self._make_tax_gl_entries(gl_entries, via_landed_cost_voucher) + self.doc.set_gl_entry_for_purchase_expense(gl_entries) + + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_regional_gl_entries + + update_regional_gl_entries(gl_entries, self.doc) + + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None: + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( + get_purchase_document_details, + ) + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_stock_value_difference + + doc = self.doc + provisional_accounting_for_non_stock_items = cint( + frappe.db.get_value("Company", doc.company, "enable_provisional_accounting_for_non_stock_items") + ) + + exchange_rate_map, net_rate_map = get_purchase_document_details(doc) + stock_items = doc.get_stock_items() + warehouse_with_no_account = [] + + def validate_account(account_type): + frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type)) + + def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name): + account_currency = get_account_currency(stock_asset_account_name) + if not stock_asset_account_name: + validate_account("Asset or warehouse account") + doc.add_gl_entry( + gl_entries=gl_entries, + account=stock_asset_account_name, + cost_center=d.cost_center, + debit=stock_value_diff, + credit=0.0, + remarks=remarks, + against_account=stock_asset_rbnb, + account_currency=account_currency, + item=item, + ) + + def make_stock_received_but_not_billed_entry(item): + if ( + doc.get("is_return") + and item.return_qty_from_rejected_warehouse + and not frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ) + ): + return 0.0 + + account = stock_asset_rbnb + if item.from_warehouse: + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "from_warehouse") + account = _inv_dict["account"] + + account_currency = get_account_currency(account) + + credit_amount = ( + flt(item.base_net_amount, item.precision("base_net_amount")) + if account_currency == doc.company_currency + else flt(item.net_amount, item.precision("net_amount")) + ) + + outgoing_amount = item.base_net_amount + if doc.is_internal_transfer() and item.valuation_rate: + outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse)) + credit_amount = outgoing_amount + + if item.get("rejected_qty") and frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ): + outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse) + credit_amount = outgoing_amount + + if credit_amount: + if not account: + validate_account("Stock or Asset Received But Not Billed") + + doc.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")), + credit=0.0, + remarks=remarks, + against_account=stock_asset_account_name, + debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), + account_currency=account_currency, + item=item, + ) + + if d.get("purchase_invoice"): + if ( + exchange_rate_map[item.purchase_invoice] + and doc.conversion_rate != exchange_rate_map[item.purchase_invoice] + and item.net_rate == net_rate_map[item.purchase_invoice_item] + ): + discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * ( + exchange_rate_map[item.purchase_invoice] - doc.conversion_rate + ) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=0.0, + credit=discrepancy_caused_by_exchange_rate_difference, + remarks=remarks, + against_account=doc.supplier, + debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=account_currency, + item=item, + ) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=doc.get_company_default("exchange_gain_loss_account"), + cost_center=d.cost_center, + debit=discrepancy_caused_by_exchange_rate_difference, + credit=0.0, + remarks=remarks, + against_account=doc.supplier, + debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=account_currency, + item=item, + ) + + return outgoing_amount + + def make_landed_cost_gl_entries(item): + if item.landed_cost_voucher_amount and landed_cost_entries: + if (item.item_code, item.name) in landed_cost_entries: + for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): + account_currency = get_account_currency(account) + credit_amount = ( + flt(amount["base_amount"]) + if (amount["base_amount"] or account_currency != doc.company_currency) + else flt(amount["amount"]) + ) + + if not account: + validate_account("Landed Cost Account") + + doc.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=stock_asset_account_name, + credit_in_account_currency=flt(amount["amount"]), + account_currency=account_currency, + project=item.project, + item=item, + ) + + def make_amount_difference_entry(item): + if item.amount_difference_with_purchase_invoice and stock_asset_rbnb: + account_currency = get_account_currency(stock_asset_rbnb) + doc.add_gl_entry( + gl_entries=gl_entries, + account=stock_asset_rbnb, + cost_center=item.cost_center, + debit=0.0, + credit=flt(item.amount_difference_with_purchase_invoice), + remarks=_("Adjustment based on Purchase Invoice rate"), + against_account=stock_asset_account_name, + account_currency=account_currency, + project=item.project, + item=item, + ) + + def make_sub_contracting_gl_entries(item): + if flt(item.rm_supp_cost) and supplier_warehouse_account: + doc.add_gl_entry( + gl_entries=gl_entries, + account=supplier_warehouse_account, + cost_center=item.cost_center, + debit=0.0, + credit=flt(item.rm_supp_cost), + remarks=remarks, + against_account=stock_asset_account_name, + account_currency=supplier_warehouse_account_currency, + item=item, + ) + + def make_divisional_loss_gl_entry(item, outgoing_amount): + if item.is_fixed_asset: + return + + valuation_amount_as_per_doc = ( + flt(outgoing_amount, d.precision("base_net_amount")) + + flt(item.landed_cost_voucher_amount) + + flt(item.rm_supp_cost) + + flt(item.item_tax_amount) + + flt(item.amount_difference_with_purchase_invoice) + ) + + divisional_loss = flt( + valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount") + ) + + if item.get("rejected_qty") and frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ): + rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse) + divisional_loss -= rejected_item_cost + + if divisional_loss: + loss_account = ( + doc.get_company_default("default_expense_account", ignore_validation=True) + or stock_asset_rbnb + ) + + if doc.is_return and item.expense_account: + loss_account = item.expense_account + + cost_center = item.cost_center or frappe.get_cached_value( + "Company", doc.company, "cost_center" + ) + account_currency = get_account_currency(loss_account) + doc.add_gl_entry( + gl_entries=gl_entries, + account=loss_account, + cost_center=cost_center, + debit=divisional_loss, + credit=0.0, + remarks=remarks, + against_account=stock_asset_account_name, + account_currency=account_currency, + project=item.project, + item=item, + ) + + for d in doc.get("items"): + remarks = doc.get("remarks") or _("Accounting Entry for {0}").format( + "Asset" if d.is_fixed_asset else "Stock" + ) + + if ( + provisional_accounting_for_non_stock_items + and d.item_code not in stock_items + and flt(d.qty) + and d.get("provisional_expense_account") + and not d.is_fixed_asset + ): + doc.add_provisional_gl_entry( + d, gl_entries, doc.posting_date, d.get("provisional_expense_account") + ) + elif flt(d.qty) and (flt(d.valuation_rate) or doc.is_return): + if not ( + (erpnext.is_perpetual_inventory_enabled(doc.company) and d.item_code in stock_items) + or (d.is_fixed_asset and not d.purchase_invoice) + ): + continue + + stock_asset_rbnb = ( + doc.get_company_default("asset_received_but_not_billed") + if d.is_fixed_asset + else doc.get_company_default("stock_received_but_not_billed") + ) + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + if d.is_fixed_asset: + stock_asset_account_name = d.expense_account + stock_value_diff = ( + flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount) + ) + elif inventory_account := doc.get_inventory_account_dict(d, inventory_account_map): + stock_value_diff = get_stock_value_difference(doc.name, d.name, d.warehouse) + stock_asset_account_name = inventory_account["account"] + + supplier_warehouse_account = None + supplier_warehouse_account_currency = None + if doc.supplier_warehouse: + if _inv_dict := doc.get_inventory_account_dict( + d, inventory_account_map, "supplier_warehouse" + ): + supplier_warehouse_account = _inv_dict["account"] + supplier_warehouse_account_currency = _inv_dict["account_currency"] + + if ( + flt(stock_value_diff) == flt(d.rm_supp_cost) + and supplier_warehouse_account + and stock_asset_account_name == supplier_warehouse_account + ): + continue + + if (flt(d.valuation_rate) or doc.is_return or d.is_fixed_asset) and flt(d.qty): + make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) + outgoing_amount = make_stock_received_but_not_billed_entry(d) + make_landed_cost_gl_entries(d) + make_amount_difference_entry(d) + make_sub_contracting_gl_entries(d) + make_divisional_loss_gl_entry(d, outgoing_amount) + elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or ( + not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials") + and d.rejected_warehouse + and d.rejected_warehouse not in warehouse_with_no_account + ): + warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse) + + if d.is_fixed_asset and d.landed_cost_voucher_amount: + doc.update_assets(d, d.valuation_rate) + + if d.rejected_qty and frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ): + stock_asset_rbnb = ( + doc.get_company_default("asset_received_but_not_billed") + if d.is_fixed_asset + else doc.get_company_default("stock_received_but_not_billed") + ) + + stock_value_diff = get_stock_value_difference(doc.name, d.name, d.rejected_warehouse) + _inv_dict = doc.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse") + stock_asset_account_name = _inv_dict["account"] + + make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) + if not d.qty: + make_stock_received_but_not_billed_entry(d) + + if warehouse_with_no_account: + frappe.msgprint( + _("No accounting entries for the following warehouses") + + ": \n" + + "\n".join(warehouse_with_no_account) + ) + + def _make_tax_gl_entries(self, gl_entries: list, via_landed_cost_voucher: bool = False) -> None: + doc = self.doc + negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in doc.get("items")]) + valuation_tax = {} + for tax in doc.get("taxes"): + if tax.category in ("Valuation", "Valuation and Total") and flt( + tax.base_tax_amount_after_discount_amount + ): + if not tax.cost_center: + frappe.throw( + _("Cost Center is required in row {0} in Taxes table for type {1}").format( + tax.idx, _(tax.category) + ) + ) + valuation_tax.setdefault(tax.name, 0) + valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt( + tax.base_tax_amount_after_discount_amount + ) + + if negative_expense_to_be_booked and valuation_tax: + against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0]) + total_valuation_amount = sum(valuation_tax.values()) + amount_including_divisional_loss = negative_expense_to_be_booked + i = 1 + for tax in doc.get("taxes"): + if valuation_tax.get(tax.name): + account = tax.account_head + if i == len(valuation_tax): + applicable_amount = amount_including_divisional_loss + else: + applicable_amount = negative_expense_to_be_booked * ( + valuation_tax[tax.name] / total_valuation_amount + ) + amount_including_divisional_loss -= applicable_amount + + doc.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=tax.cost_center, + debit=0.0, + credit=applicable_amount, + remarks=doc.remarks or _("Accounting Entry for Stock"), + against_account=against_accounts, + item=tax, + ) + + i += 1 From 8783689ec52dfa386d817dee17832d31ed939838 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 15:34:54 +0530 Subject: [PATCH 23/65] refactor(accounts): GL composer pattern for SCR, AssetCapitalization, AssetRepair Extracts get_gl_entries logic from SubcontractingReceipt, AssetCapitalization, and AssetRepair into dedicated GL composer classes under each doctype's services/ package. Each composer follows the established BaseGLComposer / BaseStockGLComposer pattern, and the original get_gl_entries becomes a 3-line shim. - SubcontractingReceiptGLComposer(BaseStockGLComposer): moves make_item_gl_entries and make_item_gl_entries_for_lcv - AssetCapitalizationGLComposer(BaseStockGLComposer): moves get_gl_entries_for_consumed_{stock,asset,service}_items and get_gl_entries_for_target_item; inventory_account_map/sle_map/precision become composer instance attributes - AssetRepairGLComposer(BaseGLComposer): moves get_gl_entries_for_repair_cost and get_gl_entries_for_consumed_items (AR inherits AccountsController, not StockController) All 29 GL snapshot tests and existing doctype test suites (32 SCR, 5 AC, 18 AR) pass. --- .../asset_capitalization.py | 131 +-------- .../asset_capitalization/services/__init__.py | 0 .../services/gl_composer.py | 160 +++++++++++ .../doctype/asset_repair/asset_repair.py | 109 +------ .../doctype/asset_repair/services/__init__.py | 0 .../asset_repair/services/gl_composer.py | 130 +++++++++ .../services/__init__.py | 0 .../services/gl_composer.py | 265 ++++++++++++++++++ .../subcontracting_receipt.py | 255 +---------------- 9 files changed, 564 insertions(+), 486 deletions(-) create mode 100644 erpnext/assets/doctype/asset_capitalization/services/__init__.py create mode 100644 erpnext/assets/doctype/asset_capitalization/services/gl_composer.py create mode 100644 erpnext/assets/doctype/asset_repair/services/__init__.py create mode 100644 erpnext/assets/doctype/asset_repair/services/gl_composer.py create mode 100644 erpnext/subcontracting/doctype/subcontracting_receipt/services/__init__.py create mode 100644 erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index e4724dadeee..ada205080cb 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -12,8 +12,6 @@ from frappe.utils import cint, flt, get_link_to_form import erpnext from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation from erpnext.assets.doctype.asset.depreciation import ( - depreciate_asset, - get_gl_entries_on_asset_disposal, get_value_after_depreciation_on_disposal_date, reset_depreciation_schedule, reverse_depreciation_entry_made_on_disposal, @@ -396,30 +394,11 @@ class AssetCapitalization(StockController): def get_gl_entries( self, inventory_account_map=None, default_expense_account=None, default_cost_center=None ): - # Stock GL Entries - gl_entries = [] - - self.inventory_account_map = inventory_account_map - if not self.inventory_account_map: - self.inventory_account_map = self.get_inventory_account_map() - - precision = self.get_debit_field_precision() - self.sle_map = self.get_stock_ledger_details() - - target_account = self.get_target_account() - target_against = set() - - self.get_gl_entries_for_consumed_stock_items(gl_entries, target_account, target_against, precision) - self.get_gl_entries_for_consumed_asset_items(gl_entries, target_account, target_against, precision) - self.get_gl_entries_for_consumed_service_items(gl_entries, target_account, target_against, precision) - - composite_component_value = self.get_composite_component_value() - - self.get_gl_entries_for_target_item( - gl_entries, target_account, target_against, precision, composite_component_value + from erpnext.assets.doctype.asset_capitalization.services.gl_composer import ( + AssetCapitalizationGLComposer, ) - return gl_entries + return AssetCapitalizationGLComposer(self).compose(inventory_account_map) def get_target_account(self): from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled @@ -435,91 +414,6 @@ class AssetCapitalization(StockController): else: return self.target_fixed_asset_account - def get_gl_entries_for_consumed_stock_items(self, gl_entries, target_account, target_against, precision): - # Consumed Stock Items - for item_row in self.stock_items: - sle_list = self.sle_map.get(item_row.name) - if sle_list: - _inv_dict = self.get_inventory_account_dict(item_row, self.inventory_account_map) - for sle in sle_list: - stock_value_difference = flt(sle.stock_value_difference, precision) - - if erpnext.is_perpetual_inventory_enabled(self.company): - account = _inv_dict["account"] - else: - account = self.get_company_default("default_expense_account") - - target_against.add(account) - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": target_account, - "cost_center": item_row.cost_center, - "project": item_row.get("project") or self.get("project"), - "remarks": self.get("remarks") or "Accounting Entry for Stock", - "credit": -1 * stock_value_difference, - }, - _inv_dict["account_currency"], - item=item_row, - ) - ) - - def get_gl_entries_for_consumed_asset_items(self, gl_entries, target_account, target_against, precision): - # Consumed Assets - for item in self.asset_items: - asset = frappe.get_doc("Asset", item.asset) - - if asset.asset_type != "Composite Component": - if asset.calculate_depreciation: - notes = _( - "This schedule was created when Asset {0} was consumed through Asset Capitalization {1}." - ).format( - get_link_to_form(asset.doctype, asset.name), - get_link_to_form(self.doctype, self.get("name")), - ) - depreciate_asset(asset, self.posting_date, notes) - asset.reload() - - fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( - asset, - item.asset_value, - item.get("finance_book") or self.get("finance_book"), - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - - for gle in fixed_asset_gl_entries: - gle["against"] = target_account - gl_entries.append(self.get_gl_dict(gle, item=item)) - target_against.add(gle["account"]) - - asset.db_set("disposal_date", self.posting_date) - self.set_consumed_asset_status(asset) - - def get_gl_entries_for_consumed_service_items( - self, gl_entries, target_account, target_against, precision - ): - # Service Expenses - for item_row in self.service_items: - expense_amount = flt(item_row.amount, precision) - target_against.add(item_row.expense_account) - - gl_entries.append( - self.get_gl_dict( - { - "account": item_row.expense_account, - "against": target_account, - "cost_center": item_row.cost_center, - "project": item_row.get("project") or self.get("project"), - "remarks": self.get("remarks") or "Accounting Entry for Stock", - "credit": expense_amount, - }, - item=item_row, - ) - ) - def get_composite_component_value(self): composite_component_value = 0 for item in self.asset_items: @@ -528,25 +422,6 @@ class AssetCapitalization(StockController): composite_component_value += flt(item.asset_value, item.precision("asset_value")) return composite_component_value - def get_gl_entries_for_target_item( - self, gl_entries, target_account, target_against, precision, composite_component_value - ): - total_value = flt(self.total_value - composite_component_value, precision) - if total_value: - # Capitalization - gl_entries.append( - self.get_gl_dict( - { - "account": target_account, - "against": ", ".join(target_against), - "remarks": self.get("remarks") or _("Accounting Entry for Asset"), - "debit": total_value, - "cost_center": self.get("cost_center"), - }, - item=self, - ) - ) - def update_target_asset(self): total_target_asset_value = flt(self.total_value, self.precision("total_value")) asset_doc = frappe.get_doc("Asset", self.target_asset) diff --git a/erpnext/assets/doctype/asset_capitalization/services/__init__.py b/erpnext/assets/doctype/asset_capitalization/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py new file mode 100644 index 00000000000..4f0993c0e92 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py @@ -0,0 +1,160 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +import erpnext +from erpnext.assets.doctype.asset.depreciation import ( + depreciate_asset, + get_gl_entries_on_asset_disposal, +) +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class AssetCapitalizationGLComposer(BaseStockGLComposer): + """GL composer for Asset Capitalization. + + Builds GL entries for consumed stock items, consumed asset items (with + depreciation side-effects), consumed service items, and the target asset debit. + """ + + def compose( + self, + inventory_account_map: dict | None = None, + default_expense_account: str | None = None, + default_cost_center: str | None = None, + ) -> list: + doc = self.doc + gl_entries = [] + + self.inventory_account_map = inventory_account_map or doc.get_inventory_account_map() + self.precision = doc.get_debit_field_precision() + self.sle_map = doc.get_stock_ledger_details() + + target_account = doc.get_target_account() + target_against: set = set() + + self._get_gl_entries_for_consumed_stock_items(gl_entries, target_account, target_against) + self._get_gl_entries_for_consumed_asset_items(gl_entries, target_account, target_against) + self._get_gl_entries_for_consumed_service_items(gl_entries, target_account, target_against) + + composite_component_value = doc.get_composite_component_value() + self._get_gl_entries_for_target_item( + gl_entries, target_account, target_against, composite_component_value + ) + + return gl_entries + + def _get_gl_entries_for_consumed_stock_items( + self, gl_entries: list, target_account: str, target_against: set + ) -> None: + doc = self.doc + for item_row in doc.stock_items: + sle_list = self.sle_map.get(item_row.name) + if sle_list: + _inv_dict = doc.get_inventory_account_dict(item_row, self.inventory_account_map) + for sle in sle_list: + stock_value_difference = flt(sle.stock_value_difference, self.precision) + + if erpnext.is_perpetual_inventory_enabled(doc.company): + account = _inv_dict["account"] + else: + account = doc.get_company_default("default_expense_account") + + target_against.add(account) + gl_entries.append( + doc.get_gl_dict( + { + "account": account, + "against": target_account, + "cost_center": item_row.cost_center, + "project": item_row.get("project") or doc.get("project"), + "remarks": doc.get("remarks") or "Accounting Entry for Stock", + "credit": -1 * stock_value_difference, + }, + _inv_dict["account_currency"], + item=item_row, + ) + ) + + def _get_gl_entries_for_consumed_asset_items( + self, gl_entries: list, target_account: str, target_against: set + ) -> None: + doc = self.doc + for item in doc.asset_items: + asset = frappe.get_doc("Asset", item.asset) + + if asset.asset_type != "Composite Component": + if asset.calculate_depreciation: + notes = _( + "This schedule was created when Asset {0} was consumed through Asset Capitalization {1}." + ).format( + frappe.utils.get_link_to_form(asset.doctype, asset.name), + frappe.utils.get_link_to_form(doc.doctype, doc.get("name")), + ) + depreciate_asset(asset, doc.posting_date, notes) + asset.reload() + + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( + asset, + item.asset_value, + item.get("finance_book") or doc.get("finance_book"), + doc.get("doctype"), + doc.get("name"), + doc.get("posting_date"), + ) + + for gle in fixed_asset_gl_entries: + gle["against"] = target_account + gl_entries.append(doc.get_gl_dict(gle, item=item)) + target_against.add(gle["account"]) + + asset.db_set("disposal_date", doc.posting_date) + doc.set_consumed_asset_status(asset) + + def _get_gl_entries_for_consumed_service_items( + self, gl_entries: list, target_account: str, target_against: set + ) -> None: + doc = self.doc + for item_row in doc.service_items: + expense_amount = flt(item_row.amount, self.precision) + target_against.add(item_row.expense_account) + + gl_entries.append( + doc.get_gl_dict( + { + "account": item_row.expense_account, + "against": target_account, + "cost_center": item_row.cost_center, + "project": item_row.get("project") or doc.get("project"), + "remarks": doc.get("remarks") or "Accounting Entry for Stock", + "credit": expense_amount, + }, + item=item_row, + ) + ) + + def _get_gl_entries_for_target_item( + self, + gl_entries: list, + target_account: str, + target_against: set, + composite_component_value: float, + ) -> None: + doc = self.doc + total_value = flt(doc.total_value - composite_component_value, self.precision) + if total_value: + gl_entries.append( + doc.get_gl_dict( + { + "account": target_account, + "against": ", ".join(target_against), + "remarks": doc.get("remarks") or _("Accounting Entry for Asset"), + "debit": total_value, + "cost_center": doc.get("cost_center"), + }, + item=doc, + ) + ) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 202f16da684..6347379d577 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -12,7 +12,6 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) from erpnext.accounts.general_ledger import make_gl_entries -from erpnext.assets.doctype.asset.asset import get_asset_account from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( reschedule_depreciation, @@ -315,113 +314,9 @@ class AssetRepair(AccountsController): make_gl_entries(gl_entries, cancel) def get_gl_entries(self): - gl_entries = [] + from erpnext.assets.doctype.asset_repair.services.gl_composer import AssetRepairGLComposer - fixed_asset_account = get_asset_account("fixed_asset_account", asset=self.asset, company=self.company) - self.get_gl_entries_for_repair_cost(gl_entries, fixed_asset_account) - self.get_gl_entries_for_consumed_items(gl_entries, fixed_asset_account) - - return gl_entries - - def get_gl_entries_for_repair_cost(self, gl_entries, fixed_asset_account): - if flt(self.repair_cost) <= 0: - return - - debit_against_account = set() - - for pi in self.invoices: - debit_against_account.add(pi.expense_account) - gl_entries.append( - self.get_gl_dict( - { - "account": pi.expense_account, - "credit": pi.repair_cost, - "credit_in_account_currency": pi.repair_cost, - "against": fixed_asset_account, - "voucher_type": self.doctype, - "voucher_no": self.name, - "cost_center": self.cost_center, - "posting_date": self.completion_date, - "company": self.company, - }, - item=self, - ) - ) - debit_against_account = ", ".join(debit_against_account) - gl_entries.append( - self.get_gl_dict( - { - "account": fixed_asset_account, - "debit": self.repair_cost, - "debit_in_account_currency": self.repair_cost, - "against": debit_against_account, - "voucher_type": self.doctype, - "voucher_no": self.name, - "cost_center": self.cost_center, - "posting_date": self.completion_date, - "against_voucher_type": "Asset", - "against_voucher": self.asset, - "company": self.company, - }, - item=self, - ) - ) - - def get_gl_entries_for_consumed_items(self, gl_entries, fixed_asset_account): - if not self.get("stock_items"): - return - - # creating GL Entries for each row in Stock Items based on the Stock Entry created for it - stock_entry_name = frappe.db.get_value("Stock Entry", {"asset_repair": self.name}, "name") - stock_entry_items = frappe.get_all( - "Stock Entry Detail", filters={"parent": stock_entry_name}, fields=["expense_account", "amount"] - ) - - default_expense_account = None - if not erpnext.is_perpetual_inventory_enabled(self.company): - default_expense_account = frappe.get_cached_value( - "Company", self.company, "default_expense_account" - ) - if not default_expense_account: - frappe.throw(_("Please set default Expense Account in Company {0}").format(self.company)) - - for item in stock_entry_items: - if flt(item.amount) > 0: - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account or default_expense_account, - "credit": item.amount, - "credit_in_account_currency": item.amount, - "against": fixed_asset_account, - "voucher_type": self.doctype, - "voucher_no": self.name, - "cost_center": self.cost_center, - "posting_date": self.completion_date, - "company": self.company, - }, - item=self, - ) - ) - - gl_entries.append( - self.get_gl_dict( - { - "account": fixed_asset_account, - "debit": item.amount, - "debit_in_account_currency": item.amount, - "against": item.expense_account or default_expense_account, - "voucher_type": self.doctype, - "voucher_no": self.name, - "cost_center": self.cost_center, - "posting_date": self.completion_date, - "against_voucher_type": "Stock Entry", - "against_voucher": stock_entry_name, - "company": self.company, - }, - item=self, - ) - ) + return AssetRepairGLComposer(self).compose() def set_increase_in_asset_life(self): if self.asset_doc.calculate_depreciation and cint(self.increase_in_asset_life) > 0: diff --git a/erpnext/assets/doctype/asset_repair/services/__init__.py b/erpnext/assets/doctype/asset_repair/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_repair/services/gl_composer.py b/erpnext/assets/doctype/asset_repair/services/gl_composer.py new file mode 100644 index 00000000000..473d7d4853a --- /dev/null +++ b/erpnext/assets/doctype/asset_repair/services/gl_composer.py @@ -0,0 +1,130 @@ +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +import erpnext +from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.assets.doctype.asset.asset import get_asset_account + + +class AssetRepairGLComposer(BaseGLComposer): + """GL composer for Asset Repair. + + Builds GL entries for repair cost (per invoice) and consumed stock items + (sourced from the related Stock Entry). + """ + + def compose(self) -> list: + doc = self.doc + gl_entries = [] + + fixed_asset_account = get_asset_account("fixed_asset_account", asset=doc.asset, company=doc.company) + self._get_gl_entries_for_repair_cost(gl_entries, fixed_asset_account) + self._get_gl_entries_for_consumed_items(gl_entries, fixed_asset_account) + + return gl_entries + + def _get_gl_entries_for_repair_cost(self, gl_entries: list, fixed_asset_account: str) -> None: + doc = self.doc + if flt(doc.repair_cost) <= 0: + return + + debit_against_account = set() + + for pi in doc.invoices: + debit_against_account.add(pi.expense_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": pi.expense_account, + "credit": pi.repair_cost, + "credit_in_account_currency": pi.repair_cost, + "against": fixed_asset_account, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "cost_center": doc.cost_center, + "posting_date": doc.completion_date, + "company": doc.company, + }, + item=doc, + ) + ) + + debit_against_account_str = ", ".join(debit_against_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": fixed_asset_account, + "debit": doc.repair_cost, + "debit_in_account_currency": doc.repair_cost, + "against": debit_against_account_str, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "cost_center": doc.cost_center, + "posting_date": doc.completion_date, + "against_voucher_type": "Asset", + "against_voucher": doc.asset, + "company": doc.company, + }, + item=doc, + ) + ) + + def _get_gl_entries_for_consumed_items(self, gl_entries: list, fixed_asset_account: str) -> None: + doc = self.doc + if not doc.get("stock_items"): + return + + stock_entry_name = frappe.db.get_value("Stock Entry", {"asset_repair": doc.name}, "name") + stock_entry_items = frappe.get_all( + "Stock Entry Detail", filters={"parent": stock_entry_name}, fields=["expense_account", "amount"] + ) + + default_expense_account = None + if not erpnext.is_perpetual_inventory_enabled(doc.company): + default_expense_account = frappe.get_cached_value( + "Company", doc.company, "default_expense_account" + ) + if not default_expense_account: + frappe.throw(_("Please set default Expense Account in Company {0}").format(doc.company)) + + for item in stock_entry_items: + if flt(item.amount) > 0: + gl_entries.append( + doc.get_gl_dict( + { + "account": item.expense_account or default_expense_account, + "credit": item.amount, + "credit_in_account_currency": item.amount, + "against": fixed_asset_account, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "cost_center": doc.cost_center, + "posting_date": doc.completion_date, + "company": doc.company, + }, + item=doc, + ) + ) + + gl_entries.append( + doc.get_gl_dict( + { + "account": fixed_asset_account, + "debit": item.amount, + "debit_in_account_currency": item.amount, + "against": item.expense_account or default_expense_account, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "cost_center": doc.cost_center, + "posting_date": doc.completion_date, + "against_voucher_type": "Stock Entry", + "against_voucher": stock_entry_name, + "company": doc.company, + }, + item=doc, + ) + ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/__init__.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py new file mode 100644 index 00000000000..a0215a74bd1 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py @@ -0,0 +1,265 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.utils import get_account_currency +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class SubcontractingReceiptGLComposer(BaseStockGLComposer): + """GL composer for Subcontracting Receipt. + + Builds GL entries for accepted stock, service cost, supplier warehouse + (raw materials), additional costs, LCV, and divisional loss. + """ + + def compose(self, inventory_account_map: dict | None = None) -> list: + import erpnext + + doc = self.doc + if not erpnext.is_perpetual_inventory_enabled(doc.company): + return [] + + gl_entries = [] + self._make_item_gl_entries(gl_entries, inventory_account_map) + self._make_item_gl_entries_for_lcv(gl_entries, inventory_account_map) + + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None: + doc = self.doc + warehouse_with_no_account = [] + + supplied_items_details = frappe._dict() + for item in doc.supplied_items: + supplied_items_details.setdefault(item.reference_name, []).append( + frappe._dict( + { + "item_code": item.rm_item_code, + "amount": item.amount, + "expense_account": item.expense_account, + "cost_center": item.cost_center, + } + ) + ) + + for item in doc.items: + if flt(item.rate) and flt(item.qty): + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + + if _inv_dict.get("account"): + stock_value_diff = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Subcontracting Receipt", + "voucher_no": doc.name, + "voucher_detail_no": item.name, + "warehouse": item.warehouse, + "is_cancelled": 0, + }, + "stock_value_difference", + ) + + remarks = doc.get("remarks") or _("Accounting Entry for Stock") + + doc.add_gl_entry( + gl_entries=gl_entries, + account=_inv_dict["account"], + cost_center=item.cost_center, + debit=stock_value_diff, + credit=0.0, + remarks=remarks, + against_account=item.expense_account, + account_currency=_inv_dict["account_currency"], + project=item.project, + item=item, + ) + + service_cost = flt( + item.service_cost_per_qty, item.precision("service_cost_per_qty") + ) * flt(item.qty, item.precision("qty")) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=0.0, + credit=flt(stock_value_diff) - service_cost, + remarks=remarks, + against_account=_inv_dict["account"], + account_currency=get_account_currency(item.expense_account), + project=item.project, + item=item, + ) + + service_account = item.service_expense_account or item.expense_account + doc.add_gl_entry( + gl_entries=gl_entries, + account=service_account, + cost_center=item.cost_center, + debit=0.0, + credit=service_cost, + remarks=remarks, + against_account=_inv_dict["account"], + account_currency=get_account_currency(service_account), + project=item.project, + item=item, + ) + + if flt(item.rm_supp_cost): + for rm_item in supplied_items_details.get(item.name): + _inv_dict = doc.get_inventory_account_dict( + rm_item, inventory_account_map, "supplier_warehouse" + ) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=_inv_dict.get("account"), + cost_center=rm_item.cost_center or item.cost_center, + debit=0.0, + credit=flt(rm_item.amount), + remarks=remarks, + against_account=rm_item.expense_account or item.expense_account, + account_currency=_inv_dict.get("account_currency"), + project=item.project, + item=item, + ) + doc.add_gl_entry( + gl_entries=gl_entries, + account=rm_item.expense_account or item.expense_account, + cost_center=rm_item.cost_center or item.cost_center, + debit=flt(rm_item.amount), + credit=0.0, + remarks=remarks, + against_account=_inv_dict.get("account"), + account_currency=get_account_currency(item.expense_account), + project=item.project, + item=item, + ) + + if item.additional_cost_per_qty: + doc.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=doc.cost_center or doc.get_company_default("cost_center"), + debit=item.qty * item.additional_cost_per_qty, + credit=0.0, + remarks=remarks, + against_account=None, + account_currency=get_account_currency(item.expense_account), + ) + + if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")): + loss_account = doc.get_company_default( + "stock_adjustment_account", ignore_validation=True + ) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=loss_account, + cost_center=item.cost_center, + debit=0.0, + credit=divisional_loss, + remarks=remarks, + against_account=item.expense_account, + account_currency=get_account_currency(loss_account), + project=item.project, + item=item, + ) + doc.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=divisional_loss, + credit=0.0, + remarks=remarks, + against_account=loss_account, + account_currency=get_account_currency(item.expense_account), + project=item.project, + item=item, + ) + elif ( + item.warehouse not in warehouse_with_no_account + or item.rejected_warehouse not in warehouse_with_no_account + ): + warehouse_with_no_account.append(item.warehouse) + + for row in doc.additional_costs: + credit_amount = ( + flt(row.base_amount) + if (row.base_amount or row.account_currency != doc.company_currency) + else flt(row.amount) + ) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=row.expense_account, + cost_center=doc.cost_center or doc.get_company_default("cost_center"), + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=None, + account_currency=get_account_currency(row.expense_account), + ) + + if warehouse_with_no_account: + frappe.msgprint( + _("No accounting entries for the following warehouses") + + ": \n" + + "\n".join(warehouse_with_no_account) + ) + + def _make_item_gl_entries_for_lcv(self, gl_entries: list, inventory_account_map: dict | None) -> None: + doc = self.doc + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + + if not landed_cost_entries: + return + + for item in doc.items: + if item.landed_cost_voucher_amount and landed_cost_entries: + remarks = _("Accounting Entry for Landed Cost Voucher for SCR {0}").format(doc.name) + if (item.item_code, item.name) in landed_cost_entries: + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + + for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): + account_currency = get_account_currency(account) + credit_amount = ( + flt(amount["base_amount"]) + if (amount["base_amount"] or account_currency != doc.company_currency) + else flt(amount["amount"]) + ) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=_inv_dict["account"], + credit_in_account_currency=flt(amount["amount"]), + account_currency=account_currency, + project=item.project, + item=item, + ) + + account_currency = get_account_currency(item.expense_account) + + doc.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=0.0, + credit=credit_amount * -1, + remarks=remarks, + against_account=_inv_dict["account"], + debit_in_account_currency=flt(amount["amount"]), + account_currency=account_currency, + project=item.project, + item=item, + ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 26ad0039070..1ae55f47017 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -11,7 +11,6 @@ from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate import erpnext -from erpnext.accounts.utils import get_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.setup.doctype.brand.brand import get_brand_defaults @@ -731,257 +730,11 @@ class SubcontractingReceipt(SubcontractingController): ) def get_gl_entries(self, inventory_account_map=None): - from erpnext.accounts.general_ledger import process_gl_map + from erpnext.subcontracting.doctype.subcontracting_receipt.services.gl_composer import ( + SubcontractingReceiptGLComposer, + ) - if not erpnext.is_perpetual_inventory_enabled(self.company): - return [] - - gl_entries = [] - self.make_item_gl_entries(gl_entries, inventory_account_map) - self.make_item_gl_entries_for_lcv(gl_entries, inventory_account_map) - - return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) - - def make_item_gl_entries(self, gl_entries, inventory_account_map=None): - warehouse_with_no_account = [] - - supplied_items_details = frappe._dict() - for item in self.supplied_items: - supplied_items_details.setdefault(item.reference_name, []).append( - frappe._dict( - { - "item_code": item.rm_item_code, - "amount": item.amount, - "expense_account": item.expense_account, - "cost_center": item.cost_center, - } - ) - ) - - for item in self.items: - if flt(item.rate) and flt(item.qty): - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map) - - if _inv_dict.get("account"): - stock_value_diff = frappe.db.get_value( - "Stock Ledger Entry", - { - "voucher_type": "Subcontracting Receipt", - "voucher_no": self.name, - "voucher_detail_no": item.name, - "warehouse": item.warehouse, - "is_cancelled": 0, - }, - "stock_value_difference", - ) - - remarks = self.get("remarks") or _("Accounting Entry for Stock") - - # Accepted Warehouse Account (Debit) - self.add_gl_entry( - gl_entries=gl_entries, - account=_inv_dict["account"], - cost_center=item.cost_center, - debit=stock_value_diff, - credit=0.0, - remarks=remarks, - against_account=item.expense_account, - account_currency=_inv_dict["account_currency"], - project=item.project, - item=item, - ) - - service_cost = flt( - item.service_cost_per_qty, item.precision("service_cost_per_qty") - ) * flt(item.qty, item.precision("qty")) - # Expense Account (Credit) - self.add_gl_entry( - gl_entries=gl_entries, - account=item.expense_account, - cost_center=item.cost_center, - debit=0.0, - credit=flt(stock_value_diff) - service_cost, - remarks=remarks, - against_account=_inv_dict["account"], - account_currency=get_account_currency(item.expense_account), - project=item.project, - item=item, - ) - - service_account = item.service_expense_account or item.expense_account - # Expense Account (Credit) - self.add_gl_entry( - gl_entries=gl_entries, - account=service_account, - cost_center=item.cost_center, - debit=0.0, - credit=service_cost, - remarks=remarks, - against_account=_inv_dict["account"], - account_currency=get_account_currency(service_account), - project=item.project, - item=item, - ) - - if flt(item.rm_supp_cost): - for rm_item in supplied_items_details.get(item.name): - _inv_dict = self.get_inventory_account_dict( - rm_item, inventory_account_map, "supplier_warehouse" - ) - - # Supplier Warehouse Account (Credit) - self.add_gl_entry( - gl_entries=gl_entries, - account=_inv_dict.get("account"), - cost_center=rm_item.cost_center or item.cost_center, - debit=0.0, - credit=flt(rm_item.amount), - remarks=remarks, - against_account=rm_item.expense_account or item.expense_account, - account_currency=_inv_dict.get("account_currency"), - project=item.project, - item=item, - ) - # Expense Account (Debit) - self.add_gl_entry( - gl_entries=gl_entries, - account=rm_item.expense_account or item.expense_account, - cost_center=rm_item.cost_center or item.cost_center, - debit=flt(rm_item.amount), - credit=0.0, - remarks=remarks, - against_account=_inv_dict.get("account"), - account_currency=get_account_currency(item.expense_account), - project=item.project, - item=item, - ) - - # Expense Account (Debit) - if item.additional_cost_per_qty: - self.add_gl_entry( - gl_entries=gl_entries, - account=item.expense_account, - cost_center=self.cost_center or self.get_company_default("cost_center"), - debit=item.qty * item.additional_cost_per_qty, - credit=0.0, - remarks=remarks, - against_account=None, - account_currency=get_account_currency(item.expense_account), - ) - - if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")): - loss_account = self.get_company_default( - "stock_adjustment_account", ignore_validation=True - ) - - # Loss Account (Credit) - self.add_gl_entry( - gl_entries=gl_entries, - account=loss_account, - cost_center=item.cost_center, - debit=0.0, - credit=divisional_loss, - remarks=remarks, - against_account=item.expense_account, - account_currency=get_account_currency(loss_account), - project=item.project, - item=item, - ) - # Expense Account (Debit) - self.add_gl_entry( - gl_entries=gl_entries, - account=item.expense_account, - cost_center=item.cost_center, - debit=divisional_loss, - credit=0.0, - remarks=remarks, - against_account=loss_account, - account_currency=get_account_currency(item.expense_account), - project=item.project, - item=item, - ) - elif ( - item.warehouse not in warehouse_with_no_account - or item.rejected_warehouse not in warehouse_with_no_account - ): - warehouse_with_no_account.append(item.warehouse) - - for row in self.additional_costs: - credit_amount = ( - flt(row.base_amount) - if (row.base_amount or row.account_currency != self.company_currency) - else flt(row.amount) - ) - - # Additional Cost Expense Account (Credit) - self.add_gl_entry( - gl_entries=gl_entries, - account=row.expense_account, - cost_center=self.cost_center or self.get_company_default("cost_center"), - debit=0.0, - credit=credit_amount, - remarks=remarks, - against_account=None, - account_currency=get_account_currency(row.expense_account), - ) - - if warehouse_with_no_account: - frappe.msgprint( - _("No accounting entries for the following warehouses") - + ": \n" - + "\n".join(warehouse_with_no_account) - ) - - def make_item_gl_entries_for_lcv(self, gl_entries, inventory_account_map): - landed_cost_entries = self.get_item_account_wise_lcv_entries() - - if not landed_cost_entries: - return - - for item in self.items: - if item.landed_cost_voucher_amount and landed_cost_entries: - remarks = _("Accounting Entry for Landed Cost Voucher for SCR {0}").format(self.name) - if (item.item_code, item.name) in landed_cost_entries: - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map) - - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != self.company_currency) - else flt(amount["amount"]) - ) - - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=item.cost_center, - debit=0.0, - credit=credit_amount, - remarks=remarks, - against_account=_inv_dict["account"], - credit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=item.project, - item=item, - ) - - account_currency = get_account_currency(item.expense_account) - - # credit amount in negative to knock off the debit entry - self.add_gl_entry( - gl_entries=gl_entries, - account=item.expense_account, - cost_center=item.cost_center, - debit=0.0, - credit=credit_amount * -1, - remarks=remarks, - against_account=_inv_dict["account"], - debit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=item.project, - item=item, - ) + return SubcontractingReceiptGLComposer(self).compose(inventory_account_map) def auto_create_purchase_receipt(self): if frappe.db.get_single_value("Buying Settings", "auto_create_purchase_receipt"): From 58c90ad651d45725c5edc305eec2b1d32ea59c21 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 15:55:28 +0530 Subject: [PATCH 24/65] refactor(accounts): extract advance payment logic into accounts/services/advances.py Moves all advance-related query and management logic out of the 4500-line AccountsController into a dedicated module-level service: - get_advance_journal_entries, get_advance_payment_entries, get_advance_payment_entries_for_regional, get_common_query - set_advances, get_advance_entries, validate_advance_entries, set_advance_gain_or_loss, calculate_total_advance_from_ledger, set_total_advance_paid, set_advance_payment_status, delink_advance_entries, create_advance_and_reconcile AccountsController methods become thin shims; module-level functions in accounts_controller.py are replaced with re-exports for backward compatibility. payment_reconciliation.py updated to import directly from the new service. All 29 GL snapshots, 121 SI tests, 53 PE tests, and 37 payment reconciliation tests pass. --- .../payment_reconciliation.py | 2 +- erpnext/accounts/services/advances.py | 510 +++++++++++++++++ erpnext/controllers/accounts_controller.py | 513 +----------------- 3 files changed, 538 insertions(+), 487 deletions(-) create mode 100644 erpnext/accounts/services/advances.py diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index d1ffca800a3..6e5998a62a2 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -15,13 +15,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( is_any_doc_running, ) +from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional from erpnext.accounts.utils import ( QueryPaymentLedger, create_gain_loss_journal, get_outstanding_invoices, reconcile_against_document, ) -from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional class PaymentReconciliation(Document): diff --git a/erpnext/accounts/services/advances.py b/erpnext/accounts/services/advances.py new file mode 100644 index 00000000000..893ce4ff4f8 --- /dev/null +++ b/erpnext/accounts/services/advances.py @@ -0,0 +1,510 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Advance payment query and management functions. + +All functions take a `doc` (AccountsController instance) as first argument so +they can be called as module-level functions from any doctype, while keeping +the AccountsController methods as thin shims. +""" + +import frappe +from frappe import _ +from frappe.query_builder import Criterion +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Abs, Sum +from frappe.utils import flt + +import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_dimensions, +) +from erpnext.accounts.party import get_party_account +from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes +from erpnext.setup.utils import get_exchange_rate + + +def set_advances(doc) -> None: + """Populate the advances child table from open advance entries.""" + res = get_advance_entries( + doc, include_unallocated=not frappe.utils.cint(doc.get("only_include_allocated_payments")) + ) + + doc.set("advances", []) + advance_allocated = 0 + for d in res: + if doc.get("party_account_currency") == doc.company_currency: + amount = doc.get("base_rounded_total") or doc.base_grand_total + else: + amount = doc.get("rounded_total") or doc.grand_total + allocated_amount = min(amount - advance_allocated, d.amount) + advance_allocated += flt(allocated_amount) + + advance_row = { + "doctype": doc.doctype + " Advance", + "reference_type": d.reference_type, + "reference_name": d.reference_name, + "reference_row": d.reference_row, + "remarks": d.remarks, + "advance_amount": flt(d.amount), + "allocated_amount": allocated_amount, + "ref_exchange_rate": flt(d.exchange_rate), + "difference_posting_date": doc.posting_date, + } + if d.get("paid_from"): + advance_row["account"] = d.paid_from + if d.get("paid_to"): + advance_row["account"] = d.paid_to + + doc.append("advances", advance_row) + + +def get_advance_entries(doc, include_unallocated: bool = True) -> list: + """Return advance journal and payment entries applicable to `doc`.""" + party_account = [] + default_advance_account = None + + if doc.doctype in ["Sales Invoice", "POS Invoice"]: + party_type = "Customer" + party = doc.customer + amount_field = "credit_in_account_currency" + order_field = "sales_order" + order_doctype = "Sales Order" + party_account.append(doc.debit_to) + else: + party_type = "Supplier" + party = doc.supplier + amount_field = "debit_in_account_currency" + order_field = "purchase_order" + order_doctype = "Purchase Order" + party_account.append(doc.credit_to) + + party_accounts = get_party_account(party_type, party=party, company=doc.company, include_advance=True) + + if party_accounts: + party_account.append(party_accounts[0]) + default_advance_account = party_accounts[1] if len(party_accounts) == 2 else None + + order_list = list(set(d.get(order_field) for d in doc.get("items") if d.get(order_field))) + + journal_entries = get_advance_journal_entries( + party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated + ) + + payment_entries = get_advance_payment_entries_for_regional( + party_type, + party, + party_account, + order_doctype, + order_list, + default_advance_account, + include_unallocated, + ) + + return journal_entries + payment_entries + + +def validate_advance_entries(doc) -> None: + """Warn if a payment entry linked to the same order is not pulled as advance.""" + order_field = "sales_order" if doc.doctype == "Sales Invoice" else "purchase_order" + order_list = list(set(d.get(order_field) for d in doc.get("items") if d.get(order_field))) + + if not order_list: + return + + advance_entries = get_advance_entries(doc, include_unallocated=False) + + if advance_entries: + advance_entries_against_si = [d.reference_name for d in doc.get("advances")] + for d in advance_entries: + if not advance_entries_against_si or d.reference_name not in advance_entries_against_si: + frappe.msgprint( + _( + "Payment Entry {0} is linked against Order {1}, check if it should be pulled as advance in this invoice." + ).format(d.reference_name, d.against_order) + ) + + +def set_advance_gain_or_loss(doc) -> None: + """Compute exchange gain/loss for each allocated advance row.""" + if doc.get("conversion_rate") == 1 or not doc.get("advances"): + return + + is_purchase_invoice = doc.doctype == "Purchase Invoice" + party_account = doc.credit_to if is_purchase_invoice else doc.debit_to + if get_account_currency(party_account) != doc.currency: + return + + for d in doc.get("advances"): + advance_exchange_rate = d.ref_exchange_rate + if d.allocated_amount and doc.conversion_rate != advance_exchange_rate: + base_allocated_amount_in_ref_rate = advance_exchange_rate * d.allocated_amount + base_allocated_amount_in_inv_rate = doc.conversion_rate * d.allocated_amount + difference = base_allocated_amount_in_ref_rate - base_allocated_amount_in_inv_rate + + d.exchange_gain_loss = difference + + +def calculate_total_advance_from_ledger(doc) -> list: + """Query the Advance Payment Ledger for the total advance against `doc`.""" + adv = frappe.qb.DocType("Advance Payment Ledger Entry") + return ( + frappe.qb.from_(adv) + .select(Abs(Sum(adv.amount)).as_("amount"), adv.currency.as_("account_currency")) + .where(adv.company == doc.company) + .where(adv.delinked == 0) + .where(adv.against_voucher_type == doc.doctype) + .where(adv.against_voucher_no == doc.name) + .run(as_dict=True) + ) + + +def set_total_advance_paid(doc) -> None: + """Update advance_paid field and payment status from the ledger.""" + advance = calculate_total_advance_from_ledger(doc) + advance_paid = 0 + + if advance: + advance = advance[0] + advance_paid = flt(advance.amount, doc.precision("advance_paid")) + if advance.account_currency: + frappe.db.set_value(doc.doctype, doc.name, "party_account_currency", advance.account_currency) + + doc.db_set("advance_paid", advance_paid) + set_advance_payment_status(doc) + + +def set_advance_payment_status(doc) -> None: + """Sync advance_payment_status with current ledger and Payment Request state.""" + new_status = None + + PaymentRequest = frappe.qb.DocType("Payment Request") + paid_amount = frappe.get_value( + doctype="Payment Request", + filters={ + "reference_doctype": doc.doctype, + "reference_name": doc.name, + "docstatus": 1, + }, + fieldname=Sum(PaymentRequest.grand_total - PaymentRequest.outstanding_amount), + ) + + if not paid_amount: + if doc.doctype in get_advance_payment_doctypes(payment_type="receivable"): + new_status = "Not Requested" if paid_amount is None else "Requested" + elif doc.doctype in get_advance_payment_doctypes(payment_type="payable"): + new_status = "Not Initiated" if paid_amount is None else "Initiated" + else: + total_amount = doc.get("rounded_total") or doc.get("grand_total") + new_status = "Fully Paid" if paid_amount == total_amount else "Partially Paid" + + if new_status == doc.advance_payment_status: + return + + doc.db_set("advance_payment_status", new_status, update_modified=False) + doc.set_status(update=True) + doc.notify_update() + + +def delink_advance_entries(doc, linked_doc_name: str) -> None: + """Remove advance rows linked to `linked_doc_name` and update total_advance.""" + total_allocated_amount = 0 + for adv in doc.advances: + consider_for_total_advance = True + if adv.reference_name == linked_doc_name: + doctype = frappe.qb.DocType(doc.doctype + " Advance") + frappe.qb.from_(doctype).delete().where(doctype.name == adv.name).run() + + consider_for_total_advance = False + + if consider_for_total_advance: + total_allocated_amount += flt(adv.allocated_amount, adv.precision("allocated_amount")) + + frappe.db.set_value(doc.doctype, doc.name, "total_advance", total_allocated_amount, update_modified=False) + + +def create_advance_and_reconcile(doc, party_link) -> None: + """Create a Journal Entry to reconcile a party-link advance.""" + secondary_party_type, secondary_party = doc.get_party() + primary_party_type, primary_party = party_link.primary_role, party_link.primary_party + + primary_account = get_party_account(primary_party_type, primary_party, doc.company) + secondary_account = get_party_account(secondary_party_type, secondary_party, doc.company) + primary_account_currency = get_account_currency(primary_account) + secondary_account_currency = get_account_currency(secondary_account) + default_currency = erpnext.get_company_currency(doc.company) + + multi_currency = ( + primary_account_currency != default_currency or secondary_account_currency != default_currency + ) + + jv = frappe.new_doc("Journal Entry") + jv.voucher_type = "Journal Entry" + jv.posting_date = doc.posting_date + jv.company = doc.company + jv.remark = f"Adjustment for {doc.doctype} {doc.name}" + jv.is_system_generated = True + + reconcilation_entry = frappe._dict() + advance_entry = frappe._dict() + + reconcilation_entry.account = secondary_account + reconcilation_entry.party_type = secondary_party_type + reconcilation_entry.party = secondary_party + reconcilation_entry.reference_type = doc.doctype + reconcilation_entry.reference_name = doc.name + reconcilation_entry.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company) + + advance_entry.account = primary_account + advance_entry.party_type = primary_party_type + advance_entry.party = primary_party + advance_entry.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company) + advance_entry.is_advance = "No" if doc.is_return else "Yes" + + dimensions_dict = frappe._dict() + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = doc.get(dim.fieldname) + + reconcilation_entry.update(dimensions_dict) + advance_entry.update(dimensions_dict) + + if multi_currency: + exc_rate_primary_to_default = ( + 1 + if primary_account_currency == default_currency + else get_exchange_rate(primary_account_currency, default_currency, doc.posting_date) + ) + exc_rate_secondary_to_default = ( + 1 + if secondary_account_currency == default_currency + else get_exchange_rate(secondary_account_currency, default_currency, doc.posting_date) + ) + exc_rate_secondary_to_primary = ( + 1 + if secondary_account_currency == primary_account_currency + else get_exchange_rate(secondary_account_currency, primary_account_currency, doc.posting_date) + ) + + outstanding_amount = abs(doc.outstanding_amount) + os_in_default_currency = outstanding_amount * exc_rate_secondary_to_default + os_in_primary_currency = outstanding_amount * exc_rate_secondary_to_primary + + reconciliation_is_credit = (doc.doctype == "Sales Invoice") != bool(doc.is_return) + _set_je_amounts( + reconcilation_entry, outstanding_amount, os_in_default_currency, reconciliation_is_credit + ) + _set_je_amounts( + advance_entry, os_in_primary_currency, os_in_default_currency, not reconciliation_is_credit + ) + + reconcilation_entry.exchange_rate = exc_rate_secondary_to_default + advance_entry.exchange_rate = exc_rate_primary_to_default + else: + outstanding_amount = abs(doc.outstanding_amount) + reconciliation_is_credit = (doc.doctype == "Sales Invoice") != bool(doc.is_return) + _set_je_amounts(reconcilation_entry, outstanding_amount, is_credit=reconciliation_is_credit) + _set_je_amounts(advance_entry, outstanding_amount, is_credit=not reconciliation_is_credit) + + jv.multi_currency = multi_currency + jv.append("accounts", reconcilation_entry) + jv.append("accounts", advance_entry) + + jv.save() + jv.submit() + + +def get_advance_journal_entries( + party_type: str, + party: str, + party_account: list, + amount_field: str, + order_doctype: str, + order_list: list, + include_unallocated: bool = True, +) -> list: + """Return open advance journal entry rows matching the given party and orders.""" + journal_entry = frappe.qb.DocType("Journal Entry") + journal_acc = frappe.qb.DocType("Journal Entry Account") + q = ( + frappe.qb.from_(journal_entry) + .inner_join(journal_acc) + .on(journal_entry.name == journal_acc.parent) + .select( + ConstantColumn("Journal Entry").as_("reference_type"), + (journal_entry.name).as_("reference_name"), + (journal_entry.remark).as_("remarks"), + (journal_acc[amount_field]).as_("amount"), + (journal_acc.name).as_("reference_row"), + (journal_acc.reference_name).as_("against_order"), + (journal_acc.exchange_rate), + ) + .where( + journal_acc.account.isin(party_account) + & (journal_acc.party_type == party_type) + & (journal_acc.party == party) + & (journal_acc.is_advance == "Yes") + & (journal_entry.docstatus == 1) + ) + ) + if party_type == "Customer": + q = q.where(journal_acc.credit_in_account_currency > 0) + else: + q = q.where(journal_acc.debit_in_account_currency > 0) + + reference_or_condition = [] + + if include_unallocated: + reference_or_condition.append(journal_acc.reference_name.isnull()) + reference_or_condition.append(journal_acc.reference_name == "") + + if order_list: + reference_or_condition.append( + (journal_acc.reference_type == order_doctype) & ((journal_acc.reference_name).isin(order_list)) + ) + + if reference_or_condition: + q = q.where(Criterion.any(reference_or_condition)) + + q = q.orderby(journal_entry.posting_date) + + return list(q.run(as_dict=True)) + + +@erpnext.allow_regional +def get_advance_payment_entries_for_regional(*args, **kwargs): + return get_advance_payment_entries(*args, **kwargs) + + +def get_advance_payment_entries( + party_type: str, + party: str, + party_account: list, + order_doctype: str, + order_list: list | None = None, + default_advance_account: str | None = None, + include_unallocated: bool = True, + against_all_orders: bool = False, + limit: int | None = None, + condition: dict | None = None, +) -> list: + """Return open advance payment entry rows matching the given party and orders.""" + payment_entries = [] + payment_entry = frappe.qb.DocType("Payment Entry") + + if order_list or against_all_orders: + q = get_common_query(party_type, party, party_account, default_advance_account, limit, condition) + payment_ref = frappe.qb.DocType("Payment Entry Reference") + + q = q.inner_join(payment_ref).on(payment_entry.name == payment_ref.parent) + q = q.select( + (payment_ref.allocated_amount).as_("amount"), + (payment_ref.name).as_("reference_row"), + (payment_ref.reference_name).as_("against_order"), + (payment_entry.book_advance_payments_in_separate_party_account), + ) + + q = q.where(payment_ref.reference_doctype == order_doctype) + if order_list: + q = q.where(payment_ref.reference_name.isin(order_list)) + + payment_entries += list(q.run(as_dict=True)) + + if include_unallocated: + q = get_common_query(party_type, party, party_account, default_advance_account, limit, condition) + q = q.select((payment_entry.unallocated_amount).as_("amount")) + q = q.where(payment_entry.unallocated_amount > 0) + + payment_entries += list(q.run(as_dict=True)) + + return payment_entries + + +def get_common_query( + party_type: str, + party: str, + party_account: list, + default_advance_account: str | None, + limit: int | None, + condition: dict | None, +): + """Build the base Payment Entry query shared by allocated and unallocated advance lookups.""" + account_type = frappe.db.get_value("Party Type", party_type, "account_type") + payment_type = "Receive" if account_type == "Receivable" else "Pay" + payment_entry = frappe.qb.DocType("Payment Entry") + + q = ( + frappe.qb.from_(payment_entry) + .select( + ConstantColumn("Payment Entry").as_("reference_type"), + (payment_entry.name).as_("reference_name"), + payment_entry.posting_date, + (payment_entry.remarks).as_("remarks"), + (payment_entry.book_advance_payments_in_separate_party_account), + ) + .where(payment_entry.payment_type == payment_type) + .where(payment_entry.party_type == party_type) + .where(payment_entry.party == party) + .where(payment_entry.docstatus == 1) + ) + + field = "paid_from" if payment_type == "Receive" else "paid_to" + q = q.select((payment_entry[f"{field}_account_currency"]).as_("currency")) + q = q.select(payment_entry[field]) + account_condition = payment_entry[field].isin(party_account) + if default_advance_account: + q = q.where( + account_condition + | ( + (payment_entry[field] == default_advance_account) + & (payment_entry.book_advance_payments_in_separate_party_account == 1) + ) + ) + else: + q = q.where(account_condition) + + if payment_type == "Receive": + q = q.select((payment_entry.source_exchange_rate).as_("exchange_rate")) + else: + q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate")) + + if condition: + common_filter_conditions = [] + common_filter_conditions.append(payment_entry.company == condition["company"]) + if condition.get("name", None): + common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%")) + if condition.get("from_payment_date"): + common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"])) + if condition.get("to_payment_date"): + common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"])) + if condition.get("get_payments") is True: + if condition.get("cost_center"): + common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"]) + if condition.get("accounting_dimensions"): + for field, val in condition.get("accounting_dimensions").items(): + common_filter_conditions.append(payment_entry[field] == val) + if condition.get("minimum_payment_amount"): + common_filter_conditions.append( + payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"]) + ) + if condition.get("maximum_payment_amount"): + common_filter_conditions.append( + payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"]) + ) + q = q.where(Criterion.all(common_filter_conditions)) + + q = q.orderby(payment_entry.posting_date) + q = q.limit(limit) if limit else q + + return q + + +def _set_je_amounts(entry, amount, default_amount=None, is_credit=True): + if is_credit: + entry.credit_in_account_currency = amount + if default_amount is not None: + entry.credit = default_amount + else: + entry.debit_in_account_currency = amount + if default_amount is not None: + entry.debit = default_amount diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 177118a0fd2..548fc6e515c 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -9,9 +9,8 @@ import frappe from frappe import _, bold, qb, throw from frappe.contacts.doctype.address.address import get_address_display from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied -from frappe.query_builder import Criterion, DocType -from frappe.query_builder.custom import ConstantColumn -from frappe.query_builder.functions import Abs, Sum +from frappe.query_builder import DocType +from frappe.query_builder.functions import Sum from frappe.utils import ( DateTimeLikeObject, add_days, @@ -1507,86 +1506,14 @@ class AccountsController(TransactionBase): @frappe.whitelist() def set_advances(self): - """Returns list of advances against Account, Party, Reference""" + from erpnext.accounts.services.advances import set_advances - res = self.get_advance_entries( - include_unallocated=not cint(self.get("only_include_allocated_payments")) - ) - - self.set("advances", []) - advance_allocated = 0 - for d in res: - if self.get("party_account_currency") == self.company_currency: - amount = self.get("base_rounded_total") or self.base_grand_total - else: - amount = self.get("rounded_total") or self.grand_total - allocated_amount = min(amount - advance_allocated, d.amount) - advance_allocated += flt(allocated_amount) - - advance_row = { - "doctype": self.doctype + " Advance", - "reference_type": d.reference_type, - "reference_name": d.reference_name, - "reference_row": d.reference_row, - "remarks": d.remarks, - "advance_amount": flt(d.amount), - "allocated_amount": allocated_amount, - "ref_exchange_rate": flt(d.exchange_rate), # exchange_rate of advance entry - "difference_posting_date": self.posting_date, - } - if d.get("paid_from"): - advance_row["account"] = d.paid_from - if d.get("paid_to"): - advance_row["account"] = d.paid_to - - self.append("advances", advance_row) + set_advances(self) def get_advance_entries(self, include_unallocated=True): - party_account = [] - default_advance_account = None + from erpnext.accounts.services.advances import get_advance_entries - if self.doctype in ["Sales Invoice", "POS Invoice"]: - party_type = "Customer" - party = self.customer - amount_field = "credit_in_account_currency" - order_field = "sales_order" - order_doctype = "Sales Order" - party_account.append(self.debit_to) - else: - party_type = "Supplier" - party = self.supplier - amount_field = "debit_in_account_currency" - order_field = "purchase_order" - order_doctype = "Purchase Order" - party_account.append(self.credit_to) - - party_accounts = get_party_account( - party_type, party=party, company=self.company, include_advance=True - ) - - if party_accounts: - party_account.append(party_accounts[0]) - default_advance_account = party_accounts[1] if len(party_accounts) == 2 else None - - order_list = list(set(d.get(order_field) for d in self.get("items") if d.get(order_field))) - - journal_entries = get_advance_journal_entries( - party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated - ) - - payment_entries = get_advance_payment_entries_for_regional( - party_type, - party, - party_account, - order_doctype, - order_list, - default_advance_account, - include_unallocated, - ) - - res = journal_entries + payment_entries - - return res + return get_advance_entries(self, include_unallocated) def is_inclusive_tax(self): is_inclusive = cint(frappe.get_single_value("Accounts Settings", "show_inclusive_tax_in_print")) @@ -1602,41 +1529,14 @@ class AccountsController(TransactionBase): return cint(frappe.get_single_value("Accounts Settings", "show_taxes_as_table_in_print")) def validate_advance_entries(self): - order_field = "sales_order" if self.doctype == "Sales Invoice" else "purchase_order" - order_list = list(set(d.get(order_field) for d in self.get("items") if d.get(order_field))) + from erpnext.accounts.services.advances import validate_advance_entries - if not order_list: - return - - advance_entries = self.get_advance_entries(include_unallocated=False) - - if advance_entries: - advance_entries_against_si = [d.reference_name for d in self.get("advances")] - for d in advance_entries: - if not advance_entries_against_si or d.reference_name not in advance_entries_against_si: - frappe.msgprint( - _( - "Payment Entry {0} is linked against Order {1}, check if it should be pulled as advance in this invoice." - ).format(d.reference_name, d.against_order) - ) + validate_advance_entries(self) def set_advance_gain_or_loss(self): - if self.get("conversion_rate") == 1 or not self.get("advances"): - return + from erpnext.accounts.services.advances import set_advance_gain_or_loss - is_purchase_invoice = self.doctype == "Purchase Invoice" - party_account = self.credit_to if is_purchase_invoice else self.debit_to - if get_account_currency(party_account) != self.currency: - return - - for d in self.get("advances"): - advance_exchange_rate = d.ref_exchange_rate - if d.allocated_amount and self.conversion_rate != advance_exchange_rate: - base_allocated_amount_in_ref_rate = advance_exchange_rate * d.allocated_amount - base_allocated_amount_in_inv_rate = self.conversion_rate * d.allocated_amount - difference = base_allocated_amount_in_ref_rate - base_allocated_amount_in_inv_rate - - d.exchange_gain_loss = difference + set_advance_gain_or_loss(self) def make_precision_loss_gl_entry(self, gl_entries): ( @@ -2307,62 +2207,19 @@ class AccountsController(TransactionBase): return asset_items def calculate_total_advance_from_ledger(self): - adv = frappe.qb.DocType("Advance Payment Ledger Entry") - return ( - qb.from_(adv) - .select(Abs(Sum(adv.amount)).as_("amount"), adv.currency.as_("account_currency")) - .where(adv.company == self.company) - .where(adv.delinked == 0) - .where(adv.against_voucher_type == self.doctype) - .where(adv.against_voucher_no == self.name) - .run(as_dict=True) - ) + from erpnext.accounts.services.advances import calculate_total_advance_from_ledger + + return calculate_total_advance_from_ledger(self) def set_total_advance_paid(self): - advance = self.calculate_total_advance_from_ledger() - advance_paid = 0 + from erpnext.accounts.services.advances import set_total_advance_paid - if advance: - advance = advance[0] - - advance_paid = flt(advance.amount, self.precision("advance_paid")) - if advance.account_currency: - frappe.db.set_value( - self.doctype, self.name, "party_account_currency", advance.account_currency - ) - - self.db_set("advance_paid", advance_paid) - self.set_advance_payment_status() + set_total_advance_paid(self) def set_advance_payment_status(self): - new_status = None + from erpnext.accounts.services.advances import set_advance_payment_status - PaymentRequest = frappe.qb.DocType("Payment Request") - paid_amount = frappe.get_value( - doctype="Payment Request", - filters={ - "reference_doctype": self.doctype, - "reference_name": self.name, - "docstatus": 1, - }, - fieldname=Sum(PaymentRequest.grand_total - PaymentRequest.outstanding_amount), - ) - - if not paid_amount: - if self.doctype in self.get_advance_payment_doctypes(payment_type="receivable"): - new_status = "Not Requested" if paid_amount is None else "Requested" - elif self.doctype in self.get_advance_payment_doctypes(payment_type="payable"): - new_status = "Not Initiated" if paid_amount is None else "Initiated" - else: - total_amount = self.get("rounded_total") or self.get("grand_total") - new_status = "Fully Paid" if paid_amount == total_amount else "Partially Paid" - - if new_status == self.advance_payment_status: - return - - self.db_set("advance_payment_status", new_status, update_modified=False) - self.set_status(update=True) - self.notify_update() + set_advance_payment_status(self) @property def company_abbr(self): @@ -2472,21 +2329,9 @@ class AccountsController(TransactionBase): ) def delink_advance_entries(self, linked_doc_name): - total_allocated_amount = 0 - for adv in self.advances: - consider_for_total_advance = True - if adv.reference_name == linked_doc_name: - doctype = frappe.qb.DocType(self.doctype + " Advance") - frappe.qb.from_(doctype).delete().where(doctype.name == adv.name).run() + from erpnext.accounts.services.advances import delink_advance_entries - consider_for_total_advance = False - - if consider_for_total_advance: - total_allocated_amount += flt(adv.allocated_amount, adv.precision("allocated_amount")) - - frappe.db.set_value( - self.doctype, self.name, "total_advance", total_allocated_amount, update_modified=False - ) + delink_advance_entries(self, linked_doc_name) def group_similar_items(self): grouped_items = {} @@ -2865,102 +2710,9 @@ class AccountsController(TransactionBase): ) def create_advance_and_reconcile(self, party_link): - secondary_party_type, secondary_party = self.get_party() - primary_party_type, primary_party = party_link.primary_role, party_link.primary_party + from erpnext.accounts.services.advances import create_advance_and_reconcile - primary_account = get_party_account(primary_party_type, primary_party, self.company) - secondary_account = get_party_account(secondary_party_type, secondary_party, self.company) - primary_account_currency = get_account_currency(primary_account) - secondary_account_currency = get_account_currency(secondary_account) - default_currency = erpnext.get_company_currency(self.company) - - # Determine if multi-currency journal entry is needed - multi_currency = ( - primary_account_currency != default_currency or secondary_account_currency != default_currency - ) - - jv = frappe.new_doc("Journal Entry") - jv.voucher_type = "Journal Entry" - jv.posting_date = self.posting_date - jv.company = self.company - jv.remark = f"Adjustment for {self.doctype} {self.name}" - jv.is_system_generated = True - - reconcilation_entry = frappe._dict() - advance_entry = frappe._dict() - - reconcilation_entry.account = secondary_account - reconcilation_entry.party_type = secondary_party_type - reconcilation_entry.party = secondary_party - reconcilation_entry.reference_type = self.doctype - reconcilation_entry.reference_name = self.name - reconcilation_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company) - - advance_entry.account = primary_account - advance_entry.party_type = primary_party_type - advance_entry.party = primary_party - advance_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company) - # For returns the direction is reversed, so this entry cannot be an advance - # (JE validation: Supplier advance must be debit, Customer advance must be credit) - advance_entry.is_advance = "No" if self.is_return else "Yes" - - # Update dimensions - dimensions_dict = frappe._dict() - active_dimensions = get_dimensions()[0] - for dim in active_dimensions: - dimensions_dict[dim.fieldname] = self.get(dim.fieldname) - - reconcilation_entry.update(dimensions_dict) - advance_entry.update(dimensions_dict) - - # Calculate exchange rates if necessary - if multi_currency: - # Exchange rates for primary and secondary accounts - exc_rate_primary_to_default = ( - 1 - if primary_account_currency == default_currency - else get_exchange_rate(primary_account_currency, default_currency, self.posting_date) - ) - exc_rate_secondary_to_default = ( - 1 - if secondary_account_currency == default_currency - else get_exchange_rate(secondary_account_currency, default_currency, self.posting_date) - ) - exc_rate_secondary_to_primary = ( - 1 - if secondary_account_currency == primary_account_currency - else get_exchange_rate( - secondary_account_currency, primary_account_currency, self.posting_date - ) - ) - - outstanding_amount = abs(self.outstanding_amount) - os_in_default_currency = outstanding_amount * exc_rate_secondary_to_default - os_in_primary_currency = outstanding_amount * exc_rate_secondary_to_primary - - # SI normal and PI return → reconciliation is credit; SI return and PI normal → debit - reconciliation_is_credit = (self.doctype == "Sales Invoice") != bool(self.is_return) - _set_je_amounts( - reconcilation_entry, outstanding_amount, os_in_default_currency, reconciliation_is_credit - ) - _set_je_amounts( - advance_entry, os_in_primary_currency, os_in_default_currency, not reconciliation_is_credit - ) - - reconcilation_entry.exchange_rate = exc_rate_secondary_to_default - advance_entry.exchange_rate = exc_rate_primary_to_default - else: - outstanding_amount = abs(self.outstanding_amount) - reconciliation_is_credit = (self.doctype == "Sales Invoice") != bool(self.is_return) - _set_je_amounts(reconcilation_entry, outstanding_amount, is_credit=reconciliation_is_credit) - _set_je_amounts(advance_entry, outstanding_amount, is_credit=not reconciliation_is_credit) - - jv.multi_currency = multi_currency - jv.append("accounts", reconcilation_entry) - jv.append("accounts", advance_entry) - - jv.save() - jv.submit() + create_advance_and_reconcile(self, party_link) def check_conversion_rate(self): default_currency = erpnext.get_company_currency(self.company) @@ -3297,212 +3049,12 @@ def set_balance_in_account_currency( ) -def get_advance_journal_entries( - party_type, - party, - party_account, - amount_field, - order_doctype, - order_list, - include_unallocated=True, -): - journal_entry = frappe.qb.DocType("Journal Entry") - journal_acc = frappe.qb.DocType("Journal Entry Account") - q = ( - frappe.qb.from_(journal_entry) - .inner_join(journal_acc) - .on(journal_entry.name == journal_acc.parent) - .select( - ConstantColumn("Journal Entry").as_("reference_type"), - (journal_entry.name).as_("reference_name"), - (journal_entry.remark).as_("remarks"), - (journal_acc[amount_field]).as_("amount"), - (journal_acc.name).as_("reference_row"), - (journal_acc.reference_name).as_("against_order"), - (journal_acc.exchange_rate), - ) - .where( - journal_acc.account.isin(party_account) - & (journal_acc.party_type == party_type) - & (journal_acc.party == party) - & (journal_acc.is_advance == "Yes") - & (journal_entry.docstatus == 1) - ) - ) - if party_type == "Customer": - q = q.where(journal_acc.credit_in_account_currency > 0) - - else: - q = q.where(journal_acc.debit_in_account_currency > 0) - - reference_or_condition = [] - - if include_unallocated: - reference_or_condition.append(journal_acc.reference_name.isnull()) - reference_or_condition.append(journal_acc.reference_name == "") - - if order_list: - reference_or_condition.append( - (journal_acc.reference_type == order_doctype) & ((journal_acc.reference_name).isin(order_list)) - ) - - if reference_or_condition: - q = q.where(Criterion.any(reference_or_condition)) - - q = q.orderby(journal_entry.posting_date) - - journal_entries = q.run(as_dict=True) - return list(journal_entries) - - -@erpnext.allow_regional -def get_advance_payment_entries_for_regional(*args, **kwargs): - return get_advance_payment_entries(*args, **kwargs) - - -def get_advance_payment_entries( - party_type, - party, - party_account, - order_doctype, - order_list=None, - default_advance_account=None, - include_unallocated=True, - against_all_orders=False, - limit=None, - condition=None, -): - payment_entries = [] - payment_entry = frappe.qb.DocType("Payment Entry") - - if order_list or against_all_orders: - q = get_common_query( - party_type, - party, - party_account, - default_advance_account, - limit, - condition, - ) - payment_ref = frappe.qb.DocType("Payment Entry Reference") - - q = q.inner_join(payment_ref).on(payment_entry.name == payment_ref.parent) - q = q.select( - (payment_ref.allocated_amount).as_("amount"), - (payment_ref.name).as_("reference_row"), - (payment_ref.reference_name).as_("against_order"), - (payment_entry.book_advance_payments_in_separate_party_account), - ) - - q = q.where(payment_ref.reference_doctype == order_doctype) - if order_list: - q = q.where(payment_ref.reference_name.isin(order_list)) - - allocated = list(q.run(as_dict=True)) - payment_entries += allocated - if include_unallocated: - q = get_common_query( - party_type, - party, - party_account, - default_advance_account, - limit, - condition, - ) - q = q.select((payment_entry.unallocated_amount).as_("amount")) - q = q.where(payment_entry.unallocated_amount > 0) - - unallocated = list(q.run(as_dict=True)) - payment_entries += unallocated - return payment_entries - - -def get_common_query( - party_type, - party, - party_account, - default_advance_account, - limit, - condition, -): - account_type = frappe.db.get_value("Party Type", party_type, "account_type") - payment_type = "Receive" if account_type == "Receivable" else "Pay" - payment_entry = frappe.qb.DocType("Payment Entry") - - q = ( - frappe.qb.from_(payment_entry) - .select( - ConstantColumn("Payment Entry").as_("reference_type"), - (payment_entry.name).as_("reference_name"), - payment_entry.posting_date, - (payment_entry.remarks).as_("remarks"), - (payment_entry.book_advance_payments_in_separate_party_account), - ) - .where(payment_entry.payment_type == payment_type) - .where(payment_entry.party_type == party_type) - .where(payment_entry.party == party) - .where(payment_entry.docstatus == 1) - ) - - field = "paid_from" if payment_type == "Receive" else "paid_to" - - q = q.select((payment_entry[f"{field}_account_currency"]).as_("currency")) - q = q.select(payment_entry[field]) - account_condition = payment_entry[field].isin(party_account) - if default_advance_account: - q = q.where( - account_condition - | ( - (payment_entry[field] == default_advance_account) - & (payment_entry.book_advance_payments_in_separate_party_account == 1) - ) - ) - - else: - q = q.where(account_condition) - - if payment_type == "Receive": - q = q.select((payment_entry.source_exchange_rate).as_("exchange_rate")) - else: - q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate")) - - if condition: - # conditions should be built as an array and passed as Criterion - common_filter_conditions = [] - - common_filter_conditions.append(payment_entry.company == condition["company"]) - if condition.get("name", None): - common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%")) - - if condition.get("from_payment_date"): - common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"])) - - if condition.get("to_payment_date"): - common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"])) - - if condition.get("get_payments") is True: - if condition.get("cost_center"): - common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"]) - - if condition.get("accounting_dimensions"): - for field, val in condition.get("accounting_dimensions").items(): - common_filter_conditions.append(payment_entry[field] == val) - - if condition.get("minimum_payment_amount"): - common_filter_conditions.append( - payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"]) - ) - - if condition.get("maximum_payment_amount"): - common_filter_conditions.append( - payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"]) - ) - q = q.where(Criterion.all(common_filter_conditions)) - - q = q.orderby(payment_entry.posting_date) - q = q.limit(limit) if limit else q - - return q +from erpnext.accounts.services.advances import ( + get_advance_journal_entries, + get_advance_payment_entries, + get_advance_payment_entries_for_regional, + get_common_query, +) def update_invoice_status(): @@ -3693,17 +3245,6 @@ def set_child_tax_template_and_map(item, child_item, parent_doc): ) -def _set_je_amounts(entry, amount, default_amount=None, is_credit=True): - if is_credit: - entry.credit_in_account_currency = amount - if default_amount is not None: - entry.credit = default_amount - else: - entry.debit_in_account_currency = amount - if default_amount is not None: - entry.debit = default_amount - - def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True): add_taxes_from_item_tax_template = frappe.get_single_value( "Accounts Settings", "add_taxes_from_item_tax_template" From 29261c5fc2a417fd8f9226236eb8e4330f7e2879 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 16:28:02 +0530 Subject: [PATCH 25/65] refactor(accounts): extract tax helpers into accounts/services/taxes.py Move validate_conversion_rate, validate_taxes_and_charges, validate_account_head, validate_cost_center, validate_inclusive_tax, set_balance_in_account_currency, set_child_tax_template_and_map, add_taxes_from_tax_template, merge_taxes, get_tax_rate, get_default_taxes_and_charges, and get_taxes_and_charges out of accounts_controller into a dedicated accounts/services/taxes.py module. Re-export all symbols from accounts_controller for backward compatibility. --- erpnext/accounts/services/taxes.py | 287 ++++++++++++++++++++ erpnext/controllers/accounts_controller.py | 289 +-------------------- 2 files changed, 301 insertions(+), 275 deletions(-) create mode 100644 erpnext/accounts/services/taxes.py diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py new file mode 100644 index 00000000000..0dfb97d78d3 --- /dev/null +++ b/erpnext/accounts/services/taxes.py @@ -0,0 +1,287 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Tax template and validation helpers shared across buying and selling controllers.""" + +import json + +import frappe +from frappe import _, throw +from frappe.utils import cint, flt + +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + ItemDetailsCtx, + _get_item_tax_template, + _get_item_tax_template_from_item_group, + get_item_tax_map, +) + + +def get_tax_rate(account_head: str) -> dict: + return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) + + +@frappe.whitelist() +def get_default_taxes_and_charges( + master_doctype: str, tax_template: str | None = None, company: str | None = None +) -> dict | None: + if not company: + return {} + + if tax_template and company: + tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company") + if tax_template_company == company: + return + + default_tax = frappe.db.get_value(master_doctype, {"is_default": 1, "company": company}) + + return { + "taxes_and_charges": default_tax, + "taxes": get_taxes_and_charges(master_doctype, default_tax), + } + + +@frappe.whitelist() +def get_taxes_and_charges(master_doctype: str, master_name: str | None = None) -> list | None: + if not master_name: + return + from frappe.model import child_table_fields, default_fields + + tax_master = frappe.get_doc(master_doctype, master_name) + + taxes_and_charges = [] + for _i, tax in enumerate(tax_master.get("taxes")): + tax = tax.as_dict() + + for fieldname in default_fields + child_table_fields: + if fieldname in tax: + del tax[fieldname] + + taxes_and_charges.append(tax) + + return taxes_and_charges + + +def validate_conversion_rate( + currency: str, conversion_rate: float, conversion_rate_label: str, company: str +) -> None: + """Throw a validation error if conversion_rate is falsy.""" + company_currency = frappe.get_cached_value("Company", company, "default_currency") + + if not conversion_rate: + throw( + _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format( + conversion_rate_label, currency, company_currency + ) + ) + + +def validate_taxes_and_charges(tax) -> None: + if tax.charge_type in ["Actual", "On Net Total", "On Paid Amount"] and tax.row_id: + frappe.throw( + _("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'") + ) + elif tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]: + if cint(tax.idx) == 1: + frappe.throw( + _( + "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" + ) + ) + elif not tax.row_id: + frappe.throw( + _("Please specify a valid Row ID for row {0} in table {1}").format(tax.idx, _(tax.doctype)) + ) + elif tax.row_id and cint(tax.row_id) >= cint(tax.idx): + frappe.throw( + _("Cannot refer row number greater than or equal to current row number for this Charge type") + ) + + if tax.charge_type == "Actual": + tax.rate = None + + +def validate_account_head(idx: int, account: str, company: str, context: str | None = None) -> None: + """Throw a ValidationError if the account belongs to a different company or is a group account.""" + if company != frappe.get_cached_value("Account", account, "company"): + frappe.throw( + _("Row {0}: The {3} Account {1} does not belong to the company {2}").format( + idx, frappe.bold(account), frappe.bold(company), context or "" + ), + title=_("Invalid Account"), + ) + + if frappe.get_cached_value("Account", account, "is_group"): + frappe.throw( + _( + "You selected the account group {1} as {2} Account in row {0}. Please select a single account." + ).format(idx, frappe.bold(account), context or ""), + title=_("Invalid Account"), + ) + + +def validate_cost_center(tax, doc) -> None: + if not tax.cost_center: + return + + company = frappe.get_cached_value("Cost Center", tax.cost_center, "company") + + if company != doc.company: + frappe.throw( + _("Row {0}: Cost Center {1} does not belong to Company {2}").format( + tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company) + ), + title=_("Invalid Cost Center"), + ) + + +def validate_inclusive_tax(tax, doc) -> None: + def _on_previous_row_error(row_range): + throw( + _("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format( + tax.idx, row_range + ) + ) + + if cint(getattr(tax, "included_in_print_rate", None)): + if tax.charge_type == "Actual": + throw( + _("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format( + tax.idx + ) + ) + elif tax.charge_type == "On Previous Row Amount" and not cint( + doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate + ): + _on_previous_row_error(tax.row_id) + elif tax.charge_type == "On Previous Row Total" and not all( + [cint(t.included_in_print_rate) for t in doc.get("taxes")[: cint(tax.row_id) - 1]] + ): + _on_previous_row_error("1 - %d" % (tax.row_id,)) + elif tax.get("category") == "Valuation": + frappe.throw(_("Valuation type charges can not be marked as Inclusive")) + + +def set_balance_in_account_currency( + gl_dict, + account_currency: str | None = None, + conversion_rate: float | None = None, + company_currency: str | None = None, +) -> None: + if (not conversion_rate) and (account_currency != company_currency): + frappe.throw( + _("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency) + ) + + gl_dict["account_currency"] = account_currency + + if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency): + gl_dict.debit_in_account_currency = ( + gl_dict.debit if account_currency == company_currency else flt(gl_dict.debit / conversion_rate, 2) + ) + + if flt(gl_dict.credit) and not flt(gl_dict.credit_in_account_currency): + gl_dict.credit_in_account_currency = ( + gl_dict.credit + if account_currency == company_currency + else flt(gl_dict.credit / conversion_rate, 2) + ) + + +def set_child_tax_template_and_map(item, child_item, parent_doc) -> None: + ctx = ItemDetailsCtx( + { + "item_code": item.item_code, + "posting_date": parent_doc.transaction_date, + "tax_category": parent_doc.get("tax_category"), + "company": parent_doc.get("company"), + "base_net_rate": item.get("base_net_rate"), + } + ) + + item_tax_template = _get_item_tax_template(ctx, item.taxes) + + if not item_tax_template: + item_tax_template = _get_item_tax_template_from_item_group(ctx, item.item_group) + + child_item.item_tax_template = item_tax_template + child_item.item_tax_rate = get_item_tax_map( + doc=parent_doc, + tax_template=child_item.item_tax_template, + as_json=True, + ) + + +def add_taxes_from_tax_template(child_item, parent_doc, db_insert: bool = True) -> None: + add_taxes_from_item_tax_template = frappe.get_single_value( + "Accounts Settings", "add_taxes_from_item_tax_template" + ) + + if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: + tax_map = json.loads(child_item.get("item_tax_rate")) + for tax_type, tax_rate in tax_map.items(): + if tax_rate == NOT_APPLICABLE_TAX: + continue + + tax_rate = flt(tax_rate) + taxes = parent_doc.get("taxes") or [] + found = any(tax.account_head == tax_type for tax in taxes) + if not found: + tax_row = parent_doc.append("taxes", {}) + tax_row.update( + { + "description": str(tax_type).split(" - ")[0], + "charge_type": "On Net Total", + "account_head": tax_type, + "rate": tax_rate, + "set_by_item_tax_template": 1, + } + ) + if parent_doc.doctype == "Purchase Order": + tax_row.update({"category": "Total", "add_deduct_tax": "Add"}) + if db_insert: + tax_row.db_insert() + + +def merge_taxes(source_doc, target_doc) -> None: + tax_map = {} + for tax in source_doc.get("taxes") or []: + found = False + for t in target_doc.get("taxes") or []: + if t.account_head == tax.account_head and t.cost_center == tax.cost_center: + t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount) + t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount) + tax_map[tax.name] = t + found = True + + if not found: + tax.charge_type = "Actual" + tax.included_in_print_rate = 0 + tax.dont_recompute_tax = 1 + tax.row_id = None + tax.idx = None + tax.tax_amount = tax.tax_amount_after_discount_amount + tax.base_tax_amount = tax.base_tax_amount_after_discount_amount + tax_map[tax.name] = target_doc.append("taxes", tax) + + item_map = {d._old_name: d for d in target_doc.get("items") if d.get("_old_name")} + + item_tax_details = target_doc.get("_item_wise_tax_details") or [] + for row in source_doc.get("item_wise_tax_details"): + item = item_map.get(row.item_row) + tax = tax_map.get(row.tax_row) + if not (item and tax): + continue + + item_tax_details.append( + frappe._dict( + item=item, + tax=tax, + amount=row.amount, + rate=row.rate, + taxable_amount=row.taxable_amount, + ) + ) + + target_doc._item_wise_tax_details = item_tax_details diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 548fc6e515c..3defc078de9 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -68,14 +68,10 @@ from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.get_item_details import ( - NOT_APPLICABLE_TAX, ItemDetailsCtx, - _get_item_tax_template, - _get_item_tax_template_from_item_group, get_bin_details, get_conversion_factor, get_item_details, - get_item_tax_map, get_item_warehouse_, ) from erpnext.utilities.regional import temporary_flag @@ -2877,184 +2873,26 @@ class AccountsController(TransactionBase): self.calculate_taxes_and_totals() -@frappe.whitelist() -def get_tax_rate(account_head: str): - return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) - - -@frappe.whitelist() -def get_default_taxes_and_charges( - master_doctype: str, tax_template: str | None = None, company: str | None = None -): - if not company: - return {} - - if tax_template and company: - tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company") - if tax_template_company == company: - return - - default_tax = frappe.db.get_value(master_doctype, {"is_default": 1, "company": company}) - - return { - "taxes_and_charges": default_tax, - "taxes": get_taxes_and_charges(master_doctype, default_tax), - } - - -@frappe.whitelist() -def get_taxes_and_charges(master_doctype: str, master_name: str | None = None): - if not master_name: - return - from frappe.model import child_table_fields, default_fields - - tax_master = frappe.get_doc(master_doctype, master_name) - - taxes_and_charges = [] - for _i, tax in enumerate(tax_master.get("taxes")): - tax = tax.as_dict() - - for fieldname in default_fields + child_table_fields: - if fieldname in tax: - del tax[fieldname] - - taxes_and_charges.append(tax) - - return taxes_and_charges - - -def validate_conversion_rate(currency, conversion_rate, conversion_rate_label, company): - """common validation for currency and price list currency""" - - company_currency = frappe.get_cached_value("Company", company, "default_currency") - - if not conversion_rate: - throw( - _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format( - conversion_rate_label, currency, company_currency - ) - ) - - -def validate_taxes_and_charges(tax): - if tax.charge_type in ["Actual", "On Net Total", "On Paid Amount"] and tax.row_id: - frappe.throw( - _("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'") - ) - elif tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]: - if cint(tax.idx) == 1: - frappe.throw( - _( - "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" - ) - ) - elif not tax.row_id: - frappe.throw( - _("Please specify a valid Row ID for row {0} in table {1}").format(tax.idx, _(tax.doctype)) - ) - elif tax.row_id and cint(tax.row_id) >= cint(tax.idx): - frappe.throw( - _("Cannot refer row number greater than or equal to current row number for this Charge type") - ) - - if tax.charge_type == "Actual": - tax.rate = None - - -def validate_account_head(idx: int, account: str, company: str, context: str | None = None) -> None: - """Throw a ValidationError if the account belongs to a different company or is a group account.""" - if company != frappe.get_cached_value("Account", account, "company"): - frappe.throw( - _("Row {0}: The {3} Account {1} does not belong to the company {2}").format( - idx, frappe.bold(account), frappe.bold(company), context or "" - ), - title=_("Invalid Account"), - ) - - if frappe.get_cached_value("Account", account, "is_group"): - frappe.throw( - _( - "You selected the account group {1} as {2} Account in row {0}. Please select a single account." - ).format(idx, frappe.bold(account), context or ""), - title=_("Invalid Account"), - ) - - -def validate_cost_center(tax, doc): - if not tax.cost_center: - return - - company = frappe.get_cached_value("Cost Center", tax.cost_center, "company") - - if company != doc.company: - frappe.throw( - _("Row {0}: Cost Center {1} does not belong to Company {2}").format( - tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company) - ), - title=_("Invalid Cost Center"), - ) - - -def validate_inclusive_tax(tax, doc): - def _on_previous_row_error(row_range): - throw( - _("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format( - tax.idx, row_range - ) - ) - - if cint(getattr(tax, "included_in_print_rate", None)): - if tax.charge_type == "Actual": - # inclusive tax cannot be of type Actual - throw( - _("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format( - tax.idx - ) - ) - elif tax.charge_type == "On Previous Row Amount" and not cint( - doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate - ): - # referred row should also be inclusive - _on_previous_row_error(tax.row_id) - elif tax.charge_type == "On Previous Row Total" and not all( - [cint(t.included_in_print_rate) for t in doc.get("taxes")[: cint(tax.row_id) - 1]] - ): - # all rows about the referred tax should be inclusive - _on_previous_row_error("1 - %d" % (tax.row_id,)) - elif tax.get("category") == "Valuation": - frappe.throw(_("Valuation type charges can not be marked as Inclusive")) - - -def set_balance_in_account_currency( - gl_dict, account_currency=None, conversion_rate=None, company_currency=None -): - if (not conversion_rate) and (account_currency != company_currency): - frappe.throw( - _("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency) - ) - - gl_dict["account_currency"] = account_currency - - # set debit/credit in account currency if not provided - if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency): - gl_dict.debit_in_account_currency = ( - gl_dict.debit if account_currency == company_currency else flt(gl_dict.debit / conversion_rate, 2) - ) - - if flt(gl_dict.credit) and not flt(gl_dict.credit_in_account_currency): - gl_dict.credit_in_account_currency = ( - gl_dict.credit - if account_currency == company_currency - else flt(gl_dict.credit / conversion_rate, 2) - ) - - from erpnext.accounts.services.advances import ( get_advance_journal_entries, get_advance_payment_entries, get_advance_payment_entries_for_regional, get_common_query, ) +from erpnext.accounts.services.taxes import ( + add_taxes_from_tax_template, + get_default_taxes_and_charges, + get_tax_rate, + get_taxes_and_charges, + merge_taxes, + set_balance_in_account_currency, + set_child_tax_template_and_map, + validate_account_head, + validate_conversion_rate, + validate_cost_center, + validate_inclusive_tax, + validate_taxes_and_charges, +) def update_invoice_status(): @@ -3221,62 +3059,6 @@ def get_supplier_block_status(party_name): return info -def set_child_tax_template_and_map(item, child_item, parent_doc): - ctx = ItemDetailsCtx( - { - "item_code": item.item_code, - "posting_date": parent_doc.transaction_date, - "tax_category": parent_doc.get("tax_category"), - "company": parent_doc.get("company"), - "base_net_rate": item.get("base_net_rate"), - } - ) - - item_tax_template = _get_item_tax_template(ctx, item.taxes) - - if not item_tax_template: - item_tax_template = _get_item_tax_template_from_item_group(ctx, item.item_group) - - child_item.item_tax_template = item_tax_template - child_item.item_tax_rate = get_item_tax_map( - doc=parent_doc, - tax_template=child_item.item_tax_template, - as_json=True, - ) - - -def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True): - add_taxes_from_item_tax_template = frappe.get_single_value( - "Accounts Settings", "add_taxes_from_item_tax_template" - ) - - if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: - tax_map = json.loads(child_item.get("item_tax_rate")) - for tax_type, tax_rate in tax_map.items(): - if tax_rate == NOT_APPLICABLE_TAX: - continue - - tax_rate = flt(tax_rate) - taxes = parent_doc.get("taxes") or [] - # add new row for tax head only if missing - found = any(tax.account_head == tax_type for tax in taxes) - if not found: - tax_row = parent_doc.append("taxes", {}) - tax_row.update( - { - "description": str(tax_type).split(" - ")[0], - "charge_type": "On Net Total", - "account_head": tax_type, - "rate": tax_rate, - "set_by_item_tax_template": 1, - } - ) - if parent_doc.doctype == "Purchase Order": - tax_row.update({"category": "Total", "add_deduct_tax": "Add"}) - if db_insert: - tax_row.db_insert() - - def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, trans_item): """ Returns a Sales/Purchase Order Item child item containing the default values @@ -3830,49 +3612,6 @@ def check_if_child_table_updated(child_table_before_update, child_table_after_up return False -def merge_taxes(source_doc, target_doc): - tax_map = {} - for tax in source_doc.get("taxes") or []: - found = False - for t in target_doc.get("taxes") or []: - if t.account_head == tax.account_head and t.cost_center == tax.cost_center: - t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount) - t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount) - tax_map[tax.name] = t - found = True - - if not found: - tax.charge_type = "Actual" - tax.included_in_print_rate = 0 - tax.dont_recompute_tax = 1 - tax.row_id = None - tax.idx = None - tax.tax_amount = tax.tax_amount_after_discount_amount - tax.base_tax_amount = tax.base_tax_amount_after_discount_amount - tax_map[tax.name] = target_doc.append("taxes", tax) - - item_map = {d._old_name: d for d in target_doc.get("items") if d.get("_old_name")} - - item_tax_details = target_doc.get("_item_wise_tax_details") or [] - for row in source_doc.get("item_wise_tax_details"): - item = item_map.get(row.item_row) - tax = tax_map.get(row.tax_row) - if not (item and tax): - continue - - item_tax_details.append( - frappe._dict( - item=item, - tax=tax, - amount=row.amount, - rate=row.rate, - taxable_amount=row.taxable_amount, - ) - ) - - target_doc._item_wise_tax_details = item_tax_details - - @erpnext.allow_regional def validate_regional(doc): pass From cba6a3149714bd4c2bf66701f39049209515e6fe Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 21:46:16 +0530 Subject: [PATCH 26/65] refactor(accounts): extract get_gl_dict and add_gl_entry into gl_entry_builder.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the get_gl_dict/add_gl_entry logic from AccountsController/StockController into free functions in accounts/services/gl_entry_builder.py with doc as first arg. BaseGLComposer gains get_gl_dict and add_gl_entry methods that delegate to the free functions — GL composers now call self.get_gl_dict/self.add_gl_entry directly without going through the doc. AccountsController and StockController keep thin shims for backward compatibility with unrefactored callers. Also move update_gl_dict_with_regional_fields and update_gl_dict_with_app_based_fields to gl_entry_builder.py, re-exporting them from accounts_controller.py to avoid a circular import. --- .../journal_entry/services/gl_composer.py | 4 +- .../payment_entry/services/gl_composer.py | 16 +- .../purchase_invoice/services/gl_composer.py | 44 ++-- .../sales_invoice/services/gl_composer.py | 32 +-- erpnext/accounts/services/base_gl_composer.py | 40 ++++ erpnext/accounts/services/gl_entry_builder.py | 223 ++++++++++++++++++ .../services/gl_composer.py | 8 +- .../asset_repair/services/gl_composer.py | 8 +- erpnext/controllers/accounts_controller.py | 160 +------------ erpnext/controllers/stock_controller.py | 39 ++- .../purchase_receipt/services/gl_composer.py | 18 +- .../stock_entry/services/gl_composer.py | 8 +- .../stock/services/base_stock_gl_composer.py | 8 +- .../services/gl_composer.py | 22 +- 14 files changed, 378 insertions(+), 252 deletions(-) create mode 100644 erpnext/accounts/services/gl_entry_builder.py diff --git a/erpnext/accounts/doctype/journal_entry/services/gl_composer.py b/erpnext/accounts/doctype/journal_entry/services/gl_composer.py index a8def33e141..16b78eae3b9 100644 --- a/erpnext/accounts/doctype/journal_entry/services/gl_composer.py +++ b/erpnext/accounts/doctype/journal_entry/services/gl_composer.py @@ -14,7 +14,7 @@ class JournalEntryGLComposer(BaseGLComposer): A Journal Entry already carries its ledger rows in the ``accounts`` child table, so composing is a straight projection of those rows into GL dicts - via ``self.doc.get_gl_dict``. The transaction currency/rate are resolved + via ``self.get_gl_dict``. The transaction currency/rate are resolved from the first foreign-currency row (mirroring the former build_gl_map). """ @@ -95,7 +95,7 @@ class JournalEntryGLComposer(BaseGLComposer): frappe.flags.party_not_required = True gl_map.append( - doc.get_gl_dict( + self.get_gl_dict( row, item=d, ) diff --git a/erpnext/accounts/doctype/payment_entry/services/gl_composer.py b/erpnext/accounts/doctype/payment_entry/services/gl_composer.py index 8e13bab3ede..28af09ec309 100644 --- a/erpnext/accounts/doctype/payment_entry/services/gl_composer.py +++ b/erpnext/accounts/doctype/payment_entry/services/gl_composer.py @@ -49,7 +49,7 @@ class PaymentEntryGLComposer(BaseGLComposer): party_account_type = frappe.db.get_value("Party Type", doc.party_type, "account_type") - party_gl_dict = doc.get_gl_dict( + party_gl_dict = self.get_gl_dict( { "account": doc.party_account, "party_type": doc.party_type, @@ -84,7 +84,7 @@ class PaymentEntryGLComposer(BaseGLComposer): dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" gle.update( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.party_account, "party_type": doc.party_type, @@ -137,7 +137,7 @@ class PaymentEntryGLComposer(BaseGLComposer): gle = party_gl_dict.copy() gle.update( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.party_account, "party_type": doc.party_type, @@ -167,7 +167,7 @@ class PaymentEntryGLComposer(BaseGLComposer): doc = self.doc if doc.payment_type in ("Pay", "Internal Transfer"): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.paid_from, "account_currency": doc.paid_from_account_currency, @@ -185,7 +185,7 @@ class PaymentEntryGLComposer(BaseGLComposer): ) if doc.payment_type in ("Receive", "Internal Transfer"): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.paid_to, "account_currency": doc.paid_to_account_currency, @@ -222,7 +222,7 @@ class PaymentEntryGLComposer(BaseGLComposer): base_tax_amount = d.base_tax_amount gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": d.account_head, "against": against, @@ -249,7 +249,7 @@ class PaymentEntryGLComposer(BaseGLComposer): base_tax_amount = flt((tax_amount / exchange_rate), doc.precision("paid_amount")) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": payment_account, "against": against, @@ -278,7 +278,7 @@ class PaymentEntryGLComposer(BaseGLComposer): frappe.throw(_("Currency for {0} must be {1}").format(d.account, doc.company_currency)) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": d.account, "account_currency": account_currency, diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index 28e26920942..8329cfac53d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -94,7 +94,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): } if remarks: gl["remarks"] = remarks - gl_entries.append(doc.get_gl_dict(gl, doc.party_account_currency, item=doc)) + gl_entries.append(self.get_gl_dict(gl, doc.party_account_currency, item=doc)) def make_item_gl_entries(self, gl_entries): from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( @@ -163,7 +163,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": _inv_dict["account"], "against": _inv_dict_from_warehouse["account"], @@ -184,7 +184,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): # Intentionally passed negative debit amount to avoid incorrect GL Entry validation gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": _inv_dict_from_warehouse["account"], "against": _inv_dict["account"], @@ -201,7 +201,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): if not doc.is_internal_transfer(): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": item.expense_account, "against": doc.supplier, @@ -219,7 +219,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): else: if not doc.is_internal_transfer(): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": item.expense_account, "against": doc.supplier, @@ -244,7 +244,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): (item.item_code, item.name) ].items(): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": account, "against": item.expense_account, @@ -270,7 +270,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): _("Please set account in Warehouse {0}").format(doc.supplier_warehouse) ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": supplier_inventory_account, "against": item.expense_account, @@ -299,7 +299,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): if not doc.is_internal_transfer(): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": expense_account, "against": doc.supplier, @@ -330,7 +330,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): ) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": expense_account, "against": doc.supplier, @@ -343,7 +343,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): ) ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.get_company_default("exchange_gain_loss_account"), "against": doc.supplier, @@ -378,7 +378,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): if not negative_expense_booked_in_pr: gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.stock_received_but_not_billed, "against": doc.supplier, @@ -505,7 +505,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): stock_adjustment_amt = stock_amount - warehouse_debit_amount gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": cost_of_goods_sold_account, "against": item.expense_account, @@ -531,7 +531,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): stock_adjustment_amt = warehouse_debit_amount - stock_amount gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": cost_of_goods_sold_account, "against": item.expense_account, @@ -560,7 +560,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): account_currency = get_account_currency(tax.account_head) dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit" gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": tax.account_head, "against": doc.supplier, @@ -606,7 +606,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): amount_including_divisional_loss -= applicable_amount gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": tax.account_head, "cost_center": tax.cost_center, @@ -627,7 +627,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): for tax in doc.get("taxes"): if valuation_tax.get(tax.name): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": tax.account_head, "cost_center": tax.cost_center, @@ -648,7 +648,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges): account_currency = get_account_currency(doc.unrealized_profit_loss_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.unrealized_profit_loss_account, "against": doc.supplier, @@ -691,7 +691,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): bank_account_currency = get_account_currency(doc.cash_bank_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.credit_to, "party_type": "Supplier", @@ -715,7 +715,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.cash_bank_account, "against": doc.supplier, @@ -737,7 +737,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): write_off_account_currency = get_account_currency(doc.write_off_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.credit_to, "party_type": "Supplier", @@ -760,7 +760,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): ) ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.write_off_account, "against": doc.supplier, @@ -802,7 +802,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): round_off_account = round_off_for_opening gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": round_off_account, "against": doc.supplier, diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index 21c00d28da2..24da512a732 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -110,7 +110,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): item_account_currency = get_account_currency(item.expense_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": dn_expense_account, "against": item.expense_account, @@ -123,7 +123,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): ) ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": item.expense_account, "against": dn_expense_account, @@ -157,7 +157,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): # Did not use base_grand_total to book rounding loss gle gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.debit_to, "party_type": "Customer", @@ -191,7 +191,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): if flt(tax.base_tax_amount_after_discount_amount): account_currency = get_account_currency(tax.account_head) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": tax.account_head, "against": doc.customer, @@ -216,7 +216,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges): account_currency = get_account_currency(doc.unrealized_profit_loss_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.unrealized_profit_loss_account, "against": doc.customer, @@ -262,7 +262,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): account_currency = get_account_currency(income_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": income_account, "against": doc.customer, @@ -310,13 +310,13 @@ class SalesInvoiceGLComposer(BaseGLComposer): for gle in fixed_asset_gl_entries: gle["against"] = doc.customer - gl_entries.append(doc.get_gl_dict(gle, item=item)) + gl_entries.append(self.get_gl_dict(gle, item=item)) def make_loyalty_point_redemption_gle(self, gl_entries): doc = self.doc if cint(doc.redeem_loyalty_points and doc.loyalty_points and not doc.is_consolidated): gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.debit_to, "party_type": "Customer", @@ -334,7 +334,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): ) ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.loyalty_redemption_account, "cost_center": doc.cost_center or doc.loyalty_redemption_cost_center, @@ -365,7 +365,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): if payment_mode.base_amount: # POS, make payment entries gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.debit_to, "party_type": "Customer", @@ -387,7 +387,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): payment_mode_account_currency = get_account_currency(payment_mode.account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": payment_mode.account, "against": doc.customer, @@ -415,7 +415,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field")) return [ - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.debit_to, "party_type": "Customer", @@ -436,7 +436,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): doc.party_account_currency, item=doc, ), - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.account_for_change_amount, "against": doc.customer, @@ -460,7 +460,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): default_cost_center = frappe.get_cached_value("Company", doc.company, "cost_center") gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.debit_to, "party_type": "Customer", @@ -485,7 +485,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): ) ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": doc.write_off_account, "against": doc.customer, @@ -536,7 +536,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): round_off_account = round_off_for_opening gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": round_off_account, "against": doc.customer, diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py index bbe2474297e..2a39c4a5243 100644 --- a/erpnext/accounts/services/base_gl_composer.py +++ b/erpnext/accounts/services/base_gl_composer.py @@ -10,6 +10,8 @@ modelled as a class holding the document being composed. Subclasses implement ``compose`` to return the voucher-specific list of GL entries. """ +from erpnext.accounts.services.gl_entry_builder import add_gl_entry, get_gl_dict + class BaseGLComposer: def __init__(self, doc): @@ -17,3 +19,41 @@ class BaseGLComposer: def compose(self): raise NotImplementedError + + def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict: + return get_gl_dict(self.doc, args, account_currency, item) + + def add_gl_entry( + self, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, + ) -> None: + add_gl_entry( + self.doc, + gl_entries, + account, + cost_center, + debit, + credit, + remarks, + against_account, + debit_in_account_currency, + credit_in_account_currency, + account_currency, + project, + voucher_detail_no, + item, + posting_date, + ) diff --git a/erpnext/accounts/services/gl_entry_builder.py b/erpnext/accounts/services/gl_entry_builder.py new file mode 100644 index 00000000000..df304c020a6 --- /dev/null +++ b/erpnext/accounts/services/gl_entry_builder.py @@ -0,0 +1,223 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Free functions for building GL entry dicts. + +These are the implementations behind ``AccountsController.get_gl_dict`` and +``StockController.add_gl_entry``. Extracting them as free functions (with +``doc`` as the first argument) allows ``BaseGLComposer`` to delegate to them +directly — without requiring every composing doctype to inherit from +``AccountsController``. + +``AccountsController`` and ``StockController`` keep thin shims that call these +functions so that existing code continues to work unchanged. +""" + +import frappe +from frappe import _ +from frappe.utils import flt, formatdate + +import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.accounts.services.taxes import set_balance_in_account_currency +from erpnext.accounts.utils import get_account_currency, get_fiscal_years +from erpnext.utilities.regional import temporary_flag + + +def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict: + """Build a GL entry dict populated with doc-level fields.""" + posting_date = args.get("posting_date") or doc.get("posting_date") + fiscal_years = get_fiscal_years(posting_date, company=doc.company) + if len(fiscal_years) > 1: + frappe.throw( + _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( + formatdate(posting_date) + ) + ) + else: + fiscal_year = fiscal_years[0][0] + + gl_dict = frappe._dict( + { + "company": doc.company, + "posting_date": posting_date, + "fiscal_year": fiscal_year, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "remarks": doc.get("remarks") or doc.get("remark"), + "debit": 0, + "credit": 0, + "debit_in_account_currency": 0, + "credit_in_account_currency": 0, + "is_opening": doc.get("is_opening") or "No", + "party_type": None, + "party": None, + "project": doc.get("project"), + "post_net_value": args.get("post_net_value"), + "voucher_detail_no": args.get("voucher_detail_no"), + "voucher_subtype": get_voucher_subtype(doc), + } + ) + + with temporary_flag("company", doc.company): + update_gl_dict_with_regional_fields(doc, gl_dict) + + update_gl_dict_with_app_based_fields(doc, gl_dict) + + accounting_dimensions = get_accounting_dimensions() + dimension_dict = frappe._dict() + for dimension in accounting_dimensions: + dimension_dict[dimension] = doc.get(dimension) + if item and item.get(dimension): + dimension_dict[dimension] = item.get(dimension) + + gl_dict.update(dimension_dict) + gl_dict.update(args) + + if not account_currency: + account_currency = get_account_currency(gl_dict.account) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + "Purchase Receipt", + "Purchase Invoice", + "Stock Entry", + ]: + validate_account_currency(doc, gl_dict.account, account_currency) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + ]: + set_balance_in_account_currency( + gl_dict, + account_currency, + args.get("transaction_exchange_rate") or doc.get("conversion_rate"), + doc.company_currency, + ) + + if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: + gl_dict.update( + { + "transaction_currency": doc.get("currency") or doc.company_currency, + "transaction_exchange_rate": args.get("transaction_exchange_rate") + or doc.get("conversion_rate", 1), + "debit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "debit" + ), + "credit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "credit" + ), + } + ) + + if not args.get("against_voucher_type") and doc.get("against_voucher_type"): + gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")}) + + if not args.get("against_voucher") and doc.get("against_voucher"): + gl_dict.update({"against_voucher": doc.get("against_voucher")}) + + return gl_dict + + +def add_gl_entry( + doc, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, +) -> None: + """Build a GL entry via get_gl_dict and append it to gl_entries.""" + gl_entry = { + "account": account, + "cost_center": cost_center, + "debit": debit, + "credit": credit, + "against": against_account, + "remarks": remarks, + } + + if voucher_detail_no: + gl_entry["voucher_detail_no"] = voucher_detail_no + + if debit_in_account_currency: + gl_entry["debit_in_account_currency"] = debit_in_account_currency + + if credit_in_account_currency: + gl_entry["credit_in_account_currency"] = credit_in_account_currency + + if posting_date: + gl_entry["posting_date"] = posting_date + + gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) + + +def get_voucher_subtype(doc) -> str: + voucher_subtypes = { + "Journal Entry": "voucher_type", + "Payment Entry": "payment_type", + "Stock Entry": "stock_entry_type", + "Asset Capitalization": "entry_type", + } + + for method_name in frappe.get_hooks("voucher_subtypes"): + voucher_subtype = frappe.get_attr(method_name)(doc) + if voucher_subtype: + return voucher_subtype + + if doc.doctype in voucher_subtypes: + return doc.get(voucher_subtypes[doc.doctype]) + elif doc.doctype == "Purchase Receipt" and doc.is_return: + return "Purchase Return" + elif doc.doctype == "Delivery Note" and doc.is_return: + return "Sales Return" + elif doc.doctype == "Sales Invoice" and doc.is_return: + return "Credit Note" + elif doc.doctype == "Sales Invoice" and doc.is_debit_note: + return "Debit Note" + elif doc.doctype == "Purchase Invoice" and doc.is_return: + return "Debit Note" + + return doc.doctype + + +def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float: + if account_currency == doc.get("currency"): + return gl_dict.get(field + "_in_account_currency") + return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1)) + + +def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None: + valid_currency = [doc.company_currency] + if doc.get("currency") and doc.currency != doc.company_currency: + valid_currency.append(doc.currency) + + if account_currency not in valid_currency: + frappe.throw( + _("Account {0} is invalid. Account Currency must be {1}").format( + account, (" " + _("or") + " ").join(valid_currency) + ) + ) + + +@erpnext.allow_regional +def update_gl_dict_with_regional_fields(doc, gl_dict): + pass + + +def update_gl_dict_with_app_based_fields(doc, gl_dict): + for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): + frappe.get_attr(method)(doc, gl_dict) diff --git a/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py index 4f0993c0e92..2b13bddd5ad 100644 --- a/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py +++ b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py @@ -65,7 +65,7 @@ class AssetCapitalizationGLComposer(BaseStockGLComposer): target_against.add(account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": account, "against": target_account, @@ -108,7 +108,7 @@ class AssetCapitalizationGLComposer(BaseStockGLComposer): for gle in fixed_asset_gl_entries: gle["against"] = target_account - gl_entries.append(doc.get_gl_dict(gle, item=item)) + gl_entries.append(self.get_gl_dict(gle, item=item)) target_against.add(gle["account"]) asset.db_set("disposal_date", doc.posting_date) @@ -123,7 +123,7 @@ class AssetCapitalizationGLComposer(BaseStockGLComposer): target_against.add(item_row.expense_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": item_row.expense_account, "against": target_account, @@ -147,7 +147,7 @@ class AssetCapitalizationGLComposer(BaseStockGLComposer): total_value = flt(doc.total_value - composite_component_value, self.precision) if total_value: gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": target_account, "against": ", ".join(target_against), diff --git a/erpnext/assets/doctype/asset_repair/services/gl_composer.py b/erpnext/assets/doctype/asset_repair/services/gl_composer.py index 473d7d4853a..53ec0b3e61a 100644 --- a/erpnext/assets/doctype/asset_repair/services/gl_composer.py +++ b/erpnext/assets/doctype/asset_repair/services/gl_composer.py @@ -37,7 +37,7 @@ class AssetRepairGLComposer(BaseGLComposer): for pi in doc.invoices: debit_against_account.add(pi.expense_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": pi.expense_account, "credit": pi.repair_cost, @@ -55,7 +55,7 @@ class AssetRepairGLComposer(BaseGLComposer): debit_against_account_str = ", ".join(debit_against_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": fixed_asset_account, "debit": doc.repair_cost, @@ -94,7 +94,7 @@ class AssetRepairGLComposer(BaseGLComposer): for item in stock_entry_items: if flt(item.amount) > 0: gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": item.expense_account or default_expense_account, "credit": item.amount, @@ -111,7 +111,7 @@ class AssetRepairGLComposer(BaseGLComposer): ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": fixed_asset_account, "debit": item.amount, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 3defc078de9..abd708bad1a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -19,7 +19,6 @@ from frappe.utils import ( comma_and, flt, fmt_money, - formatdate, get_last_day, get_link_to_form, getdate, @@ -51,7 +50,6 @@ from erpnext.accounts.utils import ( create_gain_loss_journal, get_account_currency, get_currency_precision, - get_fiscal_years, validate_fiscal_year, ) from erpnext.accounts.utils import ( @@ -1293,140 +1291,19 @@ class AccountsController(TransactionBase): ) def get_gl_dict(self, args, account_currency=None, item=None): - """this method populates the common properties of a gl entry record""" + from erpnext.accounts.services.gl_entry_builder import get_gl_dict - posting_date = args.get("posting_date") or self.get("posting_date") - fiscal_years = get_fiscal_years(posting_date, company=self.company) - if len(fiscal_years) > 1: - frappe.throw( - _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( - formatdate(posting_date) - ) - ) - else: - fiscal_year = fiscal_years[0][0] - - gl_dict = frappe._dict( - { - "company": self.company, - "posting_date": posting_date, - "fiscal_year": fiscal_year, - "voucher_type": self.doctype, - "voucher_no": self.name, - "remarks": self.get("remarks") or self.get("remark"), - "debit": 0, - "credit": 0, - "debit_in_account_currency": 0, - "credit_in_account_currency": 0, - "is_opening": self.get("is_opening") or "No", - "party_type": None, - "party": None, - "project": self.get("project"), - "post_net_value": args.get("post_net_value"), - "voucher_detail_no": args.get("voucher_detail_no"), - "voucher_subtype": self.get_voucher_subtype(), - } - ) - - with temporary_flag("company", self.company): - update_gl_dict_with_regional_fields(self, gl_dict) - - update_gl_dict_with_app_based_fields(self, gl_dict) - - accounting_dimensions = get_accounting_dimensions() - dimension_dict = frappe._dict() - - for dimension in accounting_dimensions: - dimension_dict[dimension] = self.get(dimension) - if item and item.get(dimension): - dimension_dict[dimension] = item.get(dimension) - - gl_dict.update(dimension_dict) - gl_dict.update(args) - - if not account_currency: - account_currency = get_account_currency(gl_dict.account) - - if gl_dict.account and self.doctype not in [ - "Journal Entry", - "Period Closing Voucher", - "Payment Entry", - "Purchase Receipt", - "Purchase Invoice", - "Stock Entry", - ]: - self.validate_account_currency(gl_dict.account, account_currency) - - if gl_dict.account and self.doctype not in [ - "Journal Entry", - "Period Closing Voucher", - "Payment Entry", - ]: - set_balance_in_account_currency( - gl_dict, - account_currency, - args.get("transaction_exchange_rate") or self.get("conversion_rate"), - self.company_currency, - ) - - # Update details in transaction currency - if self.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: - gl_dict.update( - { - "transaction_currency": self.get("currency") or self.company_currency, - "transaction_exchange_rate": args.get("transaction_exchange_rate") - or self.get("conversion_rate", 1), - "debit_in_transaction_currency": self.get_value_in_transaction_currency( - account_currency, gl_dict, "debit" - ), - "credit_in_transaction_currency": self.get_value_in_transaction_currency( - account_currency, gl_dict, "credit" - ), - } - ) - - if not args.get("against_voucher_type") and self.get("against_voucher_type"): - gl_dict.update({"against_voucher_type": self.get("against_voucher_type")}) - - if not args.get("against_voucher") and self.get("against_voucher"): - gl_dict.update({"against_voucher": self.get("against_voucher")}) - - return gl_dict + return get_gl_dict(self, args, account_currency, item) def get_voucher_subtype(self): - voucher_subtypes = { - "Journal Entry": "voucher_type", - "Payment Entry": "payment_type", - "Stock Entry": "stock_entry_type", - "Asset Capitalization": "entry_type", - } + from erpnext.accounts.services.gl_entry_builder import get_voucher_subtype - for method_name in frappe.get_hooks("voucher_subtypes"): - voucher_subtype = frappe.get_attr(method_name)(self) - - if voucher_subtype: - return voucher_subtype - - if self.doctype in voucher_subtypes: - return self.get(voucher_subtypes[self.doctype]) - elif self.doctype == "Purchase Receipt" and self.is_return: - return "Purchase Return" - elif self.doctype == "Delivery Note" and self.is_return: - return "Sales Return" - elif self.doctype == "Sales Invoice" and self.is_return: - return "Credit Note" - elif self.doctype == "Sales Invoice" and self.is_debit_note: - return "Debit Note" - elif self.doctype == "Purchase Invoice" and self.is_return: - return "Debit Note" - - return self.doctype + return get_voucher_subtype(self) def get_value_in_transaction_currency(self, account_currency, gl_dict, field): - if account_currency == self.get("currency"): - return gl_dict.get(field + "_in_account_currency") - else: - return flt(gl_dict.get(field, 0) / self.get("conversion_rate", 1)) + from erpnext.accounts.services.gl_entry_builder import get_value_in_transaction_currency + + return get_value_in_transaction_currency(self, account_currency, gl_dict, field) def validate_zero_qty_for_return_invoices_with_stock(self): rows = [] @@ -1458,16 +1335,9 @@ class AccountsController(TransactionBase): ) def validate_account_currency(self, account, account_currency=None): - valid_currency = [self.company_currency] - if self.get("currency") and self.currency != self.company_currency: - valid_currency.append(self.currency) + from erpnext.accounts.services.gl_entry_builder import validate_account_currency - if account_currency not in valid_currency: - frappe.throw( - _("Account {0} is invalid. Account Currency must be {1}").format( - account, (" " + _("or") + " ").join(valid_currency) - ) - ) + return validate_account_currency(self, account, account_currency) def clear_unallocated_advances(self, childtype, parentfield): self.set(parentfield, self.get(parentfield, {"allocated_amount": ["not in", [0, None, ""]]})) @@ -3622,14 +3492,10 @@ def validate_einvoice_fields(doc): pass -@erpnext.allow_regional -def update_gl_dict_with_regional_fields(doc, gl_dict): - pass - - -def update_gl_dict_with_app_based_fields(doc, gl_dict): - for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): - frappe.get_attr(method)(doc, gl_dict) +from erpnext.accounts.services.gl_entry_builder import ( + update_gl_dict_with_app_based_fields, + update_gl_dict_with_regional_fields, +) @frappe.whitelist() diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index cf8f27560a5..b8cbce7045f 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1661,28 +1661,25 @@ class StockController(AccountsController): item=None, posting_date=None, ): - gl_entry = { - "account": account, - "cost_center": cost_center, - "debit": debit, - "credit": credit, - "against": against_account, - "remarks": remarks, - } + from erpnext.accounts.services.gl_entry_builder import add_gl_entry - if voucher_detail_no: - gl_entry.update({"voucher_detail_no": voucher_detail_no}) - - if debit_in_account_currency: - gl_entry.update({"debit_in_account_currency": debit_in_account_currency}) - - if credit_in_account_currency: - gl_entry.update({"credit_in_account_currency": credit_in_account_currency}) - - if posting_date: - gl_entry.update({"posting_date": posting_date}) - - gl_entries.append(self.get_gl_dict(gl_entry, item=item)) + add_gl_entry( + self, + gl_entries, + account, + cost_center, + debit, + credit, + remarks, + against_account, + debit_in_account_currency, + credit_in_account_currency, + account_currency, + project, + voucher_detail_no, + item, + posting_date, + ) def update_stock_reservation_entries(self): def get_sre_list(): diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py index 20347583bb1..6a01a0484ce 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py +++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py @@ -58,7 +58,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): account_currency = get_account_currency(stock_asset_account_name) if not stock_asset_account_name: validate_account("Asset or warehouse account") - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=stock_asset_account_name, cost_center=d.cost_center, @@ -108,7 +108,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): if not account: validate_account("Stock or Asset Received But Not Billed") - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=account, cost_center=item.cost_center, @@ -131,7 +131,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): exchange_rate_map[item.purchase_invoice] - doc.conversion_rate ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=account, cost_center=item.cost_center, @@ -144,7 +144,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): item=item, ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=doc.get_company_default("exchange_gain_loss_account"), cost_center=d.cost_center, @@ -173,7 +173,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): if not account: validate_account("Landed Cost Account") - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=account, cost_center=item.cost_center, @@ -190,7 +190,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): def make_amount_difference_entry(item): if item.amount_difference_with_purchase_invoice and stock_asset_rbnb: account_currency = get_account_currency(stock_asset_rbnb) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=stock_asset_rbnb, cost_center=item.cost_center, @@ -205,7 +205,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): def make_sub_contracting_gl_entries(item): if flt(item.rm_supp_cost) and supplier_warehouse_account: - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=supplier_warehouse_account, cost_center=item.cost_center, @@ -252,7 +252,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): "Company", doc.company, "cost_center" ) account_currency = get_account_currency(loss_account) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=loss_account, cost_center=cost_center, @@ -394,7 +394,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): ) amount_including_divisional_loss -= applicable_amount - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=account, cost_center=tax.cost_center, diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py index f4ad4586ebf..4788346833f 100644 --- a/erpnext/stock/doctype/stock_entry/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -75,7 +75,7 @@ class StockEntryGLComposer(BaseStockGLComposer): continue gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": account, "against": d.expense_account, @@ -89,7 +89,7 @@ class StockEntryGLComposer(BaseStockGLComposer): ) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": d.expense_account, "against": account, @@ -122,7 +122,7 @@ class StockEntryGLComposer(BaseStockGLComposer): _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse") gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": account, "against": _inv_dict["account"], @@ -140,7 +140,7 @@ class StockEntryGLComposer(BaseStockGLComposer): account_currency = get_account_currency(item.expense_account) gl_entries.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": item.expense_account, "against": _inv_dict["account"], diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py index 27731c0eb9e..89837db9909 100644 --- a/erpnext/stock/services/base_stock_gl_composer.py +++ b/erpnext/stock/services/base_stock_gl_composer.py @@ -56,7 +56,7 @@ class BaseStockGLComposer(BaseGLComposer): expense_account = item_row.expense_account gl_list.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": _inv_dict["account"], "against": expense_account, @@ -72,7 +72,7 @@ class BaseStockGLComposer(BaseGLComposer): ) gl_list.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": expense_account, "against": _inv_dict["account"], @@ -110,7 +110,7 @@ class BaseStockGLComposer(BaseGLComposer): ) gl_list.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": expense_account, "against": warehouse_asset_account, @@ -126,7 +126,7 @@ class BaseStockGLComposer(BaseGLComposer): ) gl_list.append( - doc.get_gl_dict( + self.get_gl_dict( { "account": warehouse_asset_account, "against": expense_account, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py index a0215a74bd1..7e31454ab23 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py @@ -66,7 +66,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): remarks = doc.get("remarks") or _("Accounting Entry for Stock") - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=_inv_dict["account"], cost_center=item.cost_center, @@ -83,7 +83,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): item.service_cost_per_qty, item.precision("service_cost_per_qty") ) * flt(item.qty, item.precision("qty")) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=item.expense_account, cost_center=item.cost_center, @@ -97,7 +97,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): ) service_account = item.service_expense_account or item.expense_account - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=service_account, cost_center=item.cost_center, @@ -116,7 +116,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): rm_item, inventory_account_map, "supplier_warehouse" ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=_inv_dict.get("account"), cost_center=rm_item.cost_center or item.cost_center, @@ -128,7 +128,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): project=item.project, item=item, ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=rm_item.expense_account or item.expense_account, cost_center=rm_item.cost_center or item.cost_center, @@ -142,7 +142,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): ) if item.additional_cost_per_qty: - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=item.expense_account, cost_center=doc.cost_center or doc.get_company_default("cost_center"), @@ -158,7 +158,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): "stock_adjustment_account", ignore_validation=True ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=loss_account, cost_center=item.cost_center, @@ -170,7 +170,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): project=item.project, item=item, ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=item.expense_account, cost_center=item.cost_center, @@ -195,7 +195,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): else flt(row.amount) ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=row.expense_account, cost_center=doc.cost_center or doc.get_company_default("cost_center"), @@ -234,7 +234,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): else flt(amount["amount"]) ) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=account, cost_center=item.cost_center, @@ -250,7 +250,7 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): account_currency = get_account_currency(item.expense_account) - doc.add_gl_entry( + self.add_gl_entry( gl_entries=gl_entries, account=item.expense_account, cost_center=item.cost_center, From 983d80f7c5e33a897f039be57fccbf848eef13ad Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 22:32:06 +0530 Subject: [PATCH 27/65] refactor(accounts): merge gl_entry_builder.py into base_gl_composer.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free functions (get_gl_dict, add_gl_entry, get_voucher_subtype, etc.) live in the same module as BaseGLComposer — they are all about building GL entries, so there is no reason to split them across two files. Removes gl_entry_builder.py and updates all import references to base_gl_composer. --- erpnext/accounts/services/base_gl_composer.py | 224 +++++++++++++++++- erpnext/accounts/services/gl_entry_builder.py | 223 ----------------- erpnext/controllers/accounts_controller.py | 12 +- erpnext/controllers/stock_controller.py | 2 +- 4 files changed, 225 insertions(+), 236 deletions(-) delete mode 100644 erpnext/accounts/services/gl_entry_builder.py diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py index 2a39c4a5243..270658aca13 100644 --- a/erpnext/accounts/services/base_gl_composer.py +++ b/erpnext/accounts/services/base_gl_composer.py @@ -1,16 +1,226 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -"""Base class for per-document GL entry composers. +"""Base class and free functions for per-document GL entry composition. -A composer assembles the list of GL entry dicts for a single voucher. Unlike -the posting sink (``general_ledger.make_gl_entries``) and the stateless -validators (``gl_validator``), composing is stateful and per-document, so it is -modelled as a class holding the document being composed. Subclasses implement -``compose`` to return the voucher-specific list of GL entries. +``BaseGLComposer`` holds the document being composed and exposes +``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic +lives in the module-level free functions below (``doc`` as first argument), so +``AccountsController`` and ``StockController`` can delegate to them via thin +shims without forcing every GL-building doctype to inherit from those classes. + +Subclasses implement ``compose`` to return the voucher-specific list of GL +entries. """ -from erpnext.accounts.services.gl_entry_builder import add_gl_entry, get_gl_dict +import frappe +from frappe import _ +from frappe.utils import flt, formatdate + +import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.accounts.services.taxes import set_balance_in_account_currency +from erpnext.accounts.utils import get_account_currency, get_fiscal_years +from erpnext.utilities.regional import temporary_flag + + +def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict: + """Build a GL entry dict populated with doc-level fields.""" + posting_date = args.get("posting_date") or doc.get("posting_date") + fiscal_years = get_fiscal_years(posting_date, company=doc.company) + if len(fiscal_years) > 1: + frappe.throw( + _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( + formatdate(posting_date) + ) + ) + else: + fiscal_year = fiscal_years[0][0] + + gl_dict = frappe._dict( + { + "company": doc.company, + "posting_date": posting_date, + "fiscal_year": fiscal_year, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "remarks": doc.get("remarks") or doc.get("remark"), + "debit": 0, + "credit": 0, + "debit_in_account_currency": 0, + "credit_in_account_currency": 0, + "is_opening": doc.get("is_opening") or "No", + "party_type": None, + "party": None, + "project": doc.get("project"), + "post_net_value": args.get("post_net_value"), + "voucher_detail_no": args.get("voucher_detail_no"), + "voucher_subtype": get_voucher_subtype(doc), + } + ) + + with temporary_flag("company", doc.company): + update_gl_dict_with_regional_fields(doc, gl_dict) + + update_gl_dict_with_app_based_fields(doc, gl_dict) + + accounting_dimensions = get_accounting_dimensions() + dimension_dict = frappe._dict() + for dimension in accounting_dimensions: + dimension_dict[dimension] = doc.get(dimension) + if item and item.get(dimension): + dimension_dict[dimension] = item.get(dimension) + + gl_dict.update(dimension_dict) + gl_dict.update(args) + + if not account_currency: + account_currency = get_account_currency(gl_dict.account) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + "Purchase Receipt", + "Purchase Invoice", + "Stock Entry", + ]: + validate_account_currency(doc, gl_dict.account, account_currency) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + ]: + set_balance_in_account_currency( + gl_dict, + account_currency, + args.get("transaction_exchange_rate") or doc.get("conversion_rate"), + doc.company_currency, + ) + + if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: + gl_dict.update( + { + "transaction_currency": doc.get("currency") or doc.company_currency, + "transaction_exchange_rate": args.get("transaction_exchange_rate") + or doc.get("conversion_rate", 1), + "debit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "debit" + ), + "credit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "credit" + ), + } + ) + + if not args.get("against_voucher_type") and doc.get("against_voucher_type"): + gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")}) + + if not args.get("against_voucher") and doc.get("against_voucher"): + gl_dict.update({"against_voucher": doc.get("against_voucher")}) + + return gl_dict + + +def add_gl_entry( + doc, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, +) -> None: + """Build a GL entry via get_gl_dict and append it to gl_entries.""" + gl_entry = { + "account": account, + "cost_center": cost_center, + "debit": debit, + "credit": credit, + "against": against_account, + "remarks": remarks, + } + + if voucher_detail_no: + gl_entry["voucher_detail_no"] = voucher_detail_no + + if debit_in_account_currency: + gl_entry["debit_in_account_currency"] = debit_in_account_currency + + if credit_in_account_currency: + gl_entry["credit_in_account_currency"] = credit_in_account_currency + + if posting_date: + gl_entry["posting_date"] = posting_date + + gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) + + +def get_voucher_subtype(doc) -> str: + voucher_subtypes = { + "Journal Entry": "voucher_type", + "Payment Entry": "payment_type", + "Stock Entry": "stock_entry_type", + "Asset Capitalization": "entry_type", + } + + for method_name in frappe.get_hooks("voucher_subtypes"): + voucher_subtype = frappe.get_attr(method_name)(doc) + if voucher_subtype: + return voucher_subtype + + if doc.doctype in voucher_subtypes: + return doc.get(voucher_subtypes[doc.doctype]) + elif doc.doctype == "Purchase Receipt" and doc.is_return: + return "Purchase Return" + elif doc.doctype == "Delivery Note" and doc.is_return: + return "Sales Return" + elif doc.doctype == "Sales Invoice" and doc.is_return: + return "Credit Note" + elif doc.doctype == "Sales Invoice" and doc.is_debit_note: + return "Debit Note" + elif doc.doctype == "Purchase Invoice" and doc.is_return: + return "Debit Note" + + return doc.doctype + + +def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float: + if account_currency == doc.get("currency"): + return gl_dict.get(field + "_in_account_currency") + return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1)) + + +def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None: + valid_currency = [doc.company_currency] + if doc.get("currency") and doc.currency != doc.company_currency: + valid_currency.append(doc.currency) + + if account_currency not in valid_currency: + frappe.throw( + _("Account {0} is invalid. Account Currency must be {1}").format( + account, (" " + _("or") + " ").join(valid_currency) + ) + ) + + +@erpnext.allow_regional +def update_gl_dict_with_regional_fields(doc, gl_dict): + pass + + +def update_gl_dict_with_app_based_fields(doc, gl_dict): + for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): + frappe.get_attr(method)(doc, gl_dict) class BaseGLComposer: diff --git a/erpnext/accounts/services/gl_entry_builder.py b/erpnext/accounts/services/gl_entry_builder.py deleted file mode 100644 index df304c020a6..00000000000 --- a/erpnext/accounts/services/gl_entry_builder.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Free functions for building GL entry dicts. - -These are the implementations behind ``AccountsController.get_gl_dict`` and -``StockController.add_gl_entry``. Extracting them as free functions (with -``doc`` as the first argument) allows ``BaseGLComposer`` to delegate to them -directly — without requiring every composing doctype to inherit from -``AccountsController``. - -``AccountsController`` and ``StockController`` keep thin shims that call these -functions so that existing code continues to work unchanged. -""" - -import frappe -from frappe import _ -from frappe.utils import flt, formatdate - -import erpnext -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions -from erpnext.accounts.services.taxes import set_balance_in_account_currency -from erpnext.accounts.utils import get_account_currency, get_fiscal_years -from erpnext.utilities.regional import temporary_flag - - -def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict: - """Build a GL entry dict populated with doc-level fields.""" - posting_date = args.get("posting_date") or doc.get("posting_date") - fiscal_years = get_fiscal_years(posting_date, company=doc.company) - if len(fiscal_years) > 1: - frappe.throw( - _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( - formatdate(posting_date) - ) - ) - else: - fiscal_year = fiscal_years[0][0] - - gl_dict = frappe._dict( - { - "company": doc.company, - "posting_date": posting_date, - "fiscal_year": fiscal_year, - "voucher_type": doc.doctype, - "voucher_no": doc.name, - "remarks": doc.get("remarks") or doc.get("remark"), - "debit": 0, - "credit": 0, - "debit_in_account_currency": 0, - "credit_in_account_currency": 0, - "is_opening": doc.get("is_opening") or "No", - "party_type": None, - "party": None, - "project": doc.get("project"), - "post_net_value": args.get("post_net_value"), - "voucher_detail_no": args.get("voucher_detail_no"), - "voucher_subtype": get_voucher_subtype(doc), - } - ) - - with temporary_flag("company", doc.company): - update_gl_dict_with_regional_fields(doc, gl_dict) - - update_gl_dict_with_app_based_fields(doc, gl_dict) - - accounting_dimensions = get_accounting_dimensions() - dimension_dict = frappe._dict() - for dimension in accounting_dimensions: - dimension_dict[dimension] = doc.get(dimension) - if item and item.get(dimension): - dimension_dict[dimension] = item.get(dimension) - - gl_dict.update(dimension_dict) - gl_dict.update(args) - - if not account_currency: - account_currency = get_account_currency(gl_dict.account) - - if gl_dict.account and doc.doctype not in [ - "Journal Entry", - "Period Closing Voucher", - "Payment Entry", - "Purchase Receipt", - "Purchase Invoice", - "Stock Entry", - ]: - validate_account_currency(doc, gl_dict.account, account_currency) - - if gl_dict.account and doc.doctype not in [ - "Journal Entry", - "Period Closing Voucher", - "Payment Entry", - ]: - set_balance_in_account_currency( - gl_dict, - account_currency, - args.get("transaction_exchange_rate") or doc.get("conversion_rate"), - doc.company_currency, - ) - - if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: - gl_dict.update( - { - "transaction_currency": doc.get("currency") or doc.company_currency, - "transaction_exchange_rate": args.get("transaction_exchange_rate") - or doc.get("conversion_rate", 1), - "debit_in_transaction_currency": get_value_in_transaction_currency( - doc, account_currency, gl_dict, "debit" - ), - "credit_in_transaction_currency": get_value_in_transaction_currency( - doc, account_currency, gl_dict, "credit" - ), - } - ) - - if not args.get("against_voucher_type") and doc.get("against_voucher_type"): - gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")}) - - if not args.get("against_voucher") and doc.get("against_voucher"): - gl_dict.update({"against_voucher": doc.get("against_voucher")}) - - return gl_dict - - -def add_gl_entry( - doc, - gl_entries: list, - account: str, - cost_center: str, - debit: float, - credit: float, - remarks: str, - against_account: str, - debit_in_account_currency: float | None = None, - credit_in_account_currency: float | None = None, - account_currency: str | None = None, - project: str | None = None, - voucher_detail_no: str | None = None, - item=None, - posting_date=None, -) -> None: - """Build a GL entry via get_gl_dict and append it to gl_entries.""" - gl_entry = { - "account": account, - "cost_center": cost_center, - "debit": debit, - "credit": credit, - "against": against_account, - "remarks": remarks, - } - - if voucher_detail_no: - gl_entry["voucher_detail_no"] = voucher_detail_no - - if debit_in_account_currency: - gl_entry["debit_in_account_currency"] = debit_in_account_currency - - if credit_in_account_currency: - gl_entry["credit_in_account_currency"] = credit_in_account_currency - - if posting_date: - gl_entry["posting_date"] = posting_date - - gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) - - -def get_voucher_subtype(doc) -> str: - voucher_subtypes = { - "Journal Entry": "voucher_type", - "Payment Entry": "payment_type", - "Stock Entry": "stock_entry_type", - "Asset Capitalization": "entry_type", - } - - for method_name in frappe.get_hooks("voucher_subtypes"): - voucher_subtype = frappe.get_attr(method_name)(doc) - if voucher_subtype: - return voucher_subtype - - if doc.doctype in voucher_subtypes: - return doc.get(voucher_subtypes[doc.doctype]) - elif doc.doctype == "Purchase Receipt" and doc.is_return: - return "Purchase Return" - elif doc.doctype == "Delivery Note" and doc.is_return: - return "Sales Return" - elif doc.doctype == "Sales Invoice" and doc.is_return: - return "Credit Note" - elif doc.doctype == "Sales Invoice" and doc.is_debit_note: - return "Debit Note" - elif doc.doctype == "Purchase Invoice" and doc.is_return: - return "Debit Note" - - return doc.doctype - - -def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float: - if account_currency == doc.get("currency"): - return gl_dict.get(field + "_in_account_currency") - return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1)) - - -def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None: - valid_currency = [doc.company_currency] - if doc.get("currency") and doc.currency != doc.company_currency: - valid_currency.append(doc.currency) - - if account_currency not in valid_currency: - frappe.throw( - _("Account {0} is invalid. Account Currency must be {1}").format( - account, (" " + _("or") + " ").join(valid_currency) - ) - ) - - -@erpnext.allow_regional -def update_gl_dict_with_regional_fields(doc, gl_dict): - pass - - -def update_gl_dict_with_app_based_fields(doc, gl_dict): - for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): - frappe.get_attr(method)(doc, gl_dict) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index abd708bad1a..7c200698a89 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -19,6 +19,7 @@ from frappe.utils import ( comma_and, flt, fmt_money, + formatdate, get_last_day, get_link_to_form, getdate, @@ -50,6 +51,7 @@ from erpnext.accounts.utils import ( create_gain_loss_journal, get_account_currency, get_currency_precision, + get_fiscal_years, validate_fiscal_year, ) from erpnext.accounts.utils import ( @@ -1291,17 +1293,17 @@ class AccountsController(TransactionBase): ) def get_gl_dict(self, args, account_currency=None, item=None): - from erpnext.accounts.services.gl_entry_builder import get_gl_dict + from erpnext.accounts.services.base_gl_composer import get_gl_dict return get_gl_dict(self, args, account_currency, item) def get_voucher_subtype(self): - from erpnext.accounts.services.gl_entry_builder import get_voucher_subtype + from erpnext.accounts.services.base_gl_composer import get_voucher_subtype return get_voucher_subtype(self) def get_value_in_transaction_currency(self, account_currency, gl_dict, field): - from erpnext.accounts.services.gl_entry_builder import get_value_in_transaction_currency + from erpnext.accounts.services.base_gl_composer import get_value_in_transaction_currency return get_value_in_transaction_currency(self, account_currency, gl_dict, field) @@ -1335,7 +1337,7 @@ class AccountsController(TransactionBase): ) def validate_account_currency(self, account, account_currency=None): - from erpnext.accounts.services.gl_entry_builder import validate_account_currency + from erpnext.accounts.services.base_gl_composer import validate_account_currency return validate_account_currency(self, account, account_currency) @@ -3492,7 +3494,7 @@ def validate_einvoice_fields(doc): pass -from erpnext.accounts.services.gl_entry_builder import ( +from erpnext.accounts.services.base_gl_composer import ( update_gl_dict_with_app_based_fields, update_gl_dict_with_regional_fields, ) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index b8cbce7045f..aebb30bf57c 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1661,7 +1661,7 @@ class StockController(AccountsController): item=None, posting_date=None, ): - from erpnext.accounts.services.gl_entry_builder import add_gl_entry + from erpnext.accounts.services.base_gl_composer import add_gl_entry add_gl_entry( self, From bb803a8f82047d7d17942640dfd22e70324cbfd2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 23:42:50 +0530 Subject: [PATCH 28/65] refactor: extract billing, payment schedule, and exchange gain/loss into services Move billing validation, payment schedule, and exchange gain/loss logic from AccountsController into dedicated service modules under accounts/services/. AccountsController retains thin shim methods that delegate to the services. --- .../accounts/services/billing_validation.py | 147 ++++ .../accounts/services/exchange_gain_loss.py | 237 ++++++ erpnext/accounts/services/payment_schedule.py | 373 ++++++++ erpnext/controllers/accounts_controller.py | 798 ++---------------- 4 files changed, 837 insertions(+), 718 deletions(-) create mode 100644 erpnext/accounts/services/billing_validation.py create mode 100644 erpnext/accounts/services/exchange_gain_loss.py create mode 100644 erpnext/accounts/services/payment_schedule.py diff --git a/erpnext/accounts/services/billing_validation.py b/erpnext/accounts/services/billing_validation.py new file mode 100644 index 00000000000..a40a885f283 --- /dev/null +++ b/erpnext/accounts/services/billing_validation.py @@ -0,0 +1,147 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Billing amount validation helpers (overbilling checks).""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import cint, flt, fmt_money + + +def validate_multiple_billing(doc, ref_dt: str, item_ref_dn: str, based_on: str) -> None: + from erpnext.controllers.status_updater import get_allowance_for + + ref_wise_billed_amount = get_reference_wise_billed_amt(doc, ref_dt, item_ref_dn, based_on) + if not ref_wise_billed_amount: + return + + total_overbilled_amt = 0.0 + overbilled_items = [] + precision = doc.precision(based_on, "items") + precision_allowance = 1 / (10**precision) + + role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") + is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles() + + for row in ref_wise_billed_amount.values(): + total_billed_amt = row.billed_amt + allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0] + max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100) + + if total_billed_amt < 0 and max_allowed_amt < 0: + total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt) + + overbill_amt = total_billed_amt - max_allowed_amt + row["max_allowed_amt"] = max_allowed_amt + total_overbilled_amt += overbill_amt + + if overbill_amt > precision_allowance and not is_overbilling_allowed: + if doc.doctype != "Purchase Invoice" or not cint( + frappe.db.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + ): + overbilled_items.append(row) + + if overbilled_items: + throw_overbill_exception(doc, overbilled_items, precision) + + if is_overbilling_allowed and total_overbilled_amt > 0.1: + frappe.msgprint( + _("Overbilling of {} ignored because you have {} role.").format( + total_overbilled_amt, role_allowed_to_overbill + ), + indicator="orange", + alert=True, + ) + + +def get_reference_wise_billed_amt(doc, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None: + """Return sum of billed amounts per reference row, including previously submitted invoices.""" + reference_names = [d.get(item_ref_dn) for d in doc.items if d.get(item_ref_dn)] + if not reference_names: + return + + precision = doc.precision(based_on, "items") + reference_details = get_billing_reference_details(doc, reference_names, ref_dt + " Item", based_on) + already_billed = get_already_billed_amount(doc, reference_names, item_ref_dn, based_on) + + ref_wise_billed_amount = {} + for item in doc.items: + key = item.get(item_ref_dn) + if not key: + continue + + ref_amt = flt(reference_details.get(key), precision) + current_amount = flt(item.get(based_on), precision) + + if not ref_amt: + if current_amount: + frappe.msgprint( + _("System will not check over billing since amount for Item {0} in {1} is zero").format( + item.item_code, ref_dt + ), + title=_("Warning"), + indicator="orange", + ) + continue + + ref_wise_billed_amount.setdefault( + key, + frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]), + ) + ref_wise_billed_amount[key]["rows"].append(item.idx) + ref_wise_billed_amount[key]["ref_amt"] = ref_amt + ref_wise_billed_amount[key]["billed_amt"] += current_amount + if key in already_billed: + ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision) + + return ref_wise_billed_amount + + +def get_billing_reference_details( + doc, reference_names: list, reference_doctype: str, based_on: str +) -> frappe._dict: + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", based_on], + as_list=1, + ) + ) + + +def get_already_billed_amount(doc, reference_names: list, item_ref_dn: str, based_on: str) -> frappe._dict: + item_doctype = frappe.qb.DocType(doc.items[0].doctype) + based_on_field = frappe.qb.Field(based_on) + join_field = frappe.qb.Field(item_ref_dn) + + return frappe._dict( + ( + frappe.qb.from_(item_doctype) + .select(join_field, Sum(based_on_field)) + .where(join_field.isin(reference_names)) + .where((item_doctype.docstatus == 1) & (item_doctype.parent != doc.name)) + .groupby(join_field) + ).run() + ) + + +def throw_overbill_exception(doc, overbilled_items: list, precision: int) -> None: + message = ( + _("

Cannot overbill for the following Items:

") + + "
    " + + "".join( + _("
  • Item {0} in row(s) {1} billed more than {2}
  • ").format( + frappe.bold(item.item_code), + ", ".join(str(x) for x in item.rows), + frappe.bold(fmt_money(item.max_allowed_amt, precision=precision, currency=doc.currency)), + ) + for item in overbilled_items + ) + + "
" + ) + message += _("

To allow over-billing, please set allowance in Accounts Settings.

") + frappe.throw(_(message)) diff --git a/erpnext/accounts/services/exchange_gain_loss.py b/erpnext/accounts/services/exchange_gain_loss.py new file mode 100644 index 00000000000..b7ed77bc664 --- /dev/null +++ b/erpnext/accounts/services/exchange_gain_loss.py @@ -0,0 +1,237 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Exchange gain/loss journal helpers.""" + +import frappe +from frappe import _, qb +from frappe.utils import flt, get_link_to_form + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center +from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision + + +def make_precision_loss_gl_entry(doc, gl_entries: list) -> None: + round_off_account, round_off_cost_center, _ = get_round_off_account_and_cost_center( + doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + precision_loss = doc.get("base_net_total") - flt( + doc.get("net_total") * doc.conversion_rate, doc.precision("net_total") + ) + + credit_or_debit = "credit" if doc.doctype == "Purchase Invoice" else "debit" + against = doc.supplier if doc.doctype == "Purchase Invoice" else doc.customer + + if precision_loss: + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": against, + credit_or_debit: precision_loss, + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else doc.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + + +def gain_loss_journal_already_booked( + gain_loss_account: str, + exc_gain_loss: float, + ref2_dt: str, + ref2_dn: str, + ref2_detail_no: str, +) -> bool: + """Check if a gain/loss journal has already been booked for the given parameters.""" + if res := frappe.db.get_all( + "Journal Entry Account", + filters={ + "docstatus": 1, + "account": gain_loss_account, + "reference_type": ref2_dt, + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + }, + pluck="parent", + ): + res = list({x for x in res}) + if exc_vouchers := frappe.db.get_all( + "Journal Entry", + filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"}, + fields=["voucher_type", "total_debit", "total_credit"], + ): + booked_voucher = exc_vouchers[0] + if ( + booked_voucher.total_debit == exc_gain_loss + and booked_voucher.total_credit == exc_gain_loss + and booked_voucher.voucher_type == "Exchange Gain Or Loss" + ): + return True + return False + + +def make_exchange_gain_loss_journal( + doc, args: dict | None = None, dimensions_dict: dict | None = None +) -> None: + """Make Exchange Gain/Loss journal for Invoices and Payments.""" + # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event. + # see accounts/utils.py:cancel_exchange_gain_loss_journal() + if doc.docstatus != 1: + return + + if dimensions_dict is None: + dimensions_dict = frappe._dict() + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = doc.get(dim.fieldname) + + if doc.get("doctype") == "Journal Entry": + if args: + precision = get_currency_precision() + for arg in args: + if ( + flt(arg.get("difference_amount", 0), precision) != 0 + or flt(arg.get("exchange_gain_loss", 0), precision) != 0 + ) and arg.get("difference_account"): + party_account = arg.get("account") + gain_loss_account = arg.get("difference_account") + difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss") + if difference_amount > 0: + dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" + else: + dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + if not gain_loss_journal_already_booked( + gain_loss_account, + difference_amount, + doc.doctype, + doc.name, + arg.get("referenced_row"), + ): + posting_date = arg.get("difference_posting_date") or frappe.db.get_value( + arg.voucher_type, arg.voucher_no, "posting_date" + ) + je = create_gain_loss_journal( + doc.company, + posting_date, + arg.get("party_type"), + arg.get("party"), + party_account, + gain_loss_account, + difference_amount, + dr_or_cr, + reverse_dr_or_cr, + arg.get("against_voucher_type"), + arg.get("against_voucher"), + arg.get("idx"), + doc.doctype, + doc.name, + arg.get("referenced_row"), + arg.get("cost_center"), + dimensions_dict, + arg.get("project"), + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) + + if doc.get("doctype") == "Payment Entry": + gain_loss_to_book = [x for x in doc.references if x.exchange_gain_loss != 0] + booked = [] + if gain_loss_to_book: + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + parents = ( + qb.from_(jea) + .select(jea.parent) + .where( + (jea.reference_type == "Payment Entry") + & (jea.reference_name == doc.name) + & (jea.docstatus == 1) + ) + .run() + ) + + if parents: + booked = ( + qb.from_(je) + .inner_join(jea) + .on(je.name == jea.parent) + .select(jea.reference_type, jea.reference_name, jea.reference_detail_no) + .where( + (je.docstatus == 1) + & (je.name.isin(parents)) + & (je.voucher_type == "Exchange Gain or Loss") + ) + .run() + ) + + for d in gain_loss_to_book: + if d.exchange_gain_loss and ((d.reference_doctype, d.reference_name, str(d.idx)) not in booked): + if doc.book_advance_payments_in_separate_party_account: + party_account = d.account + else: + if doc.payment_type == "Receive": + party_account = doc.paid_from + elif doc.payment_type == "Pay": + party_account = doc.paid_to + + dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" + + if is_payable_account(d.reference_doctype, party_account): + dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + gain_loss_account = frappe.get_cached_value( + "Company", doc.company, "exchange_gain_loss_account" + ) + je = create_gain_loss_journal( + doc.company, + args.get("difference_posting_date") if args else doc.posting_date, + doc.party_type, + doc.party, + party_account, + gain_loss_account, + d.exchange_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + d.reference_doctype, + d.reference_name, + d.idx, + doc.doctype, + doc.name, + d.idx, + doc.cost_center, + dimensions_dict, + doc.project, + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) + + +def is_payable_account(reference_doctype: str, account: str) -> bool: + if reference_doctype == "Purchase Invoice" or ( + reference_doctype == "Journal Entry" + and frappe.get_cached_value("Account", account, "account_type") == "Payable" + ): + return True + return False + + +def set_transaction_currency_and_rate_in_gl_map(doc, gl_entries: list) -> None: + for entry in gl_entries: + entry["transaction_currency"] = doc.currency + entry["transaction_exchange_rate"] = doc.get("conversion_rate") or 1 diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py new file mode 100644 index 00000000000..bc593a94c7b --- /dev/null +++ b/erpnext/accounts/services/payment_schedule.py @@ -0,0 +1,373 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Payment schedule and payment terms helpers.""" + +import frappe +from frappe import _ +from frappe.utils import DateTimeLikeObject, add_days, add_months, cint, flt, get_last_day, getdate + +from erpnext.accounts.party import get_party_account_currency + + +def set_payment_schedule(doc) -> None: + if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": + doc.payment_terms_template = "" + return + + party_account_currency = doc.get("party_account_currency") + if not party_account_currency: + party_type, party = doc.get_party() + if party_type and party: + party_account_currency = get_party_account_currency(party_type, party, doc.company) + + posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") + due_date = doc.get("due_date") or posting_date + + base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) + grand_total = flt(doc.get("rounded_total") or doc.grand_total) + automatically_fetch_payment_terms = 0 + + if doc.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): + po_or_so, doctype, fieldname = get_order_details(doc) + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + if doc.doctype != "Sales Order": + base_grand_total = base_grand_total - flt(doc.base_write_off_amount) + grand_total = grand_total - flt(doc.write_off_amount) + + if doc.get("total_advance"): + if party_account_currency == doc.company_currency: + base_grand_total -= doc.get("total_advance") + grand_total = flt(base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total")) + else: + grand_total -= doc.get("total_advance") + base_grand_total = flt( + grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total") + ) + + if not doc.get("payment_schedule"): + if ( + doc.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] + and automatically_fetch_payment_terms + and linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) + ): + fetch_payment_terms_from_order( + doc, po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) + if doc.get("payment_terms_template"): + doc.ignore_default_payment_terms_template = 1 + elif doc.get("payment_terms_template"): + data = get_payment_terms(doc.payment_terms_template, posting_date, grand_total, base_grand_total) + for item in data: + doc.append("payment_schedule", item) + elif doc.doctype not in ["Purchase Receipt"]: + doc.append( + "payment_schedule", + dict( + due_date=due_date, + invoice_portion=100, + payment_amount=grand_total, + base_payment_amount=base_grand_total, + ), + ) + + allocate_payment_based_on_payment_terms = frappe.db.get_value( + "Payment Terms Template", + doc.payment_terms_template, + "allocate_payment_based_on_payment_terms", + ) + + if not ( + automatically_fetch_payment_terms + and allocate_payment_based_on_payment_terms + and linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) + ): + for d in doc.get("payment_schedule"): + if d.invoice_portion: + d.payment_amount = flt( + grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount") + ) + d.base_payment_amount = flt( + base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount") + ) + d.outstanding = d.payment_amount + d.base_outstanding = d.base_payment_amount + elif not d.invoice_portion: + d.base_payment_amount = flt( + d.payment_amount * doc.get("conversion_rate"), d.precision("base_payment_amount") + ) + d.base_outstanding = d.base_payment_amount + else: + fetch_payment_terms_from_order( + doc, po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) + doc.ignore_default_payment_terms_template = 1 + + +def get_order_details(doc) -> tuple: + if not doc.get("items"): + return None, None, None + if doc.doctype == "Sales Invoice": + prev_doc = doc.get("items")[0].get("sales_order") + prev_doctype = "Sales Order" + prev_doctype_name = "sales_order" + elif doc.doctype == "Purchase Invoice": + prev_doc = doc.get("items")[0].get("purchase_order") + prev_doctype = "Purchase Order" + prev_doctype_name = "purchase_order" + else: + prev_doc = doc.get("items")[0].get("prevdoc_docname") + prev_doctype = "Quotation" + prev_doctype_name = "prevdoc_docname" + return prev_doc, prev_doctype, prev_doctype_name + + +def linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) -> bool: + if po_or_so and all_items_have_same_po_or_so(doc, po_or_so, fieldname): + if linked_order_has_payment_terms_template(po_or_so, doctype): + return True + elif linked_order_has_payment_schedule(po_or_so): + return True + return False + + +def all_items_have_same_po_or_so(doc, po_or_so, fieldname) -> bool: + for item in doc.get("items"): + if item.get(fieldname) != po_or_so: + return False + return True + + +def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: + return frappe.get_value(doctype, po_or_so, "payment_terms_template") + + +def linked_order_has_payment_schedule(po_or_so) -> list: + return frappe.get_all("Payment Schedule", filters={"parent": po_or_so}) + + +def fetch_payment_terms_from_order( + doc, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms +) -> None: + """Fetch Payment Terms from Purchase/Sales Order when creating a new invoice.""" + po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so) + + doc.payment_schedule = [] + doc.payment_terms_template = po_or_so.payment_terms_template + posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") + + for schedule in po_or_so.payment_schedule: + payment_schedule = { + "payment_term": schedule.payment_term, + "due_date": schedule.due_date, + "invoice_portion": schedule.invoice_portion, + "mode_of_payment": schedule.mode_of_payment, + "description": schedule.description, + "paid_amount": schedule.paid_amount, + } + + if automatically_fetch_payment_terms: + if schedule.due_date_based_on: + payment_schedule["due_date"] = get_due_date(schedule, posting_date) + payment_schedule["due_date_based_on"] = schedule.due_date_based_on + payment_schedule["credit_days"] = cint(schedule.credit_days) + payment_schedule["credit_months"] = cint(schedule.credit_months) + + if schedule.discount_validity_based_on: + payment_schedule["discount_date"] = get_discount_date(schedule, posting_date) + payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on + payment_schedule["discount_validity"] = cint(schedule.discount_validity) + + payment_schedule["payment_amount"] = flt( + grand_total * flt(payment_schedule["invoice_portion"]) / 100, + schedule.precision("payment_amount"), + ) + payment_schedule["base_payment_amount"] = flt( + base_grand_total * flt(payment_schedule["invoice_portion"]) / 100, + schedule.precision("base_payment_amount"), + ) + payment_schedule["outstanding"] = payment_schedule["payment_amount"] + else: + payment_schedule["base_payment_amount"] = flt( + schedule.base_payment_amount * doc.get("conversion_rate"), + schedule.precision("base_payment_amount"), + ) + + if schedule.discount_type == "Percentage": + payment_schedule["discount_type"] = schedule.discount_type + payment_schedule["discount"] = schedule.discount + + if not schedule.invoice_portion: + payment_schedule["payment_amount"] = schedule.payment_amount + + doc.append("payment_schedule", payment_schedule) + + +def set_due_date(doc) -> None: + due_dates = [d.due_date for d in doc.get("payment_schedule") if d.due_date] + if due_dates: + doc.due_date = max(due_dates) + + +def validate_payment_schedule_dates(doc) -> None: + dates = [] + li = [] + + if doc.doctype == "Sales Invoice" and doc.is_pos: + return + + for d in doc.get("payment_schedule"): + d.validate_from_to_dates("discount_date", "due_date") + if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( + doc.transaction_date + ): + frappe.throw( + _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(d.idx) + ) + elif d.due_date in dates: + li.append(_("{0} in row {1}").format(d.due_date, d.idx)) + dates.append(d.due_date) + + if li: + frappe.throw( + _("Rows with duplicate due dates in other rows were found: {0}").format("
" + "
".join(li)), + title=_("Payment Schedule"), + ) + + +def validate_payment_schedule_amount(doc) -> None: + if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": + return + + party_account_currency = doc.get("party_account_currency") + if not party_account_currency: + party_type, party = doc.get_party() + if party_type and party: + party_account_currency = get_party_account_currency(party_type, party, doc.company) + + if doc.get("payment_schedule"): + total = 0 + base_total = 0 + for d in doc.get("payment_schedule"): + total += flt(d.payment_amount, d.precision("payment_amount")) + base_total += flt(d.base_payment_amount, d.precision("base_payment_amount")) + + base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) + grand_total = flt(doc.get("rounded_total") or doc.grand_total) + + if doc.doctype in ("Sales Invoice", "Purchase Invoice"): + base_grand_total = base_grand_total - flt(doc.base_write_off_amount) + grand_total = grand_total - flt(doc.write_off_amount) + + if doc.get("total_advance"): + if party_account_currency == doc.company_currency: + base_grand_total -= doc.get("total_advance") + grand_total = flt(base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total")) + else: + grand_total -= doc.get("total_advance") + base_grand_total = flt( + grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total") + ) + + if ( + abs(flt(total, doc.precision("grand_total")) - flt(grand_total, doc.precision("grand_total"))) + > 0.1 + or abs( + flt(base_total, doc.precision("base_grand_total")) + - flt(base_grand_total, doc.precision("base_grand_total")) + ) + > 0.1 + ): + frappe.throw(_("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")) + + +def get_payment_terms( + terms_template: str, + posting_date: DateTimeLikeObject | None = None, + grand_total: float | None = None, + base_grand_total: float | None = None, + bill_date: DateTimeLikeObject | None = None, +) -> list: + if not terms_template: + return + + terms_doc = frappe.get_doc("Payment Terms Template", terms_template) + schedule = [] + for d in terms_doc.get("terms"): + d = frappe._dict(d.as_dict()) + term_details = get_payment_term_details(d, posting_date, grand_total, base_grand_total, bill_date) + schedule.append(term_details) + + return schedule + + +@frappe.whitelist() +def get_payment_term_details( + term: str | frappe._dict, + posting_date: DateTimeLikeObject | None = None, + grand_total: float | None = None, + base_grand_total: float | None = None, + bill_date: DateTimeLikeObject | None = None, +) -> frappe._dict: + term_details = frappe._dict() + if isinstance(term, str): + term = frappe.get_doc("Payment Term", term) + else: + term_details.payment_term = term.payment_term + + for field in [ + "description", + "invoice_portion", + "discount_type", + "discount", + "mode_of_payment", + "due_date_based_on", + "credit_days", + "credit_months", + "discount_validity_based_on", + "discount_validity", + ]: + term_details[field] = term.get(field) + + term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100 + term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100 + term_details.outstanding = term_details.payment_amount + term_details.base_outstanding = term_details.base_payment_amount + + if bill_date: + term_details.due_date = get_due_date(term, bill_date) + term_details.discount_date = get_discount_date(term, bill_date) + elif posting_date: + term_details.due_date = get_due_date(term, posting_date) + term_details.discount_date = get_discount_date(term, posting_date) + + if posting_date and getdate(term_details.due_date) < getdate(posting_date): + term_details.due_date = posting_date + + return term_details + + +def get_due_date(term, posting_date=None, bill_date=None): + due_date = None + date = bill_date or posting_date + if term.due_date_based_on == "Day(s) after invoice date": + due_date = add_days(date, cint(term.credit_days)) + elif term.due_date_based_on == "Day(s) after the end of the invoice month": + due_date = add_days(get_last_day(date), cint(term.credit_days)) + elif term.due_date_based_on == "Month(s) after the end of the invoice month": + due_date = get_last_day(add_months(date, cint(term.credit_months))) + return due_date + + +def get_discount_date(term, posting_date=None, bill_date=None): + discount_validity = None + date = bill_date or posting_date + if term.discount_validity_based_on == "Day(s) after invoice date": + discount_validity = add_days(date, cint(term.discount_validity)) + elif term.discount_validity_based_on == "Day(s) after the end of the invoice month": + discount_validity = add_days(get_last_day(date), cint(term.discount_validity)) + elif term.discount_validity_based_on == "Month(s) after the end of the invoice month": + discount_validity = get_last_day(add_months(date, cint(term.discount_validity))) + return discount_validity diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7c200698a89..6723bb98bdd 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -12,15 +12,9 @@ from frappe.model.workflow import get_workflow_name, is_transition_condition_sat from frappe.query_builder import DocType from frappe.query_builder.functions import Sum from frappe.utils import ( - DateTimeLikeObject, - add_days, - add_months, cint, comma_and, flt, - fmt_money, - formatdate, - get_last_day, get_link_to_form, getdate, nowdate, @@ -48,10 +42,7 @@ from erpnext.accounts.party import ( validate_party_frozen_disabled, ) from erpnext.accounts.utils import ( - create_gain_loss_journal, get_account_currency, - get_currency_precision, - get_fiscal_years, validate_fiscal_year, ) from erpnext.accounts.utils import ( @@ -1407,239 +1398,30 @@ class AccountsController(TransactionBase): set_advance_gain_or_loss(self) def make_precision_loss_gl_entry(self, gl_entries): - ( - round_off_account, - round_off_cost_center, - round_off_for_opening, - ) = get_round_off_account_and_cost_center( - self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center - ) + from erpnext.accounts.services.exchange_gain_loss import make_precision_loss_gl_entry - precision_loss = self.get("base_net_total") - flt( - self.get("net_total") * self.conversion_rate, self.precision("net_total") - ) - - credit_or_debit = "credit" if self.doctype == "Purchase Invoice" else "debit" - against = self.supplier if self.doctype == "Purchase Invoice" else self.customer - - if precision_loss: - gl_entries.append( - self.get_gl_dict( - { - "account": round_off_account, - "against": against, - credit_or_debit: precision_loss, - "cost_center": round_off_cost_center - if self.use_company_roundoff_cost_center - else self.cost_center or round_off_cost_center, - "remarks": _("Net total calculation precision loss"), - } - ) - ) + make_precision_loss_gl_entry(self, gl_entries) def gain_loss_journal_already_booked( - self, - gain_loss_account, - exc_gain_loss, - ref2_dt, - ref2_dn, - ref2_detail_no, + self, gain_loss_account, exc_gain_loss, ref2_dt, ref2_dn, ref2_detail_no ) -> bool: - """ - Check if gain/loss is booked - """ - if res := frappe.db.get_all( - "Journal Entry Account", - filters={ - "docstatus": 1, - "account": gain_loss_account, - "reference_type": ref2_dt, # this will be Journal Entry - "reference_name": ref2_dn, - "reference_detail_no": ref2_detail_no, - }, - pluck="parent", - ): - # deduplicate - res = list({x for x in res}) - if exc_vouchers := frappe.db.get_all( - "Journal Entry", - filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"}, - fields=["voucher_type", "total_debit", "total_credit"], - ): - booked_voucher = exc_vouchers[0] - if ( - booked_voucher.total_debit == exc_gain_loss - and booked_voucher.total_credit == exc_gain_loss - and booked_voucher.voucher_type == "Exchange Gain Or Loss" - ): - return True - return False + from erpnext.accounts.services.exchange_gain_loss import gain_loss_journal_already_booked + + return gain_loss_journal_already_booked( + gain_loss_account, exc_gain_loss, ref2_dt, ref2_dn, ref2_detail_no + ) def make_exchange_gain_loss_journal( self, args: dict | None = None, dimensions_dict: dict | None = None ) -> None: - """ - Make Exchange Gain/Loss journal for Invoices and Payments - """ - # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event. - # see accounts/utils.py:cancel_exchange_gain_loss_journal() - if self.docstatus == 1: - if dimensions_dict is None: - dimensions_dict = frappe._dict() - active_dimensions = get_dimensions()[0] - for dim in active_dimensions: - dimensions_dict[dim.fieldname] = self.get(dim.fieldname) + from erpnext.accounts.services.exchange_gain_loss import make_exchange_gain_loss_journal - if self.get("doctype") == "Journal Entry": - # 'args' is populated with exchange gain/loss account and the amount to be booked. - # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation. - # and below logic is only for such scenarios - if args: - precision = get_currency_precision() - for arg in args: - # Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount` - if ( - flt(arg.get("difference_amount", 0), precision) != 0 - or flt(arg.get("exchange_gain_loss", 0), precision) != 0 - ) and arg.get("difference_account"): - party_account = arg.get("account") - gain_loss_account = arg.get("difference_account") - difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss") - if difference_amount > 0: - dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" - else: - dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit" - - reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - - if not self.gain_loss_journal_already_booked( - gain_loss_account, - difference_amount, - self.doctype, - self.name, - arg.get("referenced_row"), - ): - posting_date = arg.get("difference_posting_date") or frappe.db.get_value( - arg.voucher_type, arg.voucher_no, "posting_date" - ) - je = create_gain_loss_journal( - self.company, - posting_date, - arg.get("party_type"), - arg.get("party"), - party_account, - gain_loss_account, - difference_amount, - dr_or_cr, - reverse_dr_or_cr, - arg.get("against_voucher_type"), - arg.get("against_voucher"), - arg.get("idx"), - self.doctype, - self.name, - arg.get("referenced_row"), - arg.get("cost_center"), - dimensions_dict, - arg.get("project"), - ) - frappe.msgprint( - _("Exchange Gain/Loss amount has been booked through {0}").format( - get_link_to_form("Journal Entry", je) - ) - ) - - if self.get("doctype") == "Payment Entry": - # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation - gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] - booked = [] - if gain_loss_to_book: - [x.reference_doctype for x in gain_loss_to_book] - [x.reference_name for x in gain_loss_to_book] - je = qb.DocType("Journal Entry") - jea = qb.DocType("Journal Entry Account") - parents = ( - qb.from_(jea) - .select(jea.parent) - .where( - (jea.reference_type == "Payment Entry") - & (jea.reference_name == self.name) - & (jea.docstatus == 1) - ) - .run() - ) - - booked = [] - if parents: - booked = ( - qb.from_(je) - .inner_join(jea) - .on(je.name == jea.parent) - .select(jea.reference_type, jea.reference_name, jea.reference_detail_no) - .where( - (je.docstatus == 1) - & (je.name.isin(parents)) - & (je.voucher_type == "Exchange Gain or Loss") - ) - .run() - ) - - for d in gain_loss_to_book: - # Filter out References for which Gain/Loss is already booked - if d.exchange_gain_loss and ( - (d.reference_doctype, d.reference_name, str(d.idx)) not in booked - ): - if self.book_advance_payments_in_separate_party_account: - party_account = d.account - else: - if self.payment_type == "Receive": - party_account = self.paid_from - elif self.payment_type == "Pay": - party_account = self.paid_to - - dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" - - # Inverse debit/credit for payable accounts - if self.is_payable_account(d.reference_doctype, party_account): - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - - reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - - gain_loss_account = frappe.get_cached_value( - "Company", self.company, "exchange_gain_loss_account" - ) - je = create_gain_loss_journal( - self.company, - args.get("difference_posting_date") if args else self.posting_date, - self.party_type, - self.party, - party_account, - gain_loss_account, - d.exchange_gain_loss, - dr_or_cr, - reverse_dr_or_cr, - d.reference_doctype, - d.reference_name, - d.idx, - self.doctype, - self.name, - d.idx, - self.cost_center, - dimensions_dict, - self.project, - ) - frappe.msgprint( - _("Exchange Gain/Loss amount has been booked through {0}").format( - get_link_to_form("Journal Entry", je) - ) - ) + make_exchange_gain_loss_journal(self, args, dimensions_dict) def is_payable_account(self, reference_doctype, account): - if reference_doctype == "Purchase Invoice" or ( - reference_doctype == "Journal Entry" - and frappe.get_cached_value("Account", account, "account_type") == "Payable" - ): - return True - return False + from erpnext.accounts.services.exchange_gain_loss import is_payable_account + + return is_payable_account(reference_doctype, account) def update_against_document_in_jv(self): """ @@ -1907,147 +1689,34 @@ class AccountsController(TransactionBase): ) ) - def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on): - from erpnext.controllers.status_updater import get_allowance_for + def validate_multiple_billing(self, ref_dt: str, item_ref_dn: str, based_on: str) -> None: + from erpnext.accounts.services.billing_validation import validate_multiple_billing - ref_wise_billed_amount = self.get_reference_wise_billed_amt(ref_dt, item_ref_dn, based_on) + validate_multiple_billing(self, ref_dt, item_ref_dn, based_on) - if not ref_wise_billed_amount: - return + def get_billing_reference_details( + self, reference_names: list, reference_doctype: str, based_on: str + ) -> frappe._dict: + from erpnext.accounts.services.billing_validation import get_billing_reference_details - total_overbilled_amt = 0.0 - overbilled_items = [] - precision = self.precision(based_on, "items") - precision_allowance = 1 / (10**precision) + return get_billing_reference_details(self, reference_names, reference_doctype, based_on) - role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") - is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles() + def get_reference_wise_billed_amt(self, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None: + from erpnext.accounts.services.billing_validation import get_reference_wise_billed_amt - for row in ref_wise_billed_amount.values(): - total_billed_amt = row.billed_amt - allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0] + return get_reference_wise_billed_amt(self, ref_dt, item_ref_dn, based_on) - max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100) + def get_already_billed_amount( + self, reference_names: list, item_ref_dn: str, based_on: str + ) -> frappe._dict: + from erpnext.accounts.services.billing_validation import get_already_billed_amount - if total_billed_amt < 0 and max_allowed_amt < 0: - # while making debit note against purchase return entry(purchase receipt) getting overbill error - total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt) + return get_already_billed_amount(self, reference_names, item_ref_dn, based_on) - overbill_amt = total_billed_amt - max_allowed_amt - row["max_allowed_amt"] = max_allowed_amt - total_overbilled_amt += overbill_amt + def throw_overbill_exception(self, overbilled_items: list, precision: int) -> None: + from erpnext.accounts.services.billing_validation import throw_overbill_exception - if overbill_amt > precision_allowance and not is_overbilling_allowed: - if self.doctype != "Purchase Invoice" or not cint( - frappe.db.get_single_value( - "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" - ) - ): - overbilled_items.append(row) - - if overbilled_items: - self.throw_overbill_exception(overbilled_items, precision) - - if is_overbilling_allowed and total_overbilled_amt > 0.1: - frappe.msgprint( - _("Overbilling of {} ignored because you have {} role.").format( - total_overbilled_amt, role_allowed_to_overbill - ), - indicator="orange", - alert=True, - ) - - def get_billing_reference_details(self, reference_names, reference_doctype, based_on): - return frappe._dict( - frappe.get_all( - reference_doctype, - filters={"name": ("in", reference_names)}, - fields=["name", based_on], - as_list=1, - ) - ) - - def get_reference_wise_billed_amt(self, ref_dt, item_ref_dn, based_on): - """ - Returns Sum of Amount of - Sales/Purchase Invoice Items - that are linked to `item_ref_dn` (`dn_detail` / `pr_detail`) - that are submitted OR not submitted but are under current invoice - """ - reference_names = [d.get(item_ref_dn) for d in self.items if d.get(item_ref_dn)] - - if not reference_names: - return - - ref_wise_billed_amount = {} - precision = self.precision(based_on, "items") - reference_details = self.get_billing_reference_details(reference_names, ref_dt + " Item", based_on) - already_billed = self.get_already_billed_amount(reference_names, item_ref_dn, based_on) - - for item in self.items: - key = item.get(item_ref_dn) - if not key: - continue - - ref_amt = flt(reference_details.get(key), precision) - current_amount = flt(item.get(based_on), precision) - - if not ref_amt: - if current_amount: # Skip warning for free items - frappe.msgprint( - _( - "System will not check over billing since amount for Item {0} in {1} is zero" - ).format(item.item_code, ref_dt), - title=_("Warning"), - indicator="orange", - ) - continue - - ref_wise_billed_amount.setdefault( - key, - frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]), - ) - - ref_wise_billed_amount[key]["rows"].append(item.idx) - ref_wise_billed_amount[key]["ref_amt"] = ref_amt - ref_wise_billed_amount[key]["billed_amt"] += current_amount - if key in already_billed: - ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision) - - return ref_wise_billed_amount - - def get_already_billed_amount(self, reference_names, item_ref_dn, based_on): - item_doctype = frappe.qb.DocType(self.items[0].doctype) - based_on_field = frappe.qb.Field(based_on) - join_field = frappe.qb.Field(item_ref_dn) - - return frappe._dict( - ( - frappe.qb.from_(item_doctype) - .select(join_field, Sum(based_on_field)) - .where(join_field.isin(reference_names)) - .where((item_doctype.docstatus == 1) & (item_doctype.parent != self.name)) - .groupby(join_field) - ).run() - ) - - def throw_overbill_exception(self, overbilled_items, precision): - message = ( - _("

Cannot overbill for the following Items:

") - + "
    " - + "".join( - _("
  • Item {0} in row(s) {1} billed more than {2}
  • ").format( - frappe.bold(item.item_code), - ", ".join(str(x) for x in item.rows), - frappe.bold(fmt_money(item.max_allowed_amt, precision=precision, currency=self.currency)), - ) - for item in overbilled_items - ) - + "
" - ) - message += _("

To allow over-billing, please set allowance in Accounts Settings.

") - - frappe.throw(_(message)) + throw_overbill_exception(self, overbilled_items, precision) def get_company_default(self, fieldname, ignore_validation=False): from erpnext.accounts.utils import get_company_default @@ -2235,285 +1904,64 @@ class AccountsController(TransactionBase): for item in duplicate_list: self.remove(item) - def set_payment_schedule(self): - if (self.doctype == "Sales Invoice" and self.is_pos) or self.get("is_opening") == "Yes": - self.payment_terms_template = "" - return + def set_payment_schedule(self) -> None: + from erpnext.accounts.services.payment_schedule import set_payment_schedule - party_account_currency = self.get("party_account_currency") - if not party_account_currency: - party_type, party = self.get_party() + set_payment_schedule(self) - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, self.company) + def get_order_details(self) -> tuple: + from erpnext.accounts.services.payment_schedule import get_order_details - posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date") - date = self.get("due_date") - due_date = date or posting_date + return get_order_details(self) - base_grand_total = flt(self.get("base_rounded_total") or self.base_grand_total) - grand_total = flt(self.get("rounded_total") or self.grand_total) - automatically_fetch_payment_terms = 0 + def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) -> bool: + from erpnext.accounts.services.payment_schedule import linked_order_has_payment_terms - if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): - po_or_so, doctype, fieldname = self.get_order_details() - automatically_fetch_payment_terms = cint( - frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") - ) - if self.doctype != "Sales Order": - base_grand_total = base_grand_total - flt(self.base_write_off_amount) - grand_total = grand_total - flt(self.write_off_amount) + return linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) - if self.get("total_advance"): - if party_account_currency == self.company_currency: - base_grand_total -= self.get("total_advance") - grand_total = flt( - base_grand_total / self.get("conversion_rate"), self.precision("grand_total") - ) - else: - grand_total -= self.get("total_advance") - base_grand_total = flt( - grand_total * self.get("conversion_rate"), self.precision("base_grand_total") - ) + def all_items_have_same_po_or_so(self, po_or_so, fieldname) -> bool: + from erpnext.accounts.services.payment_schedule import all_items_have_same_po_or_so - if not self.get("payment_schedule"): - if ( - self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] - and automatically_fetch_payment_terms - and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) - ): - self.fetch_payment_terms_from_order( - po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - if self.get("payment_terms_template"): - self.ignore_default_payment_terms_template = 1 - elif self.get("payment_terms_template"): - data = get_payment_terms( - self.payment_terms_template, posting_date, grand_total, base_grand_total - ) - for item in data: - self.append("payment_schedule", item) - elif self.doctype not in ["Purchase Receipt"]: - data = dict( - due_date=due_date, - invoice_portion=100, - payment_amount=grand_total, - base_payment_amount=base_grand_total, - ) - self.append("payment_schedule", data) + return all_items_have_same_po_or_so(self, po_or_so, fieldname) - allocate_payment_based_on_payment_terms = frappe.db.get_value( - "Payment Terms Template", self.payment_terms_template, "allocate_payment_based_on_payment_terms" - ) + def linked_order_has_payment_terms_template(self, po_or_so, doctype) -> str | None: + from erpnext.accounts.services.payment_schedule import linked_order_has_payment_terms_template - if not ( - automatically_fetch_payment_terms - and allocate_payment_based_on_payment_terms - and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) - ): - for d in self.get("payment_schedule"): - if d.invoice_portion: - d.payment_amount = flt( - grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount") - ) - d.base_payment_amount = flt( - base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount") - ) - d.outstanding = d.payment_amount - d.base_outstanding = d.base_payment_amount - elif not d.invoice_portion: - d.base_payment_amount = flt( - d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount") - ) - d.base_outstanding = d.base_payment_amount - else: - self.fetch_payment_terms_from_order( - po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - self.ignore_default_payment_terms_template = 1 + return linked_order_has_payment_terms_template(po_or_so, doctype) - def get_order_details(self): - if not self.get("items"): - return None, None, None - if self.doctype == "Sales Invoice": - prev_doc = self.get("items")[0].get("sales_order") - prev_doctype = "Sales Order" - prev_doctype_name = "sales_order" - elif self.doctype == "Purchase Invoice": - prev_doc = self.get("items")[0].get("purchase_order") - prev_doctype = "Purchase Order" - prev_doctype_name = "purchase_order" - else: - prev_doc = self.get("items")[0].get("prevdoc_docname") - prev_doctype = "Quotation" - prev_doctype_name = "prevdoc_docname" - return prev_doc, prev_doctype, prev_doctype_name + def linked_order_has_payment_schedule(self, po_or_so) -> list: + from erpnext.accounts.services.payment_schedule import linked_order_has_payment_schedule - def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype): - if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname): - if self.linked_order_has_payment_terms_template(po_or_so, doctype): - return True - elif self.linked_order_has_payment_schedule(po_or_so): - return True - - return False - - def all_items_have_same_po_or_so(self, po_or_so, fieldname): - for item in self.get("items"): - if item.get(fieldname) != po_or_so: - return False - - return True - - def linked_order_has_payment_terms_template(self, po_or_so, doctype): - return frappe.get_value(doctype, po_or_so, "payment_terms_template") - - def linked_order_has_payment_schedule(self, po_or_so): - return frappe.get_all("Payment Schedule", filters={"parent": po_or_so}) + return linked_order_has_payment_schedule(po_or_so) def fetch_payment_terms_from_order( - self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ): - """ - Fetch Payment Terms from Purchase/Sales Order on creating a new Purchase/Sales Invoice. - """ - po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so) + self, + po_or_so, + po_or_so_doctype, + grand_total, + base_grand_total, + automatically_fetch_payment_terms, + ) -> None: + from erpnext.accounts.services.payment_schedule import fetch_payment_terms_from_order - self.payment_schedule = [] - self.payment_terms_template = po_or_so.payment_terms_template - posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date") + fetch_payment_terms_from_order( + self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) - for schedule in po_or_so.payment_schedule: - payment_schedule = { - "payment_term": schedule.payment_term, - "due_date": schedule.due_date, - "invoice_portion": schedule.invoice_portion, - "mode_of_payment": schedule.mode_of_payment, - "description": schedule.description, - "paid_amount": schedule.paid_amount, - } + def set_due_date(self) -> None: + from erpnext.accounts.services.payment_schedule import set_due_date - if automatically_fetch_payment_terms: - if schedule.due_date_based_on: - payment_schedule["due_date"] = get_due_date(schedule, posting_date) - payment_schedule["due_date_based_on"] = schedule.due_date_based_on - payment_schedule["credit_days"] = cint(schedule.credit_days) - payment_schedule["credit_months"] = cint(schedule.credit_months) + set_due_date(self) - if schedule.discount_validity_based_on: - payment_schedule["discount_date"] = get_discount_date(schedule, posting_date) - payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on - payment_schedule["discount_validity"] = cint(schedule.discount_validity) + def validate_payment_schedule_dates(self) -> None: + from erpnext.accounts.services.payment_schedule import validate_payment_schedule_dates - payment_schedule["payment_amount"] = flt( - grand_total * flt(payment_schedule["invoice_portion"]) / 100, - schedule.precision("payment_amount"), - ) - payment_schedule["base_payment_amount"] = flt( - base_grand_total * flt(payment_schedule["invoice_portion"]) / 100, - schedule.precision("base_payment_amount"), - ) - payment_schedule["outstanding"] = payment_schedule["payment_amount"] - else: - payment_schedule["base_payment_amount"] = flt( - schedule.base_payment_amount * self.get("conversion_rate"), - schedule.precision("base_payment_amount"), - ) + validate_payment_schedule_dates(self) - if schedule.discount_type == "Percentage": - payment_schedule["discount_type"] = schedule.discount_type - payment_schedule["discount"] = schedule.discount + def validate_payment_schedule_amount(self) -> None: + from erpnext.accounts.services.payment_schedule import validate_payment_schedule_amount - if not schedule.invoice_portion: - payment_schedule["payment_amount"] = schedule.payment_amount - - self.append("payment_schedule", payment_schedule) - - def set_due_date(self): - due_dates = [d.due_date for d in self.get("payment_schedule") if d.due_date] - if due_dates: - self.due_date = max(due_dates) - - def validate_payment_schedule_dates(self): - dates = [] - li = [] - - if self.doctype == "Sales Invoice" and self.is_pos: - return - - for d in self.get("payment_schedule"): - d.validate_from_to_dates("discount_date", "due_date") - if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( - self.transaction_date - ): - frappe.throw( - _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format( - d.idx - ) - ) - elif d.due_date in dates: - li.append(_("{0} in row {1}").format(d.due_date, d.idx)) - dates.append(d.due_date) - - if li: - duplicates = "
" + "
".join(li) - frappe.throw( - _("Rows with duplicate due dates in other rows were found: {0}").format(duplicates), - title=_("Payment Schedule"), - ) - - def validate_payment_schedule_amount(self): - if (self.doctype == "Sales Invoice" and self.is_pos) or self.get("is_opening") == "Yes": - return - - party_account_currency = self.get("party_account_currency") - if not party_account_currency: - party_type, party = self.get_party() - - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, self.company) - - if self.get("payment_schedule"): - total = 0 - base_total = 0 - for d in self.get("payment_schedule"): - total += flt(d.payment_amount, d.precision("payment_amount")) - base_total += flt(d.base_payment_amount, d.precision("base_payment_amount")) - - base_grand_total = flt(self.get("base_rounded_total") or self.base_grand_total) - grand_total = flt(self.get("rounded_total") or self.grand_total) - - if self.doctype in ("Sales Invoice", "Purchase Invoice"): - base_grand_total = base_grand_total - flt(self.base_write_off_amount) - grand_total = grand_total - flt(self.write_off_amount) - - if self.get("total_advance"): - if party_account_currency == self.company_currency: - base_grand_total -= self.get("total_advance") - grand_total = flt( - base_grand_total / self.get("conversion_rate"), self.precision("grand_total") - ) - else: - grand_total -= self.get("total_advance") - base_grand_total = flt( - grand_total * self.get("conversion_rate"), self.precision("base_grand_total") - ) - - if ( - abs( - flt(total, self.precision("grand_total")) - - flt(grand_total, self.precision("grand_total")) - ) - > 0.1 - or abs( - flt(base_total, self.precision("base_grand_total")) - - flt(base_grand_total, self.precision("base_grand_total")) - ) - > 0.1 - ): - frappe.throw( - _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total") - ) + validate_payment_schedule_amount(self) def is_rounded_total_disabled(self): if self.meta.get_field("disable_rounded_total"): @@ -2641,10 +2089,10 @@ class AccountsController(TransactionBase): def get_advance_payment_doctypes(self, payment_type=None) -> list: return _get_advance_payment_doctypes(payment_type=payment_type) - def set_transaction_currency_and_rate_in_gl_map(self, gl_entries): - for x in gl_entries: - x["transaction_currency"] = self.currency - x["transaction_exchange_rate"] = self.get("conversion_rate") or 1 + def set_transaction_currency_and_rate_in_gl_map(self, gl_entries: list) -> None: + from erpnext.accounts.services.exchange_gain_loss import set_transaction_currency_and_rate_in_gl_map + + set_transaction_currency_and_rate_in_gl_map(self, gl_entries) def after_mapping(self, source_doc): self.set_discount_amount_after_mapping(source_doc) @@ -2823,98 +2271,12 @@ def update_invoice_status(): frappe.qb.update(invoice).set("status", status).where(conditions).run() -@frappe.whitelist() -def get_payment_terms( - terms_template: str, - posting_date: DateTimeLikeObject | None = None, - grand_total: float | None = None, - base_grand_total: float | None = None, - bill_date: DateTimeLikeObject | None = None, -): - if not terms_template: - return - - terms_doc = frappe.get_doc("Payment Terms Template", terms_template) - - schedule = [] - for d in terms_doc.get("terms"): - d = frappe._dict(d.as_dict()) - term_details = get_payment_term_details(d, posting_date, grand_total, base_grand_total, bill_date) - schedule.append(term_details) - - return schedule - - -@frappe.whitelist() -def get_payment_term_details( - term: str | frappe._dict, - posting_date: DateTimeLikeObject | None = None, - grand_total: float | None = None, - base_grand_total: float | None = None, - bill_date: DateTimeLikeObject | None = None, -): - term_details = frappe._dict() - if isinstance(term, str): - term = frappe.get_doc("Payment Term", term) - else: - term_details.payment_term = term.payment_term - - fields_to_copy = [ - "description", - "invoice_portion", - "discount_type", - "discount", - "mode_of_payment", - "due_date_based_on", - "credit_days", - "credit_months", - "discount_validity_based_on", - "discount_validity", - ] - - for field in fields_to_copy: - term_details[field] = term.get(field) - - term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100 - term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100 - term_details.outstanding = term_details.payment_amount - term_details.base_outstanding = term_details.base_payment_amount - - if bill_date: - term_details.due_date = get_due_date(term, bill_date) - term_details.discount_date = get_discount_date(term, bill_date) - elif posting_date: - term_details.due_date = get_due_date(term, posting_date) - term_details.discount_date = get_discount_date(term, posting_date) - - if posting_date and getdate(term_details.due_date) < getdate(posting_date): - term_details.due_date = posting_date - - return term_details - - -def get_due_date(term, posting_date=None, bill_date=None): - due_date = None - date = bill_date or posting_date - if term.due_date_based_on == "Day(s) after invoice date": - due_date = add_days(date, cint(term.credit_days)) - elif term.due_date_based_on == "Day(s) after the end of the invoice month": - due_date = add_days(get_last_day(date), cint(term.credit_days)) - elif term.due_date_based_on == "Month(s) after the end of the invoice month": - due_date = get_last_day(add_months(date, cint(term.credit_months))) - return due_date - - -def get_discount_date(term, posting_date=None, bill_date=None): - discount_validity = None - date = bill_date or posting_date - if term.discount_validity_based_on == "Day(s) after invoice date": - discount_validity = add_days(date, cint(term.discount_validity)) - elif term.discount_validity_based_on == "Day(s) after the end of the invoice month": - discount_validity = add_days(get_last_day(date), cint(term.discount_validity)) - elif term.discount_validity_based_on == "Month(s) after the end of the invoice month": - discount_validity = get_last_day(add_months(date, cint(term.discount_validity))) - return discount_validity +from erpnext.accounts.services.payment_schedule import ( + get_discount_date, + get_due_date, + get_payment_term_details, + get_payment_terms, +) def get_supplier_block_status(party_name): From 0ee0d6f0c54890bf3aef9065bbf6a8469b8a04a4 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 28 May 2026 12:32:04 +0530 Subject: [PATCH 29/65] refactor: extract tax cluster from AccountsController into services/taxes.py Moves set_taxes, is_pos_profile_changed, set_taxes_and_charges, append_taxes_from_master, append_taxes_from_item_tax_template, get_tax_row, set_other_charges, validate_enabled_taxes_and_charges, validate_tax_account_company, get_tax_map, get_amount_and_base_amount, get_tax_amounts, and make_discount_gl_entries into free functions in accounts/services/taxes.py. AccountsController retains thin shims. Removes now-unused parse_json import. --- erpnext/accounts/services/taxes.py | 231 ++++++++++++++++++++- erpnext/controllers/accounts_controller.py | 225 +++----------------- 2 files changed, 262 insertions(+), 194 deletions(-) diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py index 0dfb97d78d3..f03c8264477 100644 --- a/erpnext/accounts/services/taxes.py +++ b/erpnext/accounts/services/taxes.py @@ -7,8 +7,10 @@ import json import frappe from frappe import _, throw -from frappe.utils import cint, flt +from frappe.utils import cint, flt, parse_json +import erpnext +from erpnext.accounts.utils import get_account_currency from erpnext.stock.get_item_details import ( NOT_APPLICABLE_TAX, ItemDetailsCtx, @@ -285,3 +287,230 @@ def merge_taxes(source_doc, target_doc) -> None: ) target_doc._item_wise_tax_details = item_tax_details + + +def set_taxes(doc) -> None: + if not doc.meta.get_field("taxes"): + return + + tax_master_doctype = doc.meta.get_field("taxes_and_charges").options + + if (doc.is_new() or is_pos_profile_changed(doc)) and not doc.get("taxes"): + if doc.company and not doc.get("taxes_and_charges"): + doc.taxes_and_charges = frappe.db.get_value( + tax_master_doctype, {"is_default": 1, "company": doc.company} + ) + append_taxes_from_master(doc, tax_master_doctype) + + +def is_pos_profile_changed(doc) -> bool: + if ( + doc.doctype == "Sales Invoice" + and doc.is_pos + and doc.pos_profile != frappe.db.get_value("Sales Invoice", doc.name, "pos_profile") + ): + return True + + +def set_taxes_and_charges(doc) -> None: + if doc.doctype == "Material Request": + return + + if doc.get("taxes") or doc.get("is_pos"): + return + + if frappe.get_single_value("Accounts Settings", "add_taxes_from_taxes_and_charges_template") and hasattr( + doc, "taxes_and_charges" + ): + if tax_master_doctype := doc.meta.get_field("taxes_and_charges").options: + append_taxes_from_master(doc, tax_master_doctype) + + if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + append_taxes_from_item_tax_template(doc) + + +def append_taxes_from_master(doc, tax_master_doctype=None) -> None: + if doc.get("taxes_and_charges"): + if not tax_master_doctype: + tax_master_doctype = doc.meta.get_field("taxes_and_charges").options + doc.extend("taxes", get_taxes_and_charges(tax_master_doctype, doc.get("taxes_and_charges"))) + + +def append_taxes_from_item_tax_template(doc) -> None: + if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + return + + for row in doc.items: + item_tax_rate = row.get("item_tax_rate") + if not item_tax_rate: + continue + + if isinstance(item_tax_rate, str): + item_tax_rate = parse_json(item_tax_rate) + + for account_head, _rate in item_tax_rate.items(): + row = get_tax_row(doc, account_head) + + if not row: + doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": account_head, + "rate": 0, + "description": account_head, + "set_by_item_tax_template": 1, + "category": "Total", + "add_deduct_tax": "Add", + }, + ) + + +def get_tax_row(doc, account_head): + for row in doc.taxes: + if row.account_head == account_head: + return row + + +def set_other_charges(doc) -> None: + doc.set("taxes", []) + set_taxes(doc) + + +def validate_enabled_taxes_and_charges(doc) -> None: + taxes_and_charges_doctype = doc.meta.get_options("taxes_and_charges") + if doc.taxes_and_charges and frappe.get_cached_value( + taxes_and_charges_doctype, doc.taxes_and_charges, "disabled" + ): + frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, doc.taxes_and_charges)) + + +def validate_tax_account_company(doc) -> None: + for d in doc.get("taxes"): + if d.account_head: + tax_account_company = frappe.get_cached_value("Account", d.account_head, "company") + if tax_account_company != doc.company: + frappe.throw( + _("Row #{0}: Account {1} does not belong to company {2}").format( + d.idx, d.account_head, doc.company + ) + ) + + +def get_tax_map(doc) -> dict: + tax_map = {} + for tax in doc.get("taxes"): + tax_map.setdefault(tax.account_head, 0.0) + tax_map[tax.account_head] += tax.tax_amount + return tax_map + + +def get_amount_and_base_amount(doc, item, enable_discount_accounting): + amount = item.net_amount + base_amount = item.base_net_amount + + if enable_discount_accounting and doc.get("discount_amount") and doc.get("additional_discount_account"): + if not hasattr(doc, "__has_distributed_discount_set"): + doc.__has_distributed_discount_set = any(i.distributed_discount_amount for i in doc.get("items")) + + if not doc.__has_distributed_discount_set: + return item.amount, item.base_amount + + amount += item.distributed_discount_amount + base_amount += flt( + item.distributed_discount_amount * doc.get("conversion_rate"), + item.precision("distributed_discount_amount"), + ) + + return amount, base_amount + + +def get_tax_amounts(doc, tax, enable_discount_accounting): + amount = tax.tax_amount_after_discount_amount + base_amount = tax.base_tax_amount_after_discount_amount + + if ( + enable_discount_accounting + and doc.get("discount_amount") + and doc.get("additional_discount_account") + and doc.get("apply_discount_on") == "Grand Total" + ): + amount = tax.tax_amount + base_amount = tax.base_tax_amount + + return amount, base_amount + + +def make_discount_gl_entries(doc, gl_entries: list) -> None: + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + if enable_discount_accounting: + for item in doc.get("items"): + if item.get("discount_amount") and item.get("discount_account"): + discount_amount = item.discount_amount * item.qty + income_account = ( + item.income_account + if (not item.enable_deferred_revenue or doc.is_return) + else item.deferred_revenue_account + ) + + account_currency = get_account_currency(item.discount_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": item.discount_account, + "against": doc.customer, + "debit": flt( + discount_amount * doc.get("conversion_rate"), + item.precision("discount_amount"), + ), + "debit_in_transaction_currency": flt( + discount_amount, item.precision("discount_amount") + ), + "cost_center": item.cost_center, + "project": item.project, + }, + account_currency, + item=item, + ) + ) + + account_currency = get_account_currency(income_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": income_account, + "against": doc.customer, + "credit": flt( + discount_amount * doc.get("conversion_rate"), + item.precision("discount_amount"), + ), + "credit_in_transaction_currency": flt( + discount_amount, item.precision("discount_amount") + ), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + if ( + (enable_discount_accounting or doc.get("is_cash_or_non_trade_discount")) + and doc.get("additional_discount_account") + and doc.get("discount_amount") + ): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.additional_discount_account, + "against": doc.customer, + "debit": doc.base_discount_amount, + "cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company), + }, + item=doc, + ) + ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6723bb98bdd..84aa56d7a7a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -18,7 +18,6 @@ from frappe.utils import ( get_link_to_form, getdate, nowdate, - parse_json, today, ) @@ -1182,106 +1181,49 @@ class AccountsController(TransactionBase): ) def set_taxes(self): - if not self.meta.get_field("taxes"): - return + from erpnext.accounts.services.taxes import set_taxes - tax_master_doctype = self.meta.get_field("taxes_and_charges").options - - if (self.is_new() or self.is_pos_profile_changed()) and not self.get("taxes"): - if self.company and not self.get("taxes_and_charges"): - # get the default tax master - self.taxes_and_charges = frappe.db.get_value( - tax_master_doctype, {"is_default": 1, "company": self.company} - ) - - self.append_taxes_from_master(tax_master_doctype) + set_taxes(self) def is_pos_profile_changed(self): - if ( - self.doctype == "Sales Invoice" - and self.is_pos - and self.pos_profile != frappe.db.get_value("Sales Invoice", self.name, "pos_profile") - ): - return True + from erpnext.accounts.services.taxes import is_pos_profile_changed + + return is_pos_profile_changed(self) def set_taxes_and_charges(self): - if self.doctype == "Material Request": - # Material Request does not have taxes - return + from erpnext.accounts.services.taxes import set_taxes_and_charges - if self.get("taxes") or self.get("is_pos"): - return - - if frappe.get_single_value( - "Accounts Settings", "add_taxes_from_taxes_and_charges_template" - ) and hasattr(self, "taxes_and_charges"): - if tax_master_doctype := self.meta.get_field("taxes_and_charges").options: - self.append_taxes_from_master(tax_master_doctype) - - if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): - self.append_taxes_from_item_tax_template() + set_taxes_and_charges(self) def append_taxes_from_master(self, tax_master_doctype=None): - if self.get("taxes_and_charges"): - if not tax_master_doctype: - tax_master_doctype = self.meta.get_field("taxes_and_charges").options - self.extend("taxes", get_taxes_and_charges(tax_master_doctype, self.get("taxes_and_charges"))) + from erpnext.accounts.services.taxes import append_taxes_from_master + + append_taxes_from_master(self, tax_master_doctype) def append_taxes_from_item_tax_template(self): - if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): - return + from erpnext.accounts.services.taxes import append_taxes_from_item_tax_template - for row in self.items: - item_tax_rate = row.get("item_tax_rate") - if not item_tax_rate: - continue - - if isinstance(item_tax_rate, str): - item_tax_rate = parse_json(item_tax_rate) - - for account_head, _rate in item_tax_rate.items(): - row = self.get_tax_row(account_head) - - if not row: - self.append( - "taxes", - { - "charge_type": "On Net Total", - "account_head": account_head, - "rate": 0, - "description": account_head, - "set_by_item_tax_template": 1, - "category": "Total", - "add_deduct_tax": "Add", - }, - ) + append_taxes_from_item_tax_template(self) def get_tax_row(self, account_head): - for row in self.taxes: - if row.account_head == account_head: - return row + from erpnext.accounts.services.taxes import get_tax_row + + return get_tax_row(self, account_head) def set_other_charges(self): - self.set("taxes", []) - self.set_taxes() + from erpnext.accounts.services.taxes import set_other_charges + + set_other_charges(self) def validate_enabled_taxes_and_charges(self): - taxes_and_charges_doctype = self.meta.get_options("taxes_and_charges") - if self.taxes_and_charges and frappe.get_cached_value( - taxes_and_charges_doctype, self.taxes_and_charges, "disabled" - ): - frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, self.taxes_and_charges)) + from erpnext.accounts.services.taxes import validate_enabled_taxes_and_charges + + validate_enabled_taxes_and_charges(self) def validate_tax_account_company(self): - for d in self.get("taxes"): - if d.account_head: - tax_account_company = frappe.get_cached_value("Account", d.account_head, "company") - if tax_account_company != self.company: - frappe.throw( - _("Row #{0}: Account {1} does not belong to company {2}").format( - d.idx, d.account_head, self.company - ) - ) + from erpnext.accounts.services.taxes import validate_tax_account_company + + validate_tax_account_company(self) def get_gl_dict(self, args, account_currency=None, item=None): from erpnext.accounts.services.base_gl_composer import get_gl_dict @@ -1567,127 +1509,24 @@ class AccountsController(TransactionBase): frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po))) def get_tax_map(self): - tax_map = {} - for tax in self.get("taxes"): - tax_map.setdefault(tax.account_head, 0.0) - tax_map[tax.account_head] += tax.tax_amount + from erpnext.accounts.services.taxes import get_tax_map - return tax_map + return get_tax_map(self) def get_amount_and_base_amount(self, item, enable_discount_accounting): - amount = item.net_amount - base_amount = item.base_net_amount + from erpnext.accounts.services.taxes import get_amount_and_base_amount - if ( - enable_discount_accounting - and self.get("discount_amount") - and self.get("additional_discount_account") - ): - # cases where distributed_discount_amount is not patched - if not hasattr(self, "__has_distributed_discount_set"): - self.__has_distributed_discount_set = any( - i.distributed_discount_amount for i in self.get("items") - ) - - if not self.__has_distributed_discount_set: - return item.amount, item.base_amount - - amount += item.distributed_discount_amount - base_amount += flt( - item.distributed_discount_amount * self.get("conversion_rate"), - item.precision("distributed_discount_amount"), - ) - - return amount, base_amount + return get_amount_and_base_amount(self, item, enable_discount_accounting) def get_tax_amounts(self, tax, enable_discount_accounting): - amount = tax.tax_amount_after_discount_amount - base_amount = tax.base_tax_amount_after_discount_amount + from erpnext.accounts.services.taxes import get_tax_amounts - if ( - enable_discount_accounting - and self.get("discount_amount") - and self.get("additional_discount_account") - and self.get("apply_discount_on") == "Grand Total" - ): - amount = tax.tax_amount - base_amount = tax.base_tax_amount - - return amount, base_amount + return get_tax_amounts(self, tax, enable_discount_accounting) def make_discount_gl_entries(self, gl_entries): - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) + from erpnext.accounts.services.taxes import make_discount_gl_entries - if enable_discount_accounting: - for item in self.get("items"): - if item.get("discount_amount") and item.get("discount_account"): - discount_amount = item.discount_amount * item.qty - income_account = ( - item.income_account - if (not item.enable_deferred_revenue or self.is_return) - else item.deferred_revenue_account - ) - - account_currency = get_account_currency(item.discount_account) - gl_entries.append( - self.get_gl_dict( - { - "account": item.discount_account, - "against": self.customer, - "debit": flt( - discount_amount * self.get("conversion_rate"), - item.precision("discount_amount"), - ), - "debit_in_transaction_currency": flt( - discount_amount, item.precision("discount_amount") - ), - "cost_center": item.cost_center, - "project": item.project, - }, - account_currency, - item=item, - ) - ) - - account_currency = get_account_currency(income_account) - gl_entries.append( - self.get_gl_dict( - { - "account": income_account, - "against": self.customer, - "credit": flt( - discount_amount * self.get("conversion_rate"), - item.precision("discount_amount"), - ), - "credit_in_transaction_currency": flt( - discount_amount, item.precision("discount_amount") - ), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - if ( - (enable_discount_accounting or self.get("is_cash_or_non_trade_discount")) - and self.get("additional_discount_account") - and self.get("discount_amount") - ): - gl_entries.append( - self.get_gl_dict( - { - "account": self.additional_discount_account, - "against": self.customer, - "debit": self.base_discount_amount, - "cost_center": self.cost_center or erpnext.get_default_cost_center(self.company), - }, - item=self, - ) - ) + make_discount_gl_entries(self, gl_entries) def validate_multiple_billing(self, ref_dt: str, item_ref_dn: str, based_on: str) -> None: from erpnext.accounts.services.billing_validation import validate_multiple_billing From 8aaa3a72effbd5e3a156b719a4eddd6ab4b35f86 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 28 May 2026 16:53:03 +0530 Subject: [PATCH 30/65] refactor: convert tax cluster to TaxService class in taxes.py Replaces the shim+free-function pattern with a TaxService class so callers like TaxService(self).set_taxes() make the source location explicit. Class lives in taxes.py above the existing free functions. Deletes the intermediate tax_service.py. Updates AccountsController, sales_invoice, pos_invoice, subscription, and both GL composers to call TaxService directly. --- .../doctype/pos_invoice/pos_invoice.py | 4 +- .../purchase_invoice/services/gl_composer.py | 7 +- .../doctype/sales_invoice/sales_invoice.py | 4 +- .../sales_invoice/services/gl_composer.py | 20 +- .../doctype/subscription/subscription.py | 4 +- erpnext/accounts/services/taxes.py | 462 +++++++++--------- erpnext/controllers/accounts_controller.py | 76 +-- 7 files changed, 265 insertions(+), 312 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 2925def8408..5bc32ff68df 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -745,7 +745,9 @@ class POSInvoice(SalesInvoice): # fetch charges if self.taxes_and_charges and not len(self.get("taxes")): - self.set_taxes() + from erpnext.accounts.services.taxes import TaxService + + TaxService(self).set_taxes() if not self.account_for_change_amount: self.account_for_change_amount = frappe.get_cached_value( diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index 8329cfac53d..d295814ff9a 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -8,6 +8,7 @@ from frappe.utils import cint, flt, get_link_to_form import erpnext from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.services.taxes import TaxService from erpnext.accounts.utils import get_account_currency @@ -102,6 +103,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): ) doc = self.doc + tax_service = TaxService(doc) stock_items = doc.get_stock_items() if doc.update_stock and doc.auto_accounting_for_stock: inventory_account_map = doc.get_inventory_account_map() @@ -292,7 +294,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): else item.deferred_expense_account ) account_currency = get_account_currency(expense_account) - amount, base_amount = doc.get_amount_and_base_amount(item, None) + amount, base_amount = tax_service.get_amount_and_base_amount(item, None) if provisional_accounting_for_non_stock_items: self.make_provisional_gl_entry(gl_entries, item) @@ -552,10 +554,11 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): def make_tax_gl_entries(self, gl_entries): doc = self.doc + tax_service = TaxService(doc) valuation_tax = {} for tax in doc.get("taxes"): - amount, base_amount = doc.get_tax_amounts(tax, None) + amount, base_amount = tax_service.get_tax_amounts(tax, None) if tax.category in ("Total", "Valuation and Total") and flt(base_amount): account_currency = get_account_currency(tax.account_head) dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit" diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 58f612a5d64..488f5bc9a23 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -988,7 +988,9 @@ class SalesInvoice(SellingController): # fetch charges if self.taxes_and_charges and not len(self.get("taxes")): - self.set_taxes() + from erpnext.accounts.services.taxes import TaxService + + TaxService(self).set_taxes() return pos diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index 24da512a732..afde7472717 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -8,6 +8,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form import erpnext from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.services.taxes import TaxService from erpnext.accounts.utils import get_account_currency from erpnext.assets.doctype.asset.depreciation import ( get_gl_entries_on_asset_disposal, @@ -16,19 +17,14 @@ from erpnext.assets.doctype.asset.depreciation import ( class SalesInvoiceGLComposer(BaseGLComposer): - """Assembles the GL entries for a Sales Invoice. - - The voucher-specific row builders live here and operate on ``self.doc``. - Shared helpers (get_gl_dict, make_discount_gl_entries, make_precision_loss_gl_entry, - set_transaction_currency_and_rate_in_gl_map, get_tax_amounts, get_amount_and_base_amount) - remain on the document for now and are invoked via ``self.doc``. - """ + """Assembles the GL entries for a Sales Invoice.""" def compose(self, inventory_account_map=None): from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_regional_gl_entries from erpnext.accounts.general_ledger import merge_similar_entries doc = self.doc + tax_service = TaxService(doc) gl_entries = [] self.make_customer_gl_entry(gl_entries) @@ -44,7 +40,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): self.stock_delivered_but_not_billed_gl_entries(gl_entries) doc.make_precision_loss_gl_entry(gl_entries) - doc.make_discount_gl_entries(gl_entries) + tax_service.make_discount_gl_entries(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, doc) @@ -181,12 +177,13 @@ class SalesInvoiceGLComposer(BaseGLComposer): def make_tax_gl_entries(self, gl_entries): doc = self.doc + tax_service = TaxService(doc) enable_discount_accounting = cint( frappe.get_single_value("Selling Settings", "enable_discount_accounting") ) for tax in doc.get("taxes"): - amount, base_amount = doc.get_tax_amounts(tax, enable_discount_accounting) + amount, base_amount = tax_service.get_tax_amounts(tax, enable_discount_accounting) if flt(tax.base_tax_amount_after_discount_amount): account_currency = get_account_currency(tax.account_head) @@ -234,6 +231,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice doc = self.doc + tax_service = TaxService(doc) # income account gl entries enable_discount_accounting = cint( frappe.get_single_value("Selling Settings", "enable_discount_accounting") @@ -258,7 +256,9 @@ class SalesInvoiceGLComposer(BaseGLComposer): else item.deferred_revenue_account ) - amount, base_amount = doc.get_amount_and_base_amount(item, enable_discount_accounting) + amount, base_amount = tax_service.get_amount_and_base_amount( + item, enable_discount_accounting + ) account_currency = get_account_currency(income_account) gl_entries.append( diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 642f918c3b1..8620f6b2da3 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -446,8 +446,10 @@ class Subscription(Document): tax_template = self.purchase_tax_template if tax_template: + from erpnext.accounts.services.taxes import TaxService + invoice.taxes_and_charges = tax_template - invoice.set_taxes() + TaxService(invoice).set_taxes() # Due date if self.days_until_due: diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py index f03c8264477..19e9843248b 100644 --- a/erpnext/accounts/services/taxes.py +++ b/erpnext/accounts/services/taxes.py @@ -1,7 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -"""Tax template and validation helpers shared across buying and selling controllers.""" +"""Tax helpers: TaxService class for doc-mutating operations, free functions for stateless utilities.""" import json @@ -20,6 +20,239 @@ from erpnext.stock.get_item_details import ( ) +class TaxService: + def __init__(self, doc): + self.doc = doc + + def set_taxes(self) -> None: + doc = self.doc + if not doc.meta.get_field("taxes"): + return + + tax_master_doctype = doc.meta.get_field("taxes_and_charges").options + + if (doc.is_new() or self.is_pos_profile_changed()) and not doc.get("taxes"): + if doc.company and not doc.get("taxes_and_charges"): + doc.taxes_and_charges = frappe.db.get_value( + tax_master_doctype, {"is_default": 1, "company": doc.company} + ) + self.append_taxes_from_master(tax_master_doctype) + + def is_pos_profile_changed(self) -> bool: + doc = self.doc + if ( + doc.doctype == "Sales Invoice" + and doc.is_pos + and doc.pos_profile != frappe.db.get_value("Sales Invoice", doc.name, "pos_profile") + ): + return True + + def set_taxes_and_charges(self) -> None: + doc = self.doc + if doc.doctype == "Material Request": + return + + if doc.get("taxes") or doc.get("is_pos"): + return + + if frappe.get_single_value( + "Accounts Settings", "add_taxes_from_taxes_and_charges_template" + ) and hasattr(doc, "taxes_and_charges"): + if tax_master_doctype := doc.meta.get_field("taxes_and_charges").options: + self.append_taxes_from_master(tax_master_doctype) + + if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + self.append_taxes_from_item_tax_template() + + def append_taxes_from_master(self, tax_master_doctype=None) -> None: + doc = self.doc + if doc.get("taxes_and_charges"): + if not tax_master_doctype: + tax_master_doctype = doc.meta.get_field("taxes_and_charges").options + doc.extend("taxes", get_taxes_and_charges(tax_master_doctype, doc.get("taxes_and_charges"))) + + def append_taxes_from_item_tax_template(self) -> None: + doc = self.doc + if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + return + + for row in doc.items: + item_tax_rate = row.get("item_tax_rate") + if not item_tax_rate: + continue + + if isinstance(item_tax_rate, str): + item_tax_rate = parse_json(item_tax_rate) + + for account_head, _rate in item_tax_rate.items(): + if not self.get_tax_row(account_head): + doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": account_head, + "rate": 0, + "description": account_head, + "set_by_item_tax_template": 1, + "category": "Total", + "add_deduct_tax": "Add", + }, + ) + + def get_tax_row(self, account_head): + for row in self.doc.taxes: + if row.account_head == account_head: + return row + + def set_other_charges(self) -> None: + self.doc.set("taxes", []) + self.set_taxes() + + def validate_enabled_taxes_and_charges(self) -> None: + doc = self.doc + taxes_and_charges_doctype = doc.meta.get_options("taxes_and_charges") + if doc.taxes_and_charges and frappe.get_cached_value( + taxes_and_charges_doctype, doc.taxes_and_charges, "disabled" + ): + frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, doc.taxes_and_charges)) + + def validate_tax_account_company(self) -> None: + doc = self.doc + for d in doc.get("taxes"): + if d.account_head: + tax_account_company = frappe.get_cached_value("Account", d.account_head, "company") + if tax_account_company != doc.company: + frappe.throw( + _("Row #{0}: Account {1} does not belong to company {2}").format( + d.idx, d.account_head, doc.company + ) + ) + + def get_tax_map(self) -> dict: + tax_map = {} + for tax in self.doc.get("taxes"): + tax_map.setdefault(tax.account_head, 0.0) + tax_map[tax.account_head] += tax.tax_amount + return tax_map + + def get_amount_and_base_amount(self, item, enable_discount_accounting): + doc = self.doc + amount = item.net_amount + base_amount = item.base_net_amount + + if ( + enable_discount_accounting + and doc.get("discount_amount") + and doc.get("additional_discount_account") + ): + if not hasattr(doc, "__has_distributed_discount_set"): + doc.__has_distributed_discount_set = any( + i.distributed_discount_amount for i in doc.get("items") + ) + + if not doc.__has_distributed_discount_set: + return item.amount, item.base_amount + + amount += item.distributed_discount_amount + base_amount += flt( + item.distributed_discount_amount * doc.get("conversion_rate"), + item.precision("distributed_discount_amount"), + ) + + return amount, base_amount + + def get_tax_amounts(self, tax, enable_discount_accounting): + doc = self.doc + amount = tax.tax_amount_after_discount_amount + base_amount = tax.base_tax_amount_after_discount_amount + + if ( + enable_discount_accounting + and doc.get("discount_amount") + and doc.get("additional_discount_account") + and doc.get("apply_discount_on") == "Grand Total" + ): + amount = tax.tax_amount + base_amount = tax.base_tax_amount + + return amount, base_amount + + def make_discount_gl_entries(self, gl_entries: list) -> None: + doc = self.doc + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + if enable_discount_accounting: + for item in doc.get("items"): + if item.get("discount_amount") and item.get("discount_account"): + discount_amount = item.discount_amount * item.qty + income_account = ( + item.income_account + if (not item.enable_deferred_revenue or doc.is_return) + else item.deferred_revenue_account + ) + + account_currency = get_account_currency(item.discount_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": item.discount_account, + "against": doc.customer, + "debit": flt( + discount_amount * doc.get("conversion_rate"), + item.precision("discount_amount"), + ), + "debit_in_transaction_currency": flt( + discount_amount, item.precision("discount_amount") + ), + "cost_center": item.cost_center, + "project": item.project, + }, + account_currency, + item=item, + ) + ) + + account_currency = get_account_currency(income_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": income_account, + "against": doc.customer, + "credit": flt( + discount_amount * doc.get("conversion_rate"), + item.precision("discount_amount"), + ), + "credit_in_transaction_currency": flt( + discount_amount, item.precision("discount_amount") + ), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + if ( + (enable_discount_accounting or doc.get("is_cash_or_non_trade_discount")) + and doc.get("additional_discount_account") + and doc.get("discount_amount") + ): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.additional_discount_account, + "against": doc.customer, + "debit": doc.base_discount_amount, + "cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company), + }, + item=doc, + ) + ) + + def get_tax_rate(account_head: str) -> dict: return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) @@ -287,230 +520,3 @@ def merge_taxes(source_doc, target_doc) -> None: ) target_doc._item_wise_tax_details = item_tax_details - - -def set_taxes(doc) -> None: - if not doc.meta.get_field("taxes"): - return - - tax_master_doctype = doc.meta.get_field("taxes_and_charges").options - - if (doc.is_new() or is_pos_profile_changed(doc)) and not doc.get("taxes"): - if doc.company and not doc.get("taxes_and_charges"): - doc.taxes_and_charges = frappe.db.get_value( - tax_master_doctype, {"is_default": 1, "company": doc.company} - ) - append_taxes_from_master(doc, tax_master_doctype) - - -def is_pos_profile_changed(doc) -> bool: - if ( - doc.doctype == "Sales Invoice" - and doc.is_pos - and doc.pos_profile != frappe.db.get_value("Sales Invoice", doc.name, "pos_profile") - ): - return True - - -def set_taxes_and_charges(doc) -> None: - if doc.doctype == "Material Request": - return - - if doc.get("taxes") or doc.get("is_pos"): - return - - if frappe.get_single_value("Accounts Settings", "add_taxes_from_taxes_and_charges_template") and hasattr( - doc, "taxes_and_charges" - ): - if tax_master_doctype := doc.meta.get_field("taxes_and_charges").options: - append_taxes_from_master(doc, tax_master_doctype) - - if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): - append_taxes_from_item_tax_template(doc) - - -def append_taxes_from_master(doc, tax_master_doctype=None) -> None: - if doc.get("taxes_and_charges"): - if not tax_master_doctype: - tax_master_doctype = doc.meta.get_field("taxes_and_charges").options - doc.extend("taxes", get_taxes_and_charges(tax_master_doctype, doc.get("taxes_and_charges"))) - - -def append_taxes_from_item_tax_template(doc) -> None: - if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): - return - - for row in doc.items: - item_tax_rate = row.get("item_tax_rate") - if not item_tax_rate: - continue - - if isinstance(item_tax_rate, str): - item_tax_rate = parse_json(item_tax_rate) - - for account_head, _rate in item_tax_rate.items(): - row = get_tax_row(doc, account_head) - - if not row: - doc.append( - "taxes", - { - "charge_type": "On Net Total", - "account_head": account_head, - "rate": 0, - "description": account_head, - "set_by_item_tax_template": 1, - "category": "Total", - "add_deduct_tax": "Add", - }, - ) - - -def get_tax_row(doc, account_head): - for row in doc.taxes: - if row.account_head == account_head: - return row - - -def set_other_charges(doc) -> None: - doc.set("taxes", []) - set_taxes(doc) - - -def validate_enabled_taxes_and_charges(doc) -> None: - taxes_and_charges_doctype = doc.meta.get_options("taxes_and_charges") - if doc.taxes_and_charges and frappe.get_cached_value( - taxes_and_charges_doctype, doc.taxes_and_charges, "disabled" - ): - frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, doc.taxes_and_charges)) - - -def validate_tax_account_company(doc) -> None: - for d in doc.get("taxes"): - if d.account_head: - tax_account_company = frappe.get_cached_value("Account", d.account_head, "company") - if tax_account_company != doc.company: - frappe.throw( - _("Row #{0}: Account {1} does not belong to company {2}").format( - d.idx, d.account_head, doc.company - ) - ) - - -def get_tax_map(doc) -> dict: - tax_map = {} - for tax in doc.get("taxes"): - tax_map.setdefault(tax.account_head, 0.0) - tax_map[tax.account_head] += tax.tax_amount - return tax_map - - -def get_amount_and_base_amount(doc, item, enable_discount_accounting): - amount = item.net_amount - base_amount = item.base_net_amount - - if enable_discount_accounting and doc.get("discount_amount") and doc.get("additional_discount_account"): - if not hasattr(doc, "__has_distributed_discount_set"): - doc.__has_distributed_discount_set = any(i.distributed_discount_amount for i in doc.get("items")) - - if not doc.__has_distributed_discount_set: - return item.amount, item.base_amount - - amount += item.distributed_discount_amount - base_amount += flt( - item.distributed_discount_amount * doc.get("conversion_rate"), - item.precision("distributed_discount_amount"), - ) - - return amount, base_amount - - -def get_tax_amounts(doc, tax, enable_discount_accounting): - amount = tax.tax_amount_after_discount_amount - base_amount = tax.base_tax_amount_after_discount_amount - - if ( - enable_discount_accounting - and doc.get("discount_amount") - and doc.get("additional_discount_account") - and doc.get("apply_discount_on") == "Grand Total" - ): - amount = tax.tax_amount - base_amount = tax.base_tax_amount - - return amount, base_amount - - -def make_discount_gl_entries(doc, gl_entries: list) -> None: - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) - - if enable_discount_accounting: - for item in doc.get("items"): - if item.get("discount_amount") and item.get("discount_account"): - discount_amount = item.discount_amount * item.qty - income_account = ( - item.income_account - if (not item.enable_deferred_revenue or doc.is_return) - else item.deferred_revenue_account - ) - - account_currency = get_account_currency(item.discount_account) - gl_entries.append( - doc.get_gl_dict( - { - "account": item.discount_account, - "against": doc.customer, - "debit": flt( - discount_amount * doc.get("conversion_rate"), - item.precision("discount_amount"), - ), - "debit_in_transaction_currency": flt( - discount_amount, item.precision("discount_amount") - ), - "cost_center": item.cost_center, - "project": item.project, - }, - account_currency, - item=item, - ) - ) - - account_currency = get_account_currency(income_account) - gl_entries.append( - doc.get_gl_dict( - { - "account": income_account, - "against": doc.customer, - "credit": flt( - discount_amount * doc.get("conversion_rate"), - item.precision("discount_amount"), - ), - "credit_in_transaction_currency": flt( - discount_amount, item.precision("discount_amount") - ), - "cost_center": item.cost_center, - "project": item.project or doc.project, - }, - account_currency, - item=item, - ) - ) - - if ( - (enable_discount_accounting or doc.get("is_cash_or_non_trade_discount")) - and doc.get("additional_discount_account") - and doc.get("discount_amount") - ): - gl_entries.append( - doc.get_gl_dict( - { - "account": doc.additional_discount_account, - "against": doc.customer, - "debit": doc.base_discount_amount, - "cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company), - }, - item=doc, - ) - ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 84aa56d7a7a..938d07c31c8 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -242,11 +242,14 @@ class AccountsController(TransactionBase): # Need to set taxes based on taxes_and_charges template # before calculating taxes and totals - if self.meta.get_field("taxes_and_charges"): - self.validate_enabled_taxes_and_charges() - self.validate_tax_account_company() + from erpnext.accounts.services.taxes import TaxService - self.set_taxes_and_charges() + tax_service = TaxService(self) + if self.meta.get_field("taxes_and_charges"): + tax_service.validate_enabled_taxes_and_charges() + tax_service.validate_tax_account_company() + + tax_service.set_taxes_and_charges() if self.meta.get_field("currency"): self.calculate_taxes_and_totals() @@ -1180,51 +1183,6 @@ class AccountsController(TransactionBase): }, ) - def set_taxes(self): - from erpnext.accounts.services.taxes import set_taxes - - set_taxes(self) - - def is_pos_profile_changed(self): - from erpnext.accounts.services.taxes import is_pos_profile_changed - - return is_pos_profile_changed(self) - - def set_taxes_and_charges(self): - from erpnext.accounts.services.taxes import set_taxes_and_charges - - set_taxes_and_charges(self) - - def append_taxes_from_master(self, tax_master_doctype=None): - from erpnext.accounts.services.taxes import append_taxes_from_master - - append_taxes_from_master(self, tax_master_doctype) - - def append_taxes_from_item_tax_template(self): - from erpnext.accounts.services.taxes import append_taxes_from_item_tax_template - - append_taxes_from_item_tax_template(self) - - def get_tax_row(self, account_head): - from erpnext.accounts.services.taxes import get_tax_row - - return get_tax_row(self, account_head) - - def set_other_charges(self): - from erpnext.accounts.services.taxes import set_other_charges - - set_other_charges(self) - - def validate_enabled_taxes_and_charges(self): - from erpnext.accounts.services.taxes import validate_enabled_taxes_and_charges - - validate_enabled_taxes_and_charges(self) - - def validate_tax_account_company(self): - from erpnext.accounts.services.taxes import validate_tax_account_company - - validate_tax_account_company(self) - def get_gl_dict(self, args, account_currency=None, item=None): from erpnext.accounts.services.base_gl_composer import get_gl_dict @@ -1508,26 +1466,6 @@ class AccountsController(TransactionBase): frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po))) - def get_tax_map(self): - from erpnext.accounts.services.taxes import get_tax_map - - return get_tax_map(self) - - def get_amount_and_base_amount(self, item, enable_discount_accounting): - from erpnext.accounts.services.taxes import get_amount_and_base_amount - - return get_amount_and_base_amount(self, item, enable_discount_accounting) - - def get_tax_amounts(self, tax, enable_discount_accounting): - from erpnext.accounts.services.taxes import get_tax_amounts - - return get_tax_amounts(self, tax, enable_discount_accounting) - - def make_discount_gl_entries(self, gl_entries): - from erpnext.accounts.services.taxes import make_discount_gl_entries - - make_discount_gl_entries(self, gl_entries) - def validate_multiple_billing(self, ref_dt: str, item_ref_dn: str, based_on: str) -> None: from erpnext.accounts.services.billing_validation import validate_multiple_billing From 6c1ac51d7abeeaf0f9a38879f28bc0a1260e510c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 28 May 2026 17:13:23 +0530 Subject: [PATCH 31/65] refactor: convert payment schedule and billing validation to service objects Introduce PaymentScheduleService and BillingValidationService classes so call sites read PaymentScheduleService(doc).set_payment_schedule() instead of the opaque self.set_payment_schedule() shim. Removes 15 shim methods from AccountsController and updates all 11 call sites across the codebase. --- .../purchase_invoice/purchase_invoice.py | 4 +- .../doctype/sales_invoice/sales_invoice.py | 4 +- .../accounts/services/billing_validation.py | 252 ++++----- erpnext/accounts/services/payment_schedule.py | 521 +++++++++--------- .../doctype/purchase_order/purchase_order.py | 4 +- erpnext/controllers/accounts_controller.py | 116 +--- .../regional/united_arab_emirates/utils.py | 4 +- .../selling/doctype/quotation/quotation.py | 4 +- .../doctype/sales_order/sales_order.py | 4 +- .../doctype/delivery_note/delivery_note.py | 9 +- .../purchase_receipt/purchase_receipt.py | 4 +- 11 files changed, 443 insertions(+), 483 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 6c4910269f2..40a8ea7dba5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -290,7 +290,9 @@ class PurchaseInvoice(BuyingController): self.validate_expense_account() self.set_against_expense_account() self.validate_write_off_account() - self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") + from erpnext.accounts.services.billing_validation import BillingValidationService + + BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") self.set_status() self.validate_purchase_receipt_if_update_stock() validate_inter_company_party( diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 488f5bc9a23..2950a182e1d 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -362,7 +362,9 @@ class SalesInvoice(SellingController): if not self.is_return: self.validate_time_sheets_are_submitted() - self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") + from erpnext.accounts.services.billing_validation import BillingValidationService + + BillingValidationService(self).validate_multiple_billing("Delivery Note", "dn_detail", "amount") if self.is_return and self.return_against: for row in self.timesheets: diff --git a/erpnext/accounts/services/billing_validation.py b/erpnext/accounts/services/billing_validation.py index a40a885f283..05a69084344 100644 --- a/erpnext/accounts/services/billing_validation.py +++ b/erpnext/accounts/services/billing_validation.py @@ -9,139 +9,143 @@ from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, fmt_money -def validate_multiple_billing(doc, ref_dt: str, item_ref_dn: str, based_on: str) -> None: - from erpnext.controllers.status_updater import get_allowance_for +class BillingValidationService: + def __init__(self, doc): + self.doc = doc - ref_wise_billed_amount = get_reference_wise_billed_amt(doc, ref_dt, item_ref_dn, based_on) - if not ref_wise_billed_amount: - return + def validate_multiple_billing(self, ref_dt: str, item_ref_dn: str, based_on: str) -> None: + from erpnext.controllers.status_updater import get_allowance_for - total_overbilled_amt = 0.0 - overbilled_items = [] - precision = doc.precision(based_on, "items") - precision_allowance = 1 / (10**precision) + ref_wise_billed_amount = self.get_reference_wise_billed_amt(ref_dt, item_ref_dn, based_on) + if not ref_wise_billed_amount: + return - role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") - is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles() + total_overbilled_amt = 0.0 + overbilled_items = [] + precision = self.doc.precision(based_on, "items") + precision_allowance = 1 / (10**precision) - for row in ref_wise_billed_amount.values(): - total_billed_amt = row.billed_amt - allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0] - max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100) + role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") + is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles() - if total_billed_amt < 0 and max_allowed_amt < 0: - total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt) + for row in ref_wise_billed_amount.values(): + total_billed_amt = row.billed_amt + allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0] + max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100) - overbill_amt = total_billed_amt - max_allowed_amt - row["max_allowed_amt"] = max_allowed_amt - total_overbilled_amt += overbill_amt + if total_billed_amt < 0 and max_allowed_amt < 0: + total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt) - if overbill_amt > precision_allowance and not is_overbilling_allowed: - if doc.doctype != "Purchase Invoice" or not cint( - frappe.db.get_single_value( - "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" - ) - ): - overbilled_items.append(row) + overbill_amt = total_billed_amt - max_allowed_amt + row["max_allowed_amt"] = max_allowed_amt + total_overbilled_amt += overbill_amt - if overbilled_items: - throw_overbill_exception(doc, overbilled_items, precision) + if overbill_amt > precision_allowance and not is_overbilling_allowed: + if self.doc.doctype != "Purchase Invoice" or not cint( + frappe.db.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + ): + overbilled_items.append(row) - if is_overbilling_allowed and total_overbilled_amt > 0.1: - frappe.msgprint( - _("Overbilling of {} ignored because you have {} role.").format( - total_overbilled_amt, role_allowed_to_overbill - ), - indicator="orange", - alert=True, - ) + if overbilled_items: + self.throw_overbill_exception(overbilled_items, precision) - -def get_reference_wise_billed_amt(doc, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None: - """Return sum of billed amounts per reference row, including previously submitted invoices.""" - reference_names = [d.get(item_ref_dn) for d in doc.items if d.get(item_ref_dn)] - if not reference_names: - return - - precision = doc.precision(based_on, "items") - reference_details = get_billing_reference_details(doc, reference_names, ref_dt + " Item", based_on) - already_billed = get_already_billed_amount(doc, reference_names, item_ref_dn, based_on) - - ref_wise_billed_amount = {} - for item in doc.items: - key = item.get(item_ref_dn) - if not key: - continue - - ref_amt = flt(reference_details.get(key), precision) - current_amount = flt(item.get(based_on), precision) - - if not ref_amt: - if current_amount: - frappe.msgprint( - _("System will not check over billing since amount for Item {0} in {1} is zero").format( - item.item_code, ref_dt - ), - title=_("Warning"), - indicator="orange", - ) - continue - - ref_wise_billed_amount.setdefault( - key, - frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]), - ) - ref_wise_billed_amount[key]["rows"].append(item.idx) - ref_wise_billed_amount[key]["ref_amt"] = ref_amt - ref_wise_billed_amount[key]["billed_amt"] += current_amount - if key in already_billed: - ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision) - - return ref_wise_billed_amount - - -def get_billing_reference_details( - doc, reference_names: list, reference_doctype: str, based_on: str -) -> frappe._dict: - return frappe._dict( - frappe.get_all( - reference_doctype, - filters={"name": ("in", reference_names)}, - fields=["name", based_on], - as_list=1, - ) - ) - - -def get_already_billed_amount(doc, reference_names: list, item_ref_dn: str, based_on: str) -> frappe._dict: - item_doctype = frappe.qb.DocType(doc.items[0].doctype) - based_on_field = frappe.qb.Field(based_on) - join_field = frappe.qb.Field(item_ref_dn) - - return frappe._dict( - ( - frappe.qb.from_(item_doctype) - .select(join_field, Sum(based_on_field)) - .where(join_field.isin(reference_names)) - .where((item_doctype.docstatus == 1) & (item_doctype.parent != doc.name)) - .groupby(join_field) - ).run() - ) - - -def throw_overbill_exception(doc, overbilled_items: list, precision: int) -> None: - message = ( - _("

Cannot overbill for the following Items:

") - + "
    " - + "".join( - _("
  • Item {0} in row(s) {1} billed more than {2}
  • ").format( - frappe.bold(item.item_code), - ", ".join(str(x) for x in item.rows), - frappe.bold(fmt_money(item.max_allowed_amt, precision=precision, currency=doc.currency)), + if is_overbilling_allowed and total_overbilled_amt > 0.1: + frappe.msgprint( + _("Overbilling of {} ignored because you have {} role.").format( + total_overbilled_amt, role_allowed_to_overbill + ), + indicator="orange", + alert=True, + ) + + def get_reference_wise_billed_amt(self, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None: + """Return sum of billed amounts per reference row, including previously submitted invoices.""" + reference_names = [d.get(item_ref_dn) for d in self.doc.items if d.get(item_ref_dn)] + if not reference_names: + return + + precision = self.doc.precision(based_on, "items") + reference_details = self.get_billing_reference_details(reference_names, ref_dt + " Item", based_on) + already_billed = self.get_already_billed_amount(reference_names, item_ref_dn, based_on) + + ref_wise_billed_amount = {} + for item in self.doc.items: + key = item.get(item_ref_dn) + if not key: + continue + + ref_amt = flt(reference_details.get(key), precision) + current_amount = flt(item.get(based_on), precision) + + if not ref_amt: + if current_amount: + frappe.msgprint( + _( + "System will not check over billing since amount for Item {0} in {1} is zero" + ).format(item.item_code, ref_dt), + title=_("Warning"), + indicator="orange", + ) + continue + + ref_wise_billed_amount.setdefault( + key, + frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]), + ) + ref_wise_billed_amount[key]["rows"].append(item.idx) + ref_wise_billed_amount[key]["ref_amt"] = ref_amt + ref_wise_billed_amount[key]["billed_amt"] += current_amount + if key in already_billed: + ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision) + + return ref_wise_billed_amount + + def get_billing_reference_details( + self, reference_names: list, reference_doctype: str, based_on: str + ) -> frappe._dict: + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", based_on], + as_list=1, ) - for item in overbilled_items ) - + "
" - ) - message += _("

To allow over-billing, please set allowance in Accounts Settings.

") - frappe.throw(_(message)) + + def get_already_billed_amount( + self, reference_names: list, item_ref_dn: str, based_on: str + ) -> frappe._dict: + item_doctype = frappe.qb.DocType(self.doc.items[0].doctype) + based_on_field = frappe.qb.Field(based_on) + join_field = frappe.qb.Field(item_ref_dn) + + return frappe._dict( + ( + frappe.qb.from_(item_doctype) + .select(join_field, Sum(based_on_field)) + .where(join_field.isin(reference_names)) + .where((item_doctype.docstatus == 1) & (item_doctype.parent != self.doc.name)) + .groupby(join_field) + ).run() + ) + + def throw_overbill_exception(self, overbilled_items: list, precision: int) -> None: + message = ( + _("

Cannot overbill for the following Items:

") + + "
    " + + "".join( + _("
  • Item {0} in row(s) {1} billed more than {2}
  • ").format( + frappe.bold(item.item_code), + ", ".join(str(x) for x in item.rows), + frappe.bold( + fmt_money(item.max_allowed_amt, precision=precision, currency=self.doc.currency) + ), + ) + for item in overbilled_items + ) + + "
" + ) + message += _("

To allow over-billing, please set allowance in Accounts Settings.

") + frappe.throw(_(message)) diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py index bc593a94c7b..96802cd56cf 100644 --- a/erpnext/accounts/services/payment_schedule.py +++ b/erpnext/accounts/services/payment_schedule.py @@ -10,256 +10,37 @@ from frappe.utils import DateTimeLikeObject, add_days, add_months, cint, flt, ge from erpnext.accounts.party import get_party_account_currency -def set_payment_schedule(doc) -> None: - if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": - doc.payment_terms_template = "" - return +class PaymentScheduleService: + def __init__(self, doc): + self.doc = doc - party_account_currency = doc.get("party_account_currency") - if not party_account_currency: - party_type, party = doc.get_party() - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, doc.company) + def set_payment_schedule(self) -> None: + doc = self.doc + if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": + doc.payment_terms_template = "" + return - posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") - due_date = doc.get("due_date") or posting_date + party_account_currency = doc.get("party_account_currency") + if not party_account_currency: + party_type, party = doc.get_party() + if party_type and party: + party_account_currency = get_party_account_currency(party_type, party, doc.company) - base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) - grand_total = flt(doc.get("rounded_total") or doc.grand_total) - automatically_fetch_payment_terms = 0 - - if doc.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): - po_or_so, doctype, fieldname = get_order_details(doc) - automatically_fetch_payment_terms = cint( - frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") - ) - if doc.doctype != "Sales Order": - base_grand_total = base_grand_total - flt(doc.base_write_off_amount) - grand_total = grand_total - flt(doc.write_off_amount) - - if doc.get("total_advance"): - if party_account_currency == doc.company_currency: - base_grand_total -= doc.get("total_advance") - grand_total = flt(base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total")) - else: - grand_total -= doc.get("total_advance") - base_grand_total = flt( - grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total") - ) - - if not doc.get("payment_schedule"): - if ( - doc.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] - and automatically_fetch_payment_terms - and linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) - ): - fetch_payment_terms_from_order( - doc, po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - if doc.get("payment_terms_template"): - doc.ignore_default_payment_terms_template = 1 - elif doc.get("payment_terms_template"): - data = get_payment_terms(doc.payment_terms_template, posting_date, grand_total, base_grand_total) - for item in data: - doc.append("payment_schedule", item) - elif doc.doctype not in ["Purchase Receipt"]: - doc.append( - "payment_schedule", - dict( - due_date=due_date, - invoice_portion=100, - payment_amount=grand_total, - base_payment_amount=base_grand_total, - ), - ) - - allocate_payment_based_on_payment_terms = frappe.db.get_value( - "Payment Terms Template", - doc.payment_terms_template, - "allocate_payment_based_on_payment_terms", - ) - - if not ( - automatically_fetch_payment_terms - and allocate_payment_based_on_payment_terms - and linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) - ): - for d in doc.get("payment_schedule"): - if d.invoice_portion: - d.payment_amount = flt( - grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount") - ) - d.base_payment_amount = flt( - base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount") - ) - d.outstanding = d.payment_amount - d.base_outstanding = d.base_payment_amount - elif not d.invoice_portion: - d.base_payment_amount = flt( - d.payment_amount * doc.get("conversion_rate"), d.precision("base_payment_amount") - ) - d.base_outstanding = d.base_payment_amount - else: - fetch_payment_terms_from_order( - doc, po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - doc.ignore_default_payment_terms_template = 1 - - -def get_order_details(doc) -> tuple: - if not doc.get("items"): - return None, None, None - if doc.doctype == "Sales Invoice": - prev_doc = doc.get("items")[0].get("sales_order") - prev_doctype = "Sales Order" - prev_doctype_name = "sales_order" - elif doc.doctype == "Purchase Invoice": - prev_doc = doc.get("items")[0].get("purchase_order") - prev_doctype = "Purchase Order" - prev_doctype_name = "purchase_order" - else: - prev_doc = doc.get("items")[0].get("prevdoc_docname") - prev_doctype = "Quotation" - prev_doctype_name = "prevdoc_docname" - return prev_doc, prev_doctype, prev_doctype_name - - -def linked_order_has_payment_terms(doc, po_or_so, fieldname, doctype) -> bool: - if po_or_so and all_items_have_same_po_or_so(doc, po_or_so, fieldname): - if linked_order_has_payment_terms_template(po_or_so, doctype): - return True - elif linked_order_has_payment_schedule(po_or_so): - return True - return False - - -def all_items_have_same_po_or_so(doc, po_or_so, fieldname) -> bool: - for item in doc.get("items"): - if item.get(fieldname) != po_or_so: - return False - return True - - -def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: - return frappe.get_value(doctype, po_or_so, "payment_terms_template") - - -def linked_order_has_payment_schedule(po_or_so) -> list: - return frappe.get_all("Payment Schedule", filters={"parent": po_or_so}) - - -def fetch_payment_terms_from_order( - doc, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms -) -> None: - """Fetch Payment Terms from Purchase/Sales Order when creating a new invoice.""" - po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so) - - doc.payment_schedule = [] - doc.payment_terms_template = po_or_so.payment_terms_template - posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") - - for schedule in po_or_so.payment_schedule: - payment_schedule = { - "payment_term": schedule.payment_term, - "due_date": schedule.due_date, - "invoice_portion": schedule.invoice_portion, - "mode_of_payment": schedule.mode_of_payment, - "description": schedule.description, - "paid_amount": schedule.paid_amount, - } - - if automatically_fetch_payment_terms: - if schedule.due_date_based_on: - payment_schedule["due_date"] = get_due_date(schedule, posting_date) - payment_schedule["due_date_based_on"] = schedule.due_date_based_on - payment_schedule["credit_days"] = cint(schedule.credit_days) - payment_schedule["credit_months"] = cint(schedule.credit_months) - - if schedule.discount_validity_based_on: - payment_schedule["discount_date"] = get_discount_date(schedule, posting_date) - payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on - payment_schedule["discount_validity"] = cint(schedule.discount_validity) - - payment_schedule["payment_amount"] = flt( - grand_total * flt(payment_schedule["invoice_portion"]) / 100, - schedule.precision("payment_amount"), - ) - payment_schedule["base_payment_amount"] = flt( - base_grand_total * flt(payment_schedule["invoice_portion"]) / 100, - schedule.precision("base_payment_amount"), - ) - payment_schedule["outstanding"] = payment_schedule["payment_amount"] - else: - payment_schedule["base_payment_amount"] = flt( - schedule.base_payment_amount * doc.get("conversion_rate"), - schedule.precision("base_payment_amount"), - ) - - if schedule.discount_type == "Percentage": - payment_schedule["discount_type"] = schedule.discount_type - payment_schedule["discount"] = schedule.discount - - if not schedule.invoice_portion: - payment_schedule["payment_amount"] = schedule.payment_amount - - doc.append("payment_schedule", payment_schedule) - - -def set_due_date(doc) -> None: - due_dates = [d.due_date for d in doc.get("payment_schedule") if d.due_date] - if due_dates: - doc.due_date = max(due_dates) - - -def validate_payment_schedule_dates(doc) -> None: - dates = [] - li = [] - - if doc.doctype == "Sales Invoice" and doc.is_pos: - return - - for d in doc.get("payment_schedule"): - d.validate_from_to_dates("discount_date", "due_date") - if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( - doc.transaction_date - ): - frappe.throw( - _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(d.idx) - ) - elif d.due_date in dates: - li.append(_("{0} in row {1}").format(d.due_date, d.idx)) - dates.append(d.due_date) - - if li: - frappe.throw( - _("Rows with duplicate due dates in other rows were found: {0}").format("
" + "
".join(li)), - title=_("Payment Schedule"), - ) - - -def validate_payment_schedule_amount(doc) -> None: - if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": - return - - party_account_currency = doc.get("party_account_currency") - if not party_account_currency: - party_type, party = doc.get_party() - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, doc.company) - - if doc.get("payment_schedule"): - total = 0 - base_total = 0 - for d in doc.get("payment_schedule"): - total += flt(d.payment_amount, d.precision("payment_amount")) - base_total += flt(d.base_payment_amount, d.precision("base_payment_amount")) + posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") + due_date = doc.get("due_date") or posting_date base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) grand_total = flt(doc.get("rounded_total") or doc.grand_total) + automatically_fetch_payment_terms = 0 - if doc.doctype in ("Sales Invoice", "Purchase Invoice"): - base_grand_total = base_grand_total - flt(doc.base_write_off_amount) - grand_total = grand_total - flt(doc.write_off_amount) + if doc.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): + po_or_so, doctype, fieldname = self.get_order_details() + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + if doc.doctype != "Sales Order": + base_grand_total = base_grand_total - flt(doc.base_write_off_amount) + grand_total = grand_total - flt(doc.write_off_amount) if doc.get("total_advance"): if party_account_currency == doc.company_currency: @@ -271,16 +52,252 @@ def validate_payment_schedule_amount(doc) -> None: grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total") ) - if ( - abs(flt(total, doc.precision("grand_total")) - flt(grand_total, doc.precision("grand_total"))) - > 0.1 - or abs( - flt(base_total, doc.precision("base_grand_total")) - - flt(base_grand_total, doc.precision("base_grand_total")) - ) - > 0.1 + if not doc.get("payment_schedule"): + if ( + doc.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] + and automatically_fetch_payment_terms + and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) + ): + self.fetch_payment_terms_from_order( + po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) + if doc.get("payment_terms_template"): + doc.ignore_default_payment_terms_template = 1 + elif doc.get("payment_terms_template"): + data = get_payment_terms( + doc.payment_terms_template, posting_date, grand_total, base_grand_total + ) + for item in data: + doc.append("payment_schedule", item) + elif doc.doctype not in ["Purchase Receipt"]: + doc.append( + "payment_schedule", + dict( + due_date=due_date, + invoice_portion=100, + payment_amount=grand_total, + base_payment_amount=base_grand_total, + ), + ) + + allocate_payment_based_on_payment_terms = frappe.db.get_value( + "Payment Terms Template", + doc.payment_terms_template, + "allocate_payment_based_on_payment_terms", + ) + + if not ( + automatically_fetch_payment_terms + and allocate_payment_based_on_payment_terms + and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) ): - frappe.throw(_("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")) + for d in doc.get("payment_schedule"): + if d.invoice_portion: + d.payment_amount = flt( + grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount") + ) + d.base_payment_amount = flt( + base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount") + ) + d.outstanding = d.payment_amount + d.base_outstanding = d.base_payment_amount + elif not d.invoice_portion: + d.base_payment_amount = flt( + d.payment_amount * doc.get("conversion_rate"), d.precision("base_payment_amount") + ) + d.base_outstanding = d.base_payment_amount + else: + self.fetch_payment_terms_from_order( + po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms + ) + doc.ignore_default_payment_terms_template = 1 + + def get_order_details(self) -> tuple: + doc = self.doc + if not doc.get("items"): + return None, None, None + if doc.doctype == "Sales Invoice": + prev_doc = doc.get("items")[0].get("sales_order") + prev_doctype = "Sales Order" + prev_doctype_name = "sales_order" + elif doc.doctype == "Purchase Invoice": + prev_doc = doc.get("items")[0].get("purchase_order") + prev_doctype = "Purchase Order" + prev_doctype_name = "purchase_order" + else: + prev_doc = doc.get("items")[0].get("prevdoc_docname") + prev_doctype = "Quotation" + prev_doctype_name = "prevdoc_docname" + return prev_doc, prev_doctype, prev_doctype_name + + def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) -> bool: + if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname): + if linked_order_has_payment_terms_template(po_or_so, doctype): + return True + elif linked_order_has_payment_schedule(po_or_so): + return True + return False + + def all_items_have_same_po_or_so(self, po_or_so, fieldname) -> bool: + for item in self.doc.get("items"): + if item.get(fieldname) != po_or_so: + return False + return True + + def fetch_payment_terms_from_order( + self, + po_or_so, + po_or_so_doctype, + grand_total, + base_grand_total, + automatically_fetch_payment_terms, + ) -> None: + """Fetch Payment Terms from Purchase/Sales Order when creating a new invoice.""" + doc = self.doc + po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so) + + doc.payment_schedule = [] + doc.payment_terms_template = po_or_so.payment_terms_template + posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date") + + for schedule in po_or_so.payment_schedule: + payment_schedule = { + "payment_term": schedule.payment_term, + "due_date": schedule.due_date, + "invoice_portion": schedule.invoice_portion, + "mode_of_payment": schedule.mode_of_payment, + "description": schedule.description, + "paid_amount": schedule.paid_amount, + } + + if automatically_fetch_payment_terms: + if schedule.due_date_based_on: + payment_schedule["due_date"] = get_due_date(schedule, posting_date) + payment_schedule["due_date_based_on"] = schedule.due_date_based_on + payment_schedule["credit_days"] = cint(schedule.credit_days) + payment_schedule["credit_months"] = cint(schedule.credit_months) + + if schedule.discount_validity_based_on: + payment_schedule["discount_date"] = get_discount_date(schedule, posting_date) + payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on + payment_schedule["discount_validity"] = cint(schedule.discount_validity) + + payment_schedule["payment_amount"] = flt( + grand_total * flt(payment_schedule["invoice_portion"]) / 100, + schedule.precision("payment_amount"), + ) + payment_schedule["base_payment_amount"] = flt( + base_grand_total * flt(payment_schedule["invoice_portion"]) / 100, + schedule.precision("base_payment_amount"), + ) + payment_schedule["outstanding"] = payment_schedule["payment_amount"] + else: + payment_schedule["base_payment_amount"] = flt( + schedule.base_payment_amount * doc.get("conversion_rate"), + schedule.precision("base_payment_amount"), + ) + + if schedule.discount_type == "Percentage": + payment_schedule["discount_type"] = schedule.discount_type + payment_schedule["discount"] = schedule.discount + + if not schedule.invoice_portion: + payment_schedule["payment_amount"] = schedule.payment_amount + + doc.append("payment_schedule", payment_schedule) + + def set_due_date(self) -> None: + due_dates = [d.due_date for d in self.doc.get("payment_schedule") if d.due_date] + if due_dates: + self.doc.due_date = max(due_dates) + + def validate_payment_schedule_dates(self) -> None: + dates = [] + li = [] + doc = self.doc + + if doc.doctype == "Sales Invoice" and doc.is_pos: + return + + for d in doc.get("payment_schedule"): + d.validate_from_to_dates("discount_date", "due_date") + if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( + doc.transaction_date + ): + frappe.throw( + _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format( + d.idx + ) + ) + elif d.due_date in dates: + li.append(_("{0} in row {1}").format(d.due_date, d.idx)) + dates.append(d.due_date) + + if li: + frappe.throw( + _("Rows with duplicate due dates in other rows were found: {0}").format( + "
" + "
".join(li) + ), + title=_("Payment Schedule"), + ) + + def validate_payment_schedule_amount(self) -> None: + doc = self.doc + if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes": + return + + party_account_currency = doc.get("party_account_currency") + if not party_account_currency: + party_type, party = doc.get_party() + if party_type and party: + party_account_currency = get_party_account_currency(party_type, party, doc.company) + + if doc.get("payment_schedule"): + total = 0 + base_total = 0 + for d in doc.get("payment_schedule"): + total += flt(d.payment_amount, d.precision("payment_amount")) + base_total += flt(d.base_payment_amount, d.precision("base_payment_amount")) + + base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) + grand_total = flt(doc.get("rounded_total") or doc.grand_total) + + if doc.doctype in ("Sales Invoice", "Purchase Invoice"): + base_grand_total = base_grand_total - flt(doc.base_write_off_amount) + grand_total = grand_total - flt(doc.write_off_amount) + + if doc.get("total_advance"): + if party_account_currency == doc.company_currency: + base_grand_total -= doc.get("total_advance") + grand_total = flt( + base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total") + ) + else: + grand_total -= doc.get("total_advance") + base_grand_total = flt( + grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total") + ) + + if ( + abs(flt(total, doc.precision("grand_total")) - flt(grand_total, doc.precision("grand_total"))) + > 0.1 + or abs( + flt(base_total, doc.precision("base_grand_total")) + - flt(base_grand_total, doc.precision("base_grand_total")) + ) + > 0.1 + ): + frappe.throw( + _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total") + ) + + +def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: + return frappe.get_value(doctype, po_or_so, "payment_terms_template") + + +def linked_order_has_payment_schedule(po_or_so) -> list: + return frappe.get_all("Payment Schedule", filters={"parent": po_or_so}) def get_payment_terms( diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index e25332528e2..7ff80d31d58 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -842,7 +842,9 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions if target.get("allocate_advances_automatically"): target.set_advances() - target.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(target).set_payment_schedule() target.credit_to = get_party_account("Supplier", source.supplier, source.company) def get_billed_qty(po_item_name): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 938d07c31c8..c17f9d5871f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -125,7 +125,9 @@ class AccountsController(TransactionBase): "Sales Invoice", ) if self.doctype in relevant_docs: - self.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(self).set_payment_schedule() def on_update(self): from erpnext.controllers.taxes_and_totals import process_item_wise_tax_details @@ -647,18 +649,24 @@ class AccountsController(TransactionBase): if self.is_return: return - self.validate_payment_schedule_dates() - self.set_due_date() - self.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + ps = PaymentScheduleService(self) + ps.validate_payment_schedule_dates() + ps.set_due_date() + ps.set_payment_schedule() if not self.get("ignore_default_payment_terms_template"): - self.validate_payment_schedule_amount() + ps.validate_payment_schedule_amount() self.validate_due_date() self.validate_advance_entries() def validate_non_invoice_documents_schedule(self): - self.set_payment_schedule() - self.validate_payment_schedule_dates() - self.validate_payment_schedule_amount() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + ps = PaymentScheduleService(self) + ps.set_payment_schedule() + ps.validate_payment_schedule_dates() + ps.validate_payment_schedule_amount() def validate_all_documents_schedule(self): if self.doctype in ("Sales Invoice", "Purchase Invoice"): @@ -1466,35 +1474,6 @@ class AccountsController(TransactionBase): frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po))) - def validate_multiple_billing(self, ref_dt: str, item_ref_dn: str, based_on: str) -> None: - from erpnext.accounts.services.billing_validation import validate_multiple_billing - - validate_multiple_billing(self, ref_dt, item_ref_dn, based_on) - - def get_billing_reference_details( - self, reference_names: list, reference_doctype: str, based_on: str - ) -> frappe._dict: - from erpnext.accounts.services.billing_validation import get_billing_reference_details - - return get_billing_reference_details(self, reference_names, reference_doctype, based_on) - - def get_reference_wise_billed_amt(self, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None: - from erpnext.accounts.services.billing_validation import get_reference_wise_billed_amt - - return get_reference_wise_billed_amt(self, ref_dt, item_ref_dn, based_on) - - def get_already_billed_amount( - self, reference_names: list, item_ref_dn: str, based_on: str - ) -> frappe._dict: - from erpnext.accounts.services.billing_validation import get_already_billed_amount - - return get_already_billed_amount(self, reference_names, item_ref_dn, based_on) - - def throw_overbill_exception(self, overbilled_items: list, precision: int) -> None: - from erpnext.accounts.services.billing_validation import throw_overbill_exception - - throw_overbill_exception(self, overbilled_items, precision) - def get_company_default(self, fieldname, ignore_validation=False): from erpnext.accounts.utils import get_company_default @@ -1681,65 +1660,6 @@ class AccountsController(TransactionBase): for item in duplicate_list: self.remove(item) - def set_payment_schedule(self) -> None: - from erpnext.accounts.services.payment_schedule import set_payment_schedule - - set_payment_schedule(self) - - def get_order_details(self) -> tuple: - from erpnext.accounts.services.payment_schedule import get_order_details - - return get_order_details(self) - - def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) -> bool: - from erpnext.accounts.services.payment_schedule import linked_order_has_payment_terms - - return linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) - - def all_items_have_same_po_or_so(self, po_or_so, fieldname) -> bool: - from erpnext.accounts.services.payment_schedule import all_items_have_same_po_or_so - - return all_items_have_same_po_or_so(self, po_or_so, fieldname) - - def linked_order_has_payment_terms_template(self, po_or_so, doctype) -> str | None: - from erpnext.accounts.services.payment_schedule import linked_order_has_payment_terms_template - - return linked_order_has_payment_terms_template(po_or_so, doctype) - - def linked_order_has_payment_schedule(self, po_or_so) -> list: - from erpnext.accounts.services.payment_schedule import linked_order_has_payment_schedule - - return linked_order_has_payment_schedule(po_or_so) - - def fetch_payment_terms_from_order( - self, - po_or_so, - po_or_so_doctype, - grand_total, - base_grand_total, - automatically_fetch_payment_terms, - ) -> None: - from erpnext.accounts.services.payment_schedule import fetch_payment_terms_from_order - - fetch_payment_terms_from_order( - self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms - ) - - def set_due_date(self) -> None: - from erpnext.accounts.services.payment_schedule import set_due_date - - set_due_date(self) - - def validate_payment_schedule_dates(self) -> None: - from erpnext.accounts.services.payment_schedule import validate_payment_schedule_dates - - validate_payment_schedule_dates(self) - - def validate_payment_schedule_amount(self) -> None: - from erpnext.accounts.services.payment_schedule import validate_payment_schedule_amount - - validate_payment_schedule_amount(self) - def is_rounded_total_disabled(self): if self.meta.get_field("disable_rounded_total"): return self.disable_rounded_total @@ -2538,7 +2458,9 @@ def update_child_qty_rate( ) if parent_doctype != "Supplier Quotation": - parent.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(parent).set_payment_schedule() if parent_doctype == "Purchase Order": parent.validate_minimum_order_qty() parent.validate_budget() diff --git a/erpnext/regional/united_arab_emirates/utils.py b/erpnext/regional/united_arab_emirates/utils.py index 28997542393..671f726a740 100644 --- a/erpnext/regional/united_arab_emirates/utils.py +++ b/erpnext/regional/united_arab_emirates/utils.py @@ -140,7 +140,9 @@ def update_totals(vat_tax, base_vat_tax, doc): doc.in_words = money_in_words(doc.grand_total, doc.currency) doc.base_in_words = money_in_words(doc.base_grand_total, erpnext.get_company_currency(doc.company)) - doc.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doc).set_payment_schedule() def make_regional_gl_entries(gl_entries, doc): diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 6d950c9f69f..e453ae546fd 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -480,7 +480,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar ) if automatically_fetch_payment_terms: - doclist.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doclist).set_payment_schedule() return doclist diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 4d68a79e62d..e2d43dee72b 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1606,7 +1606,9 @@ def make_sales_invoice( frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") ) if automatically_fetch_payment_terms: - doclist.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doclist).set_payment_schedule() return doclist diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index f85ed1dc2a9..f13c2d9c393 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -1000,9 +1000,12 @@ def make_sales_invoice( ) if not doc.is_return: - so, doctype, fieldname = doc.get_order_details() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + ps = PaymentScheduleService(doc) + so, doctype, fieldname = ps.get_order_details() if ( - doc.linked_order_has_payment_terms(so, fieldname, doctype) + ps.linked_order_has_payment_terms(so, fieldname, doctype) and not automatically_fetch_payment_terms ): payment_terms_template = frappe.db.get_value(doctype, so, "payment_terms_template") @@ -1016,7 +1019,7 @@ def make_sales_invoice( ) elif automatically_fetch_payment_terms: - doc.set_payment_schedule() + ps.set_payment_schedule() return doc diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 0fca30c5458..0a48b00776c 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1112,7 +1112,9 @@ def make_purchase_invoice( merge_taxes(source, doc) doc.run_method("calculate_taxes_and_totals") - doc.set_payment_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doc).set_payment_schedule() def update_item(source_doc, target_doc, source_parent): target_doc.qty, returned_qty = get_pending_qty(source_doc) From c7b4806117796546d19b7018f1a3e1ee2001feb9 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 28 May 2026 20:10:45 +0530 Subject: [PATCH 32/65] refactor: extract party validation and inter-company logic into service classes - accounts/services/party_validation.py: PartyValidator class with single validate() entry point covering party frozen/disabled check, party accounts, currency, party account currency, address/contact, and company-linked addresses. AccountsController.get_party() kept as a shim (called by advances and payment_schedule services). - accounts/services/internal_transfer.py: InternalTransferService class with validate() (reference + transaction rate + pricing/tax disablers), set_account() for unrealized P&L, is_internal_transfer(), process_common_party_accounting(), and get_common_party_link(). Shims retained on AccountsController for the three methods called by selling/buying/stock controllers and GL composers. accounts_controller.py drops from ~2722 to ~2356 lines. --- .../accounts/services/internal_transfer.py | 196 +++++++++ erpnext/accounts/services/party_validation.py | 223 ++++++++++ erpnext/controllers/accounts_controller.py | 405 +----------------- 3 files changed, 438 insertions(+), 386 deletions(-) create mode 100644 erpnext/accounts/services/internal_transfer.py create mode 100644 erpnext/accounts/services/party_validation.py diff --git a/erpnext/accounts/services/internal_transfer.py b/erpnext/accounts/services/internal_transfer.py new file mode 100644 index 00000000000..fdce48e0815 --- /dev/null +++ b/erpnext/accounts/services/internal_transfer.py @@ -0,0 +1,196 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Internal transfer helpers: InternalTransferService for inter-company transaction validation and setup.""" + +import frappe +from frappe import _, bold +from frappe.utils import cint, flt + + +class InternalTransferService: + """Handles validation and setup for inter-company / internal transfer transactions.""" + + def __init__(self, doc): + self.doc = doc + + def is_internal_transfer(self) -> bool: + """Return True if document is an internal transfer (internal party + same represents_company).""" + doc = self.doc + if doc.doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): + internal_party_field = "is_internal_customer" + elif doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"): + internal_party_field = "is_internal_supplier" + else: + return False + + return bool(doc.get(internal_party_field) and doc.represents_company == doc.company) + + def validate(self) -> None: + """Run all inter-company validations and apply internal-transfer field overrides.""" + self.validate_reference() + self.validate_transaction() + self.disable_pricing_rule() + self.disable_tax_included_prices() + + def set_account(self) -> None: + """Set unrealized profit/loss account for internal transfers (SI/PI only).""" + if not self.is_internal_transfer() or self.doc.unrealized_profit_loss_account: + return + + unrealized_profit_loss_account = frappe.get_cached_value( + "Company", self.doc.company, "unrealized_profit_loss_account" + ) + + if not unrealized_profit_loss_account: + frappe.throw( + _( + "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}" + ).format(frappe.bold(self.doc.company)) + ) + + self.doc.unrealized_profit_loss_account = unrealized_profit_loss_account + + def process_common_party_accounting(self) -> None: + """Auto-create and reconcile advance for common party links (called from on_submit).""" + if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"): + return + + if frappe.get_single_value("Accounts Settings", "enable_common_party_accounting"): + party_link = self.get_common_party_link() + if party_link and self.doc.outstanding_amount: + from erpnext.accounts.services.advances import create_advance_and_reconcile + + create_advance_and_reconcile(self.doc, party_link) + + def get_common_party_link(self) -> frappe._dict | None: + party_type, party = self.doc.get_party() + return frappe.db.get_value( + doctype="Party Link", + filters={"secondary_role": party_type, "secondary_party": party}, + fieldname=["primary_role", "primary_party"], + as_dict=True, + ) + + def validate_reference(self) -> None: + if self.doc.get("is_return"): + return + if self.doc.doctype not in ("Purchase Invoice", "Purchase Receipt"): + return + if not self.is_internal_transfer(): + return + + if not ( + self.doc.get("inter_company_reference") + or self.doc.get("inter_company_invoice_reference") + or self.doc.get("inter_company_order_reference") + ): + msg = _("Internal Sale or Delivery Reference missing.") + msg += _("Please create purchase from internal sale or delivery document itself") + frappe.throw(msg, title=_("Internal Sales Reference Missing")) + + label = "Delivery Note Item" if self.doc.doctype == "Purchase Receipt" else "Sales Invoice Item" + field = frappe.scrub(label) + + for row in self.doc.get("items"): + if not row.get(field): + frappe.throw( + _(f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer"), + title=_("Internal Transfer Reference Missing"), + ) + + def validate_transaction(self) -> None: + if not cint(frappe.get_single_value("Accounts Settings", "maintain_same_internal_transaction_rate")): + return + + applicable_doctypes = ("Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice") + if self.doc.doctype not in applicable_doctypes: + return + if not (self.doc.get("is_internal_customer") or self.doc.get("is_internal_supplier")): + return + + self._validate_transaction_by_voucher_type() + + def disable_pricing_rule(self) -> None: + if not self.doc.get("ignore_pricing_rule") and self.is_internal_transfer(): + self.doc.ignore_pricing_rule = 1 + frappe.msgprint( + _("Disabled pricing rules since this {} is an internal transfer").format(self.doc.doctype), + alert=1, + ) + + def disable_tax_included_prices(self) -> None: + if not self.is_internal_transfer(): + return + + tax_updated = False + for tax in self.doc.get("taxes"): + if tax.get("included_in_print_rate"): + tax.included_in_print_rate = 0 + tax_updated = True + + if tax_updated: + frappe.msgprint( + _("Disabled tax included prices since this {} is an internal transfer").format( + self.doc.doctype + ), + alert=1, + ) + + def _validate_transaction_by_voucher_type(self) -> None: + orders = ("Sales Order", "Purchase Order") + invoices = ("Sales Invoice", "Purchase Invoice") + + if self.doc.doctype in orders and self.doc.get("inter_company_order_reference"): + linked_doctype = "Sales Order" if self.doc.doctype == "Purchase Order" else "Purchase Order" + self._validate_line_items( + linked_doctype, + "sales_order" if linked_doctype == "Sales Order" else "purchase_order", + "sales_order_item" if linked_doctype == "Sales Order" else "purchase_order_item", + ) + elif self.doc.doctype in invoices and self.doc.get("inter_company_invoice_reference"): + linked_doctype = "Sales Invoice" if self.doc.doctype == "Purchase Invoice" else "Purchase Invoice" + self._validate_line_items( + linked_doctype, + "sales_invoice" if linked_doctype == "Sales Invoice" else "purchase_invoice", + "sales_invoice_item" if linked_doctype == "Sales Invoice" else "purchase_invoice_item", + ) + + def _validate_line_items(self, ref_dt: str, ref_dn_field: str, ref_link_field: str) -> None: + action, role_allowed_to_override = frappe.get_cached_value( + "Accounts Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) + + reference_names = [d.get(ref_link_field) for d in self.doc.get("items") if d.get(ref_link_field)] + reference_details = self.doc.get_reference_details(reference_names, ref_dt + " Item") + + stop_actions = [] + + for d in self.doc.get("items"): + if not d.get(ref_link_field): + continue + + ref_rate = reference_details.get(d.get(ref_link_field)) + if ref_rate is None or abs(flt(d.rate - ref_rate, d.precision("rate"))) < 0.01: + continue + + ref_name = ( + self.doc.inter_company_invoice_reference + if d.parenttype in ("Sales Invoice", "Purchase Invoice") + else d.get(ref_dn_field) + ) + msg = _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( + d.idx, ref_dt, ref_name, d.rate, ref_rate + ) + + if action == "Stop": + user_roles = frappe.get_all( + "Has Role", filters={"parent": frappe.session.user}, fields=["role"], pluck="role" + ) + if role_allowed_to_override not in user_roles: + stop_actions.append(msg) + else: + frappe.msgprint(msg, title=_("Warning"), indicator="orange") + + if stop_actions: + frappe.throw(stop_actions, as_list=True) diff --git a/erpnext/accounts/services/party_validation.py b/erpnext/accounts/services/party_validation.py new file mode 100644 index 00000000000..a6ec9716840 --- /dev/null +++ b/erpnext/accounts/services/party_validation.py @@ -0,0 +1,223 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Party validation: PartyValidator class for transaction-level party checks.""" + +import frappe +from frappe import _ + +from erpnext.accounts.party import ( + get_party_account_currency, + get_party_gle_currency, + validate_party_frozen_disabled, +) +from erpnext.accounts.utils import get_account_currency +from erpnext.exceptions import InvalidCurrency + + +class PartyValidator: + """Validates all party-related fields on a transaction document.""" + + def __init__(self, doc): + self.doc = doc + + def validate(self) -> None: + """Run all party-related validations in order.""" + self.validate_party() + self.validate_party_accounts() + self.validate_currency() + self.validate_party_account_currency() + self.validate_address_and_contact() + self.validate_company_linked_addresses() + + def get_party(self) -> tuple[str | None, str | None]: + """Return (party_type, party_name) for the document.""" + doc = self.doc + party_type = None + + if doc.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"): + party_type = "Customer" + elif doc.doctype in ( + "Supplier Quotation", + "Purchase Order", + "Purchase Receipt", + "Purchase Invoice", + ): + party_type = "Supplier" + elif doc.meta.get_field("customer"): + party_type = "Customer" + elif doc.meta.get_field("supplier"): + party_type = "Supplier" + + party = doc.get(party_type.lower()) if party_type else None + return party_type, party + + def validate_party(self) -> None: + party_type, party = self.get_party() + validate_party_frozen_disabled(self.doc.company, party_type, party) + + def validate_party_accounts(self) -> None: + if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"): + return + + if self.doc.doctype == "Sales Invoice": + party_account_field = "debit_to" + item_field = "income_account" + else: + party_account_field = "credit_to" + item_field = "expense_account" + + for item in self.doc.get("items"): + if item.get(item_field) == self.doc.get(party_account_field): + frappe.throw( + _("Row {0}: {1} {2} cannot be same as {3} (Party Account) {4}").format( + item.idx, + frappe.bold(frappe.unscrub(item_field)), + item.get(item_field), + frappe.bold(frappe.unscrub(party_account_field)), + self.doc.get(party_account_field), + ) + ) + + def validate_currency(self) -> None: + if not self.doc.get("currency"): + return + + party_type, party = self.get_party() + if not (party_type and party): + return + + party_account_currency = get_party_account_currency(party_type, party, self.doc.company) + + if ( + party_account_currency + and party_account_currency != self.doc.company_currency + and self.doc.currency != party_account_currency + ): + frappe.throw( + _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format( + party_type, party, party_account_currency + ), + InvalidCurrency, + ) + + def validate_party_account_currency(self) -> None: + if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"): + return + if self.doc.is_opening == "Yes": + return + + party_type, party = self.get_party() + party_gle_currency = get_party_gle_currency(party_type, party, self.doc.company) + party_account = ( + self.doc.get("debit_to") if self.doc.doctype == "Sales Invoice" else self.doc.get("credit_to") + ) + party_account_currency = get_account_currency(party_account) + allow_multi_currency = frappe.db.get_singles_value( + "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account" + ) + + if ( + not party_gle_currency + and party_account_currency != self.doc.currency + and not allow_multi_currency + ): + frappe.throw( + _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format( + frappe.bold(party_account), party_account_currency, self.doc.currency + ) + ) + + def validate_address_and_contact(self) -> None: + party_type, party = self.get_party() + if not (party_type and party): + return + + if party_type == "Customer": + self._validate_address( + party, + party_type, + self.doc.get("customer_address"), + self.doc.get("shipping_address_name"), + ) + elif party_type == "Supplier": + self._validate_address(party, party_type, self.doc.get("supplier_address")) + + self._validate_contact(party, party_type) + + def validate_company_linked_addresses(self) -> None: + doc = self.doc + sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice") + purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation") + + if doc.doctype in sales_doctypes: + address_fields = ["dispatch_address_name", "company_address"] + elif doc.doctype in purchase_doctypes: + address_fields = ["billing_address", "shipping_address"] + else: + return + + is_drop_ship = ( + doc.doctype + in { + "Purchase Order", + "Purchase Invoice", + "Sales Order", + "Sales Invoice", + } + and self._is_drop_ship() + ) + + for field in address_fields: + address = doc.get(field) + if field in ("dispatch_address_name", "shipping_address") and is_drop_ship: + continue + if address and not frappe.db.exists( + "Dynamic Link", + { + "parent": address, + "parenttype": "Address", + "link_doctype": "Company", + "link_name": doc.company, + }, + ): + frappe.throw( + _("{0} does not belong to the Company {1}.").format( + _(doc.meta.get_label(field)), frappe.bold(doc.company) + ) + ) + + def _validate_address( + self, + party: str, + party_type: str, + billing_address: str | None, + shipping_address: str | None = None, + ) -> None: + if not (billing_address or shipping_address): + return + + party_addresses = frappe.get_all( + "Dynamic Link", + {"link_doctype": party_type, "link_name": party, "parenttype": "Address"}, + pluck="parent", + ) + if billing_address and billing_address not in party_addresses: + frappe.throw(_("Billing Address does not belong to the {0}").format(party)) + elif shipping_address and shipping_address not in party_addresses: + frappe.throw(_("Shipping Address does not belong to the {0}").format(party)) + + def _validate_contact(self, party: str, party_type: str) -> None: + if not self.doc.get("contact_person"): + return + + contacts = frappe.get_all( + "Dynamic Link", + {"link_doctype": party_type, "link_name": party, "parenttype": "Contact"}, + pluck="parent", + ) + if self.doc.contact_person not in contacts: + frappe.throw(_("Contact Person does not belong to the {0}").format(party)) + + def _is_drop_ship(self) -> bool: + return any(item.delivered_by_supplier for item in self.doc.items) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index c17f9d5871f..e3fae9b2b13 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -35,25 +35,17 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente from erpnext.accounts.party import ( PURCHASE_TRANSACTION_TYPES, SALES_TRANSACTION_TYPES, - get_party_account, - get_party_account_currency, - get_party_gle_currency, - validate_party_frozen_disabled, -) -from erpnext.accounts.utils import ( - get_account_currency, - validate_fiscal_year, ) from erpnext.accounts.utils import ( get_advance_payment_doctypes as _get_advance_payment_doctypes, ) +from erpnext.accounts.utils import validate_fiscal_year from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, set_print_templates_for_taxes, ) from erpnext.controllers.sales_and_purchase_return import validate_return -from erpnext.exceptions import InvalidCurrency from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.packed_item.packed_item import make_packing_list @@ -225,19 +217,15 @@ class AccountsController(TransactionBase): self.ensure_supplier_is_not_blocked() self.validate_date_with_fiscal_year() - self.validate_party_accounts() if self.doctype in ["Sales Invoice", "Purchase Invoice"]: if self.is_return: self.validate_qty() else: self.validate_deferred_start_and_end_date() - self.validate_inter_company_reference() - # validate inter company transaction rate - self.validate_internal_transaction() + from erpnext.accounts.services.internal_transfer import InternalTransferService - self.disable_pricing_rule_on_internal_transfer() - self.disable_tax_included_prices_for_internal_transfer() + InternalTransferService(self).validate() self.set_incoming_rate() self.init_internal_values() self.validate_against_voucher_outstanding() @@ -263,9 +251,9 @@ class AccountsController(TransactionBase): self.validate_all_documents_schedule() - self.validate_party() - self.validate_currency() - self.validate_party_account_currency() + from erpnext.accounts.services.party_validation import PartyValidator + + PartyValidator(self).validate() self.validate_return_against_account() if self.doctype in ["Purchase Invoice", "Sales Invoice"]: @@ -286,7 +274,7 @@ class AccountsController(TransactionBase): self.set_advance_gain_or_loss() self.validate_deferred_income_expense_account() - self.set_inter_company_account() + InternalTransferService(self).set_account() if self.doctype == "Purchase Invoice": self.calculate_paid_amount() @@ -301,54 +289,6 @@ class AccountsController(TransactionBase): self.set_total_in_words() self.set_default_letter_head() self.validate_company_in_accounting_dimension() - self.validate_party_address_and_contact() - self.validate_company_linked_addresses() - - def validate_company_linked_addresses(self): - address_fields = [] - sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice") - purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation") - - if self.doctype in sales_doctypes: - address_fields = ["dispatch_address_name", "company_address"] - elif self.doctype in purchase_doctypes: - address_fields = ["billing_address", "shipping_address"] - - if not address_fields: - return - - # Determine if drop ship applies - is_drop_ship = self.doctype in { - "Purchase Order", - "Purchase Invoice", - "Sales Order", - "Sales Invoice", - } and self.is_drop_ship(self.items) - - for field in address_fields: - address = self.get(field) - - if (field in ["dispatch_address_name", "shipping_address"]) and is_drop_ship: - continue - - if address and not frappe.db.exists( - "Dynamic Link", - { - "parent": address, - "parenttype": "Address", - "link_doctype": "Company", - "link_name": self.company, - }, - ): - frappe.throw( - _("{0} does not belong to the Company {1}.").format( - _(self.meta.get_label(field)), bold(self.company) - ) - ) - - @staticmethod - def is_drop_ship(items): - return any(item.delivered_by_supplier for item in items) def set_default_letter_head(self): if hasattr(self, "letter_head") and not self.letter_head: @@ -536,46 +476,6 @@ class AccountsController(TransactionBase): ) ) - def validate_party_address_and_contact(self): - party_type, party = self.get_party() - - if not (party_type and party): - return - - if party_type == "Customer": - billing_address, shipping_address = ( - self.get("customer_address"), - self.get("shipping_address_name"), - ) - self.validate_party_address(party, party_type, billing_address, shipping_address) - elif party_type == "Supplier": - billing_address = self.get("supplier_address") - self.validate_party_address(party, party_type, billing_address) - - self.validate_party_contact(party, party_type) - - def validate_party_address(self, party, party_type, billing_address, shipping_address=None): - if billing_address or shipping_address: - party_address = frappe.get_all( - "Dynamic Link", - {"link_doctype": party_type, "link_name": party, "parenttype": "Address"}, - pluck="parent", - ) - if billing_address and billing_address not in party_address: - frappe.throw(_("Billing Address does not belong to the {0}").format(party)) - elif shipping_address and shipping_address not in party_address: - frappe.throw(_("Shipping Address does not belong to the {0}").format(party)) - - def validate_party_contact(self, party, party_type): - if self.get("contact_person"): - contact = frappe.get_all( - "Dynamic Link", - {"link_doctype": party_type, "link_name": party, "parenttype": "Contact"}, - pluck="parent", - ) - if self.contact_person and self.contact_person not in contact: - frappe.throw(_("Contact Person does not belong to the {0}").format(party)) - def validate_return_against_account(self): if self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against: cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to" @@ -762,162 +662,6 @@ class AccountsController(TransactionBase): self, ) - def validate_party_accounts(self): - if self.doctype not in ("Sales Invoice", "Purchase Invoice"): - return - - if self.doctype == "Sales Invoice": - party_account_field = "debit_to" - item_field = "income_account" - else: - party_account_field = "credit_to" - item_field = "expense_account" - - for item in self.get("items"): - if item.get(item_field) == self.get(party_account_field): - frappe.throw( - _("Row {0}: {1} {2} cannot be same as {3} (Party Account) {4}").format( - item.idx, - frappe.bold(frappe.unscrub(item_field)), - item.get(item_field), - frappe.bold(frappe.unscrub(party_account_field)), - self.get(party_account_field), - ) - ) - - def validate_inter_company_reference(self): - if self.get("is_return"): - return - - if self.doctype not in ("Purchase Invoice", "Purchase Receipt"): - return - - if self.is_internal_transfer(): - if not ( - self.get("inter_company_reference") - or self.get("inter_company_invoice_reference") - or self.get("inter_company_order_reference") - ) and not self.get("is_return"): - msg = _("Internal Sale or Delivery Reference missing.") - msg += _("Please create purchase from internal sale or delivery document itself") - frappe.throw(msg, title=_("Internal Sales Reference Missing")) - - label = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item" - - field = frappe.scrub(label) - - for row in self.get("items"): - if not row.get(field): - msg = f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer" - frappe.throw(_(msg), title=_("Internal Transfer Reference Missing")) - - def validate_internal_transaction(self): - if not cint(frappe.get_single_value("Accounts Settings", "maintain_same_internal_transaction_rate")): - return - - doctypes_list = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] - - if self.doctype in doctypes_list and ( - self.get("is_internal_customer") or self.get("is_internal_supplier") - ): - self.validate_internal_transaction_based_on_voucher_type() - - def validate_internal_transaction_based_on_voucher_type(self): - order = ["Sales Order", "Purchase Order"] - invoice = ["Sales Invoice", "Purchase Invoice"] - - if self.doctype in order and self.get("inter_company_order_reference"): - # Fetch the linked order - linked_doctype = "Sales Order" if self.doctype == "Purchase Order" else "Purchase Order" - self.validate_line_items( - linked_doctype, - "sales_order" if linked_doctype == "Sales Order" else "purchase_order", - "sales_order_item" if linked_doctype == "Sales Order" else "purchase_order_item", - ) - elif self.doctype in invoice and self.get("inter_company_invoice_reference"): - # Fetch the linked invoice - linked_doctype = "Sales Invoice" if self.doctype == "Purchase Invoice" else "Purchase Invoice" - self.validate_line_items( - linked_doctype, - "sales_invoice" if linked_doctype == "Sales Invoice" else "purchase_invoice", - "sales_invoice_item" if linked_doctype == "Sales Invoice" else "purchase_invoice_item", - ) - - def validate_line_items(self, ref_dt, ref_dn_field, ref_link_field): - action, role_allowed_to_override = frappe.get_cached_value( - "Accounts Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] - ) - - reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)] - reference_details = self.get_reference_details(reference_names, ref_dt + " Item") - - stop_actions = [] - - for d in self.get("items"): - if d.get(ref_link_field): - ref_rate = reference_details.get(d.get(ref_link_field)) - if ref_rate is not None and abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01: - if action == "Stop": - user_roles = [ - r["role"] - for r in frappe.get_all( - "Has Role", filters={"parent": frappe.session.user}, fields=["role"] - ) - ] - if role_allowed_to_override not in user_roles: - stop_actions.append( - _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( - d.idx, - ref_dt, - self.inter_company_invoice_reference - if d.parenttype in ("Sales Invoice", "Purchase Invoice") - else d.get(ref_dn_field), - d.rate, - ref_rate, - ) - ) - else: - frappe.msgprint( - _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( - d.idx, - ref_dt, - self.inter_company_invoice_reference - if d.parenttype in ("Sales Invoice", "Purchase Invoice") - else d.get(ref_dn_field), - d.rate, - ref_rate, - ), - title=_("Warning"), - indicator="orange", - ) - - if stop_actions: - frappe.throw(stop_actions, as_list=True) - - def disable_pricing_rule_on_internal_transfer(self): - if not self.get("ignore_pricing_rule") and self.is_internal_transfer(): - self.ignore_pricing_rule = 1 - frappe.msgprint( - _("Disabled pricing rules since this {} is an internal transfer").format(self.doctype), - alert=1, - ) - - def disable_tax_included_prices_for_internal_transfer(self): - if self.is_internal_transfer(): - tax_updated = False - for tax in self.get("taxes"): - if tax.get("included_in_print_rate"): - tax.included_in_print_rate = 0 - tax_updated = True - - if tax_updated: - frappe.msgprint( - _("Disabled tax included prices since this {} is an internal transfer").format( - self.doctype - ), - alert=1, - ) - def validate_due_date(self): if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]: return @@ -1546,80 +1290,10 @@ class AccountsController(TransactionBase): frappe.throw(message, title=_("Account Missing"), exc=AccountMissingError) - def validate_party(self): - party_type, party = self.get_party() - validate_party_frozen_disabled(self.company, party_type, party) + def get_party(self) -> tuple[str | None, str | None]: + from erpnext.accounts.services.party_validation import PartyValidator - def get_party(self): - party_type = None - if self.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"): - party_type = "Customer" - - elif self.doctype in ( - "Supplier Quotation", - "Purchase Order", - "Purchase Receipt", - "Purchase Invoice", - ): - party_type = "Supplier" - - elif self.meta.get_field("customer"): - party_type = "Customer" - - elif self.meta.get_field("supplier"): - party_type = "Supplier" - - party = self.get(party_type.lower()) if party_type else None - - return party_type, party - - def validate_currency(self): - if self.get("currency"): - party_type, party = self.get_party() - if party_type and party: - party_account_currency = get_party_account_currency(party_type, party, self.company) - - if ( - party_account_currency - and party_account_currency != self.company_currency - and self.currency != party_account_currency - ): - frappe.throw( - _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format( - party_type, party, party_account_currency - ), - InvalidCurrency, - ) - - # Note: not validating with gle account because we don't have the account - # at quotation / sales order level and we shouldn't stop someone - # from creating a sales invoice if sales order is already created - - def validate_party_account_currency(self): - if self.doctype not in ("Sales Invoice", "Purchase Invoice"): - return - - if self.is_opening == "Yes": - return - - party_type, party = self.get_party() - party_gle_currency = get_party_gle_currency(party_type, party, self.company) - party_account = self.get("debit_to") if self.doctype == "Sales Invoice" else self.get("credit_to") - party_account_currency = get_account_currency(party_account) - allow_multi_currency_invoices_against_single_party_account = frappe.db.get_singles_value( - "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account" - ) - - if ( - not party_gle_currency - and (party_account_currency != self.currency) - and not allow_multi_currency_invoices_against_single_party_account - ): - frappe.throw( - _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format( - frappe.bold(party_account), party_account_currency, self.currency - ) - ) + return PartyValidator(self).get_party() def delink_advance_entries(self, linked_doc_name): from erpnext.accounts.services.advances import delink_advance_entries @@ -1666,61 +1340,20 @@ class AccountsController(TransactionBase): else: return frappe.db.get_single_value("Global Defaults", "disable_rounded_total") - def set_inter_company_account(self): - """ - Set intercompany account for inter warehouse transactions - This account will be used in case billing company and internal customer's - representation company is same - """ + def is_internal_transfer(self) -> bool: + from erpnext.accounts.services.internal_transfer import InternalTransferService - if self.is_internal_transfer() and not self.unrealized_profit_loss_account: - unrealized_profit_loss_account = frappe.get_cached_value( - "Company", self.company, "unrealized_profit_loss_account" - ) + return InternalTransferService(self).is_internal_transfer() - if not unrealized_profit_loss_account: - msg = _( - "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}" - ).format(frappe.bold(self.company)) - frappe.throw(msg) + def process_common_party_accounting(self) -> None: + from erpnext.accounts.services.internal_transfer import InternalTransferService - self.unrealized_profit_loss_account = unrealized_profit_loss_account + InternalTransferService(self).process_common_party_accounting() - def is_internal_transfer(self): - """ - It will an internal transfer if its an internal customer and representation - company is same as billing company - """ - if self.doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): - internal_party_field = "is_internal_customer" - elif self.doctype in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"): - internal_party_field = "is_internal_supplier" - else: - return False + def get_common_party_link(self) -> frappe._dict | None: + from erpnext.accounts.services.internal_transfer import InternalTransferService - if self.get(internal_party_field) and (self.represents_company == self.company): - return True - - return False - - def process_common_party_accounting(self): - is_invoice = self.doctype in ["Sales Invoice", "Purchase Invoice"] - if not is_invoice: - return - - if frappe.get_single_value("Accounts Settings", "enable_common_party_accounting"): - party_link = self.get_common_party_link() - if party_link and self.outstanding_amount: - self.create_advance_and_reconcile(party_link) - - def get_common_party_link(self): - party_type, party = self.get_party() - return frappe.db.get_value( - doctype="Party Link", - filters={"secondary_role": party_type, "secondary_party": party}, - fieldname=["primary_role", "primary_party"], - as_dict=True, - ) + return InternalTransferService(self).get_common_party_link() def create_advance_and_reconcile(self, party_link): from erpnext.accounts.services.advances import create_advance_and_reconcile From a12d6660378f3b4aca9acb7841cd885eaf0ebbb5 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 28 May 2026 20:38:25 +0530 Subject: [PATCH 33/65] refactor: extract child item update cluster into ChildItemUpdater service accounts/services/child_item_update.py: - ChildItemUpdater class with update() entry point encapsulating all the logic from the old update_child_qty_rate free function; nested closures (check_doc_permissions, validate_workflow_conditions, validate_quantity_and_rate, validate_fg_item_for_subcontracting) become private methods on the class - update_child_qty_rate kept as @frappe.whitelist() thin wrapper; re-exported from accounts_controller.py so the JS whitelist path "erpnext.controllers.accounts_controller.update_child_qty_rate" and test imports continue to work - Free functions: set_order_defaults, validate_child_on_delete, update_bin_on_delete, validate_and_delete_children, get_allow_zero_qty, get_child_item_change_state, is_child_item_unchanged, update_child_item_rate_and_discount, update_child_item_uom_and_weight, check_if_child_table_updated accounts_controller.py drops from ~2356 to ~1796 lines. --- .../accounts/services/child_item_update.py | 593 ++++++++++++++++++ erpnext/controllers/accounts_controller.py | 565 +---------------- 2 files changed, 596 insertions(+), 562 deletions(-) create mode 100644 erpnext/accounts/services/child_item_update.py diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py new file mode 100644 index 00000000000..c3b83302272 --- /dev/null +++ b/erpnext/accounts/services/child_item_update.py @@ -0,0 +1,593 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Child item update service: ChildItemUpdater class and helpers for the update_child_qty_rate API.""" + +import frappe +from frappe import _ +from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied +from frappe.utils import flt, get_link_to_form, getdate + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.buying.utils import update_last_purchase_rate +from erpnext.stock.doctype.packed_item.packed_item import make_packing_list +from erpnext.stock.get_item_details import ( + get_bin_details, + get_conversion_factor, + get_item_warehouse_, +) + + +class ChildItemUpdater: + """Validates and applies item-level edits on submitted orders and quotations.""" + + def __init__(self, parent_doctype: str, parent_doctype_name: str, child_docname: str = "items"): + self.parent_doctype = parent_doctype + self.parent_doctype_name = parent_doctype_name + self.child_docname = child_docname + self.parent = frappe.get_doc(parent_doctype, parent_doctype_name) + self.allow_zero_qty = get_allow_zero_qty(parent_doctype) + self._ordered_items: dict | None = None + self._purchased_items: dict | None = None + + def update(self, trans_items: str) -> None: + """Process item additions, edits, and deletions from trans_items JSON.""" + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.quotation import get_ordered_items + + data = frappe.parse_json(trans_items) + any_qty_changed = False + items_added_or_removed = False + any_conversion_factor_changed = False + + self._check_permissions("write") + + if self.parent_doctype == "Quotation": + self._ordered_items = get_ordered_items(self.parent.name) + items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items) + elif self.parent_doctype == "Supplier Quotation": + self._purchased_items = get_purchased_items(self.parent.name) + items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items) + else: + items_added_or_removed |= validate_and_delete_children(self.parent, data) + + for d in data: + new_child_flag = False + rate_unchanged = None + + if not d.get("item_code"): + continue + + if not d.get("docname"): + new_child_flag = True + items_added_or_removed = True + self._check_permissions("create") + child_item = self._get_new_child_item(d) + else: + self._check_permissions("write") + child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname")) + + change_state = get_child_item_change_state(self.parent_doctype, child_item, d) + rate_unchanged = change_state.rate_unchanged + any_conversion_factor_changed |= not change_state.conversion_factor_unchanged + if is_child_item_unchanged(change_state): + continue + + self._validate_quantity_and_rate(child_item, d, rate_unchanged) + + if flt(child_item.get("qty")) != flt(d.get("qty")): + any_qty_changed = True + + if self.parent.doctype in ("Sales Order", "Purchase Order") and self.parent.is_subcontracted: + self._validate_fg_item_for_subcontracting(d, new_child_flag) + child_item.fg_item_qty = flt(d["fg_item_qty"]) + if new_child_flag: + child_item.fg_item = d["fg_item"] + + child_item.qty = flt(d.get("qty")) + child_item.description = d.get("description") + update_child_item_rate_and_discount( + self.parent_doctype, child_item, d, self.allow_zero_qty, rate_unchanged=rate_unchanged + ) + update_child_item_uom_and_weight(child_item, d) + + if d.get("delivery_date") and self.parent_doctype == "Sales Order": + child_item.delivery_date = d.get("delivery_date") + + if d.get("schedule_date") and self.parent_doctype == "Purchase Order": + child_item.schedule_date = d.get("schedule_date") + + if d.get("bom_no") and self.parent_doctype == "Sales Order": + child_item.bom_no = d.get("bom_no") + + child_item.flags.ignore_validate_update_after_submit = True + if new_child_flag: + self.parent.load_from_db() + child_item.idx = len(self.parent.items) + 1 + child_item.insert() + else: + child_item.save(ignore_permissions=True) + + self._post_update(any_qty_changed, items_added_or_removed, any_conversion_factor_changed) + + def _post_update( + self, any_qty_changed: bool, items_added_or_removed: bool, any_conversion_factor_changed: bool + ) -> None: + parent = self.parent + parent.reload() + parent.flags.ignore_validate_update_after_submit = True + parent.set_qty_as_per_stock_uom() + parent.calculate_taxes_and_totals() + parent.set_total_in_words() + + if self.parent_doctype == "Sales Order" and not parent.is_subcontracted: + make_packing_list(parent) + parent.set_gross_profit() + + frappe.get_cached_doc("Authorization Control").validate_approving_authority( + parent.doctype, parent.company, parent.base_grand_total + ) + + if self.parent_doctype != "Supplier Quotation": + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(parent).set_payment_schedule() + + if self.parent_doctype == "Purchase Order": + parent.validate_minimum_order_qty() + parent.validate_budget() + if parent.is_against_so(): + parent.update_status_updater() + elif self.parent_doctype == "Sales Order": + parent.check_credit_limit() + + for idx, row in enumerate(parent.get(self.child_docname), start=1): + row.idx = idx + + parent.save() + + if self.parent_doctype == "Purchase Order": + update_last_purchase_rate(parent, is_submit=1) + + if any_qty_changed or items_added_or_removed or any_conversion_factor_changed: + parent.update_prevdoc_status() + + parent.update_requested_qty() + parent.update_ordered_qty() + parent.update_ordered_and_reserved_qty() + parent.update_receiving_percentage() + + if parent.is_subcontracted and not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." + ).format(frappe.bold(parent.name)) + ) + + elif self.parent_doctype == "Sales Order": + if parent.is_subcontracted and not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." + ) + ) + parent.validate_selling_price() + parent.validate_for_duplicate_items() + parent.validate_warehouse() + parent.update_reserved_qty() + parent.update_project() + parent.update_prevdoc_status("submit") + parent.update_delivery_status() + + parent.reload() + self._validate_workflow() + + if self.parent_doctype in ("Purchase Order", "Sales Order"): + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() + + parent.validate_uom_is_integer("uom", "qty") + parent.validate_uom_is_integer("stock_uom", "stock_qty") + + if self.parent_doctype == "Sales Order" and not parent.is_subcontracted: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + has_reserved_stock, + ) + + if has_reserved_stock(parent.doctype, parent.name): + cancel_stock_reservation_entries(parent.doctype, parent.name) + if parent.per_picked == 0: + parent.create_stock_reservation_entries() + + def _check_permissions(self, perm_type: str = "create") -> None: + try: + self.parent.check_permission(perm_type) + except frappe.PermissionError: + actions = {"create": "add", "write": "update"} + frappe.throw( + _("You do not have permissions to {} items in a {}.").format( + actions[perm_type], self.parent_doctype + ), + title=_("Insufficient Permissions"), + ) + + def _validate_workflow(self) -> None: + workflow = get_workflow_name(self.parent.doctype) + if not workflow: + return + + workflow_doc = frappe.get_doc("Workflow", workflow) + current_state = self.parent.get(workflow_doc.workflow_state_field) + roles = frappe.get_roles() + + transitions = [ + t.as_dict() + for t in workflow_doc.transitions + if t.next_state == current_state + and t.allowed in roles + and is_transition_condition_satisfied(t, self.parent) + ] + + if not transitions: + frappe.throw( + _("You are not allowed to update as per the conditions set in {} Workflow.").format( + get_link_to_form("Workflow", workflow) + ), + title=_("Insufficient Permissions"), + ) + + def _get_new_child_item(self, item_row) -> "frappe.model.document.Document": + child_doctype = self.parent_doctype + " Item" + return set_order_defaults( + self.parent_doctype, + self.parent_doctype_name, + child_doctype, + self.child_docname, + item_row, + ) + + def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None: + if not flt(new_data.get("qty")) and not self.allow_zero_qty: + frappe.throw( + _("Row #{0}:Quantity for Item {1} cannot be zero.").format( + new_data.get("idx"), frappe.bold(new_data.get("item_code")) + ), + title=_("Invalid Qty"), + ) + + qty_limits = { + "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")), + "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")), + } + + if self.parent_doctype in qty_limits: + qty_field, error_message = qty_limits[self.parent_doctype] + if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): + frappe.throw( + _("Row #{0}:").format(new_data.get("idx")) + error_message, + title=_("Invalid Qty"), + ) + + if self.parent_doctype not in ("Quotation", "Supplier Quotation"): + return + + items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items + if not items_map: + return + + qty_to_check = items_map.get(child_item.name) + if not qty_to_check: + return + + if not rate_unchanged: + frappe.throw( + _( + "Cannot update rate as item {0} is already ordered or purchased against this quotation" + ).format(frappe.bold(new_data.get("item_code"))) + ) + + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + + def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None: + if is_new: + if not new_data.get("fg_item"): + frappe.throw( + _("Finished Good Item is not specified for service item {0}").format( + new_data["item_code"] + ) + ) + + is_sub_contracted_item, default_bom = frappe.db.get_value( + "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"] + ) + + if not is_sub_contracted_item: + frappe.throw( + _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"]) + ) + elif not default_bom: + frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"])) + + if not new_data.get("fg_item_qty"): + frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) + + +@frappe.whitelist() +def update_child_qty_rate( + parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items" +) -> None: + ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items) + + +def set_order_defaults( + parent_doctype: str, + parent_doctype_name: str, + child_doctype: str, + child_docname: str, + trans_item: dict, +) -> "frappe.model.document.Document": + """Return a new child item populated with item master defaults.""" + from erpnext.accounts.services.taxes import add_taxes_from_tax_template, set_child_tax_template_and_map + + p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) + child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname) + item = frappe.get_doc("Item", trans_item.get("item_code")) + + for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"): + child_item.update({field: item.get(field)}) + + date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" + child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) + child_item.stock_uom = item.stock_uom + child_item.uom = trans_item.get("uom") or item.stock_uom + child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True) + conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) + child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor + child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + child_item.base_rate = 1 + child_item.base_amount = 1 + + if child_doctype == "Sales Order Item": + child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True) + if not child_item.warehouse: + frappe.throw( + _( + "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings." + ).format(frappe.bold(item.item_code)) + ) + + set_child_tax_template_and_map(item, child_item, p_doc) + add_taxes_from_tax_template(child_item, p_doc) + return child_item + + +def validate_child_on_delete(row, parent, ordered_item=None) -> None: + """Raise if a partially transacted child item is being deleted.""" + if parent.doctype == "Sales Order": + if flt(row.delivered_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been delivered").format( + row.idx, row.item_code + ) + ) + if flt(row.work_order_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format( + row.idx, row.item_code + ) + ) + if flt(row.ordered_qty): + frappe.throw( + _( + "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order." + ).format(row.idx, row.item_code) + ) + + if parent.doctype == "Purchase Order" and flt(row.received_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been received").format( + row.idx, row.item_code + ) + ) + + if parent.doctype in ("Purchase Order", "Sales Order") and flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) + ) + + if parent.doctype == "Quotation" and ordered_item and ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) + + +def update_bin_on_delete(row, doctype: str) -> None: + """Update bin quantities after a child item row is deleted.""" + from erpnext.stock.stock_balance import ( + get_indented_qty, + get_ordered_qty, + get_reserved_qty, + update_bin_qty, + ) + + qty_dict = {} + + if doctype == "Sales Order": + qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse) + else: + if row.material_request_item: + qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse) + qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) + + if row.warehouse: + update_bin_qty(row.item_code, row.warehouse, qty_dict) + + +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: + """Delete child rows not present in data; return True if any were removed.""" + updated_item_names = [d.get("docname") for d in data] + deleted_children = [item for item in parent.items if item.name not in updated_item_names] + + for d in deleted_children: + validate_child_on_delete(d, parent, ordered_item) + d.cancel() + d.delete() + + if parent.doctype == "Purchase Order": + parent.update_ordered_qty_in_so_for_removed_items(deleted_children) + + if parent.doctype not in ("Quotation", "Supplier Quotation"): + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) + + return bool(deleted_children) + + +def get_allow_zero_qty(parent_doctype: str) -> bool: + if parent_doctype == "Sales Order": + return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False + if parent_doctype == "Purchase Order": + return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False + return False + + +def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict: + prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) + prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty")) + prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty")) + prev_con_fac = flt(child_item.get("conversion_factor")) + new_con_fac = flt(new_data.get("conversion_factor")) + + if parent_doctype == "Sales Order": + prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date") + elif parent_doctype == "Purchase Order": + prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date") + else: + prev_date, new_date = None, None + + if parent_doctype in ("Quotation", "Supplier Quotation"): + date_unchanged = False + else: + prev_date = getdate(prev_date) if prev_date else None + new_date = getdate(new_date) if new_date else None + date_unchanged = prev_date == new_date + + return frappe._dict( + rate_unchanged=prev_rate == new_rate, + qty_unchanged=prev_qty == new_qty, + fg_qty_unchanged=prev_fg_qty == new_fg_qty, + uom_unchanged=child_item.get("uom") == new_data.get("uom"), + conversion_factor_unchanged=prev_con_fac == new_con_fac, + date_unchanged=date_unchanged, + description_unchanged=child_item.get("description") == new_data.get("description"), + ) + + +def is_child_item_unchanged(change_state: frappe._dict) -> bool: + return ( + change_state.rate_unchanged + and change_state.qty_unchanged + and change_state.fg_qty_unchanged + and change_state.conversion_factor_unchanged + and change_state.uom_unchanged + and change_state.date_unchanged + and change_state.description_unchanged + ) + + +def update_child_item_rate_and_discount( + parent_doctype: str, + child_item, + new_data, + allow_zero_qty: bool, + rate_unchanged: bool | None = None, +) -> None: + rate_precision = child_item.precision("rate") or 2 + qty_precision = child_item.precision("qty") or 2 + + if rate_unchanged is None: + rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate")) + + if not rate_unchanged and not child_item.get("qty") and allow_zero_qty: + frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) + + row_rate = flt(new_data.get("rate"), rate_precision) + + if parent_doctype in ("Purchase Order", "Sales Order"): + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(new_data.get("qty"), qty_precision), rate_precision + ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + + child_item.rate = row_rate + + if parent_doctype not in ("Sales Order", "Purchase Order") or not flt(child_item.price_list_rate): + return + + if flt(child_item.rate) > flt(child_item.price_list_rate): + child_item.discount_percentage = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.discount_percentage = flt( + (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, + child_item.precision("discount_percentage"), + ) + child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 + + +def update_child_item_uom_and_weight(child_item, new_data) -> None: + conv_fac_precision = child_item.precision("conversion_factor") or 2 + + if new_data.get("conversion_factor"): + if child_item.stock_uom == child_item.uom: + child_item.conversion_factor = 1 + else: + child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision) + + if new_data.get("uom"): + child_item.uom = new_data.get("uom") + conversion_factor = flt( + get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor") + ) + child_item.conversion_factor = ( + flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor + ) + + if child_item.get("weight_per_unit"): + child_item.total_weight = flt( + child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, + child_item.precision("total_weight"), + ) + + +def check_if_child_table_updated( + child_table_before_update, child_table_after_update, fields_to_check +) -> bool: + """Return True if any accounting-relevant field changed in a child table.""" + fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"] + + for index, item in enumerate(child_table_before_update): + for field in fields_to_check: + if child_table_after_update[index].get(field) != item.get(field): + return True + + return False diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index e3fae9b2b13..563d9f516dd 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -8,7 +8,6 @@ from collections import defaultdict import frappe from frappe import _, bold, qb, throw from frappe.contacts.doctype.address.address import get_address_display -from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied from frappe.query_builder import DocType from frappe.query_builder.functions import Sum from frappe.utils import ( @@ -40,7 +39,6 @@ from erpnext.accounts.utils import ( get_advance_payment_doctypes as _get_advance_payment_doctypes, ) from erpnext.accounts.utils import validate_fiscal_year -from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, set_print_templates_for_taxes, @@ -48,13 +46,9 @@ from erpnext.controllers.print_settings import ( from erpnext.controllers.sales_and_purchase_return import validate_return from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor -from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.get_item_details import ( ItemDetailsCtx, - get_bin_details, - get_conversion_factor, get_item_details, - get_item_warehouse_, ) from erpnext.utilities.regional import temporary_flag from erpnext.utilities.transaction_base import TransactionBase @@ -1388,17 +1382,16 @@ class AccountsController(TransactionBase): ) def check_if_fields_updated(self, fields_to_check, child_tables): - # Check if any field affecting accounting entry is altered + from erpnext.accounts.services.child_item_update import check_if_child_table_updated + doc_before_update = self.get_doc_before_save() accounting_dimensions = [*get_accounting_dimensions(), "cost_center", "project"] - # Parent Level Accounts excluding party account fields_to_check += accounting_dimensions for field in fields_to_check: if doc_before_update.get(field) != self.get(field): return True - # Check for child tables for table in child_tables: if check_if_child_table_updated( doc_before_update.get(table), self.get(table), child_tables[table] @@ -1623,559 +1616,7 @@ def get_supplier_block_status(party_name): return info -def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, trans_item): - """ - Returns a Sales/Purchase Order Item child item containing the default values - """ - p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) - child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname) - item = frappe.get_doc("Item", trans_item.get("item_code")) - - for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"): - child_item.update({field: item.get(field)}) - - date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" - child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) - child_item.stock_uom = item.stock_uom - child_item.uom = trans_item.get("uom") or item.stock_uom - child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True) - conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) - child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) - - if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: - # Initialized value will update in parent validation - child_item.base_rate = 1 - child_item.base_amount = 1 - if child_doctype == "Sales Order Item": - child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True) - if not child_item.warehouse: - frappe.throw( - _( - "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings." - ).format(frappe.bold(item.item_code)) - ) - - set_child_tax_template_and_map(item, child_item, p_doc) - add_taxes_from_tax_template(child_item, p_doc) - return child_item - - -def validate_child_on_delete(row, parent, ordered_item=None): - """Check if partially transacted item (row) is being deleted.""" - if parent.doctype == "Sales Order": - if flt(row.delivered_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been delivered").format( - row.idx, row.item_code - ) - ) - if flt(row.work_order_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format( - row.idx, row.item_code - ) - ) - if flt(row.ordered_qty): - frappe.throw( - _( - "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order." - ).format(row.idx, row.item_code) - ) - - if parent.doctype == "Purchase Order" and flt(row.received_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been received").format( - row.idx, row.item_code - ) - ) - if parent.doctype in ["Purchase Order", "Sales Order"]: - if flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code - ) - ) - - if parent.doctype == "Quotation": - if ordered_item.get(row.name): - frappe.throw(_("Cannot delete an item which has been ordered")) - - -def update_bin_on_delete(row, doctype): - """Update bin for deleted item (row).""" - from erpnext.stock.stock_balance import ( - get_indented_qty, - get_ordered_qty, - get_reserved_qty, - update_bin_qty, - ) - - qty_dict = {} - - if doctype == "Sales Order": - qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse) - else: - if row.material_request_item: - qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse) - - qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - - if row.warehouse: - update_bin_qty(row.item_code, row.warehouse, qty_dict) - - -def validate_and_delete_children(parent, data, ordered_item=None) -> bool: - deleted_children = [] - updated_item_names = [d.get("docname") for d in data] - for item in parent.items: - if item.name not in updated_item_names: - deleted_children.append(item) - - for d in deleted_children: - validate_child_on_delete(d, parent, ordered_item) - d.cancel() - d.delete() - - if parent.doctype == "Purchase Order": - parent.update_ordered_qty_in_so_for_removed_items(deleted_children) - - # need to update ordered qty in Material Request first - # bin uses Material Request Items to recalculate & update - if parent.doctype not in ["Quotation", "Supplier Quotation"]: - parent.update_prevdoc_status() - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) - - return bool(deleted_children) - - -def get_allow_zero_qty(parent_doctype: str) -> bool: - if parent_doctype == "Sales Order": - return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False - if parent_doctype == "Purchase Order": - return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False - return False - - -def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict: - prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) - prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty")) - prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty")) - prev_con_fac, new_con_fac = ( - flt(child_item.get("conversion_factor")), - flt(new_data.get("conversion_factor")), - ) - - if parent_doctype == "Sales Order": - prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date") - elif parent_doctype == "Purchase Order": - prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date") - else: - prev_date, new_date = None, None - - if parent_doctype in ["Quotation", "Supplier Quotation"]: - date_unchanged = False - else: - prev_date = getdate(prev_date) if prev_date else None - new_date = getdate(new_date) if new_date else None - date_unchanged = prev_date == new_date - - return frappe._dict( - rate_unchanged=prev_rate == new_rate, - qty_unchanged=prev_qty == new_qty, - fg_qty_unchanged=prev_fg_qty == new_fg_qty, - uom_unchanged=child_item.get("uom") == new_data.get("uom"), - conversion_factor_unchanged=prev_con_fac == new_con_fac, - date_unchanged=date_unchanged, - description_unchanged=child_item.get("description") == new_data.get("description"), - ) - - -def is_child_item_unchanged(change_state: frappe._dict) -> bool: - return ( - change_state.rate_unchanged - and change_state.qty_unchanged - and change_state.fg_qty_unchanged - and change_state.conversion_factor_unchanged - and change_state.uom_unchanged - and change_state.date_unchanged - and change_state.description_unchanged - ) - - -def update_child_item_rate_and_discount( - parent_doctype: str, child_item, new_data, allow_zero_qty: bool, rate_unchanged: bool | None = None -) -> None: - rate_precision = child_item.precision("rate") or 2 - qty_precision = child_item.precision("qty") or 2 - - if rate_unchanged is None: - prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) - rate_unchanged = prev_rate == new_rate - - if not rate_unchanged and not child_item.get("qty") and allow_zero_qty: - frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - - # Amount cannot be lesser than billed amount, except for negative amounts - row_rate = flt(new_data.get("rate"), rate_precision) - - if parent_doctype in ["Purchase Order", "Sales Order"]: - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(new_data.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) - ) - - child_item.rate = row_rate - - if parent_doctype not in ["Sales Order", "Purchase Order"] or not flt(child_item.price_list_rate): - return - - if flt(child_item.rate) > flt(child_item.price_list_rate): - # if rate is greater than price_list_rate, set margin or set discount - child_item.discount_percentage = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.discount_percentage = flt( - (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, - child_item.precision("discount_percentage"), - ) - child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 - - -def update_child_item_uom_and_weight(child_item, new_data) -> None: - conv_fac_precision = child_item.precision("conversion_factor") or 2 - - if new_data.get("conversion_factor"): - if child_item.stock_uom == child_item.uom: - child_item.conversion_factor = 1 - else: - child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision) - - if new_data.get("uom"): - child_item.uom = new_data.get("uom") - conversion_factor = flt( - get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor") - ) - child_item.conversion_factor = ( - flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor - ) - - if child_item.get("weight_per_unit"): - child_item.total_weight = flt( - child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, - child_item.precision("total_weight"), - ) - - -@frappe.whitelist() -def update_child_qty_rate( - parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items" -): - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items - from erpnext.selling.doctype.quotation.quotation import get_ordered_items - - def check_doc_permissions(doc, perm_type="create"): - try: - doc.check_permission(perm_type) - except frappe.PermissionError: - actions = {"create": "add", "write": "update"} - - frappe.throw( - _("You do not have permissions to {} items in a {}.").format( - actions[perm_type], parent_doctype - ), - title=_("Insufficient Permissions"), - ) - - def validate_workflow_conditions(doc): - workflow = get_workflow_name(doc.doctype) - if not workflow: - return - - workflow_doc = frappe.get_doc("Workflow", workflow) - current_state = doc.get(workflow_doc.workflow_state_field) - roles = frappe.get_roles() - - transitions = [] - for transition in workflow_doc.transitions: - if transition.next_state == current_state and transition.allowed in roles: - if not is_transition_condition_satisfied(transition, doc): - continue - transitions.append(transition.as_dict()) - - if not transitions: - frappe.throw( - _("You are not allowed to update as per the conditions set in {} Workflow.").format( - get_link_to_form("Workflow", workflow) - ), - title=_("Insufficient Permissions"), - ) - - def get_new_child_item(item_row): - child_doctype = parent_doctype + " Item" - return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) - - def validate_quantity_and_rate(child_item, new_data): - if not flt(new_data.get("qty")) and not allow_zero_qty: - frappe.throw( - _("Row #{0}:Quantity for Item {1} cannot be zero.").format( - new_data.get("idx"), frappe.bold(new_data.get("item_code")) - ), - title=_("Invalid Qty"), - ) - - qty_limits = { - "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")), - "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")), - } - - if parent_doctype in qty_limits: - qty_field, error_message = qty_limits[parent_doctype] - if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): - frappe.throw( - _("Row #{0}:").format(new_data.get("idx")) - + error_message.format(frappe.bold(new_data.get("item_code"))), - title=_("Invalid Qty"), - ) - - if parent_doctype in ["Quotation", "Supplier Quotation"]: - if (parent_doctype == "Quotation" and not ordered_items) or ( - parent_doctype == "Supplier Quotation" and not purchased_items - ): - return - - qty_to_check = ( - ordered_items.get(child_item.name) - if parent_doctype == "Quotation" - else purchased_items.get(child_item.name) - ) - - if qty_to_check: - if not rate_unchanged: - frappe.throw( - _( - "Cannot update rate as item {0} is already ordered or purchased against this quotation" - ).format(frappe.bold(new_data.get("item_code"))) - ) - - if flt(new_data.get("qty")) < qty_to_check: - frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) - - def validate_fg_item_for_subcontracting(new_data, is_new): - if is_new: - if not new_data.get("fg_item"): - frappe.throw( - _("Finished Good Item is not specified for service item {0}").format( - new_data["item_code"] - ) - ) - else: - is_sub_contracted_item, default_bom = frappe.db.get_value( - "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"] - ) - - if not is_sub_contracted_item: - frappe.throw( - _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"]) - ) - elif not default_bom: - frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"])) - - if not new_data.get("fg_item_qty"): - frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) - - data = json.loads(trans_items) - any_qty_changed = False # updated to true if any item's qty changes - items_added_or_removed = False # updated to true if any new item is added or removed - any_conversion_factor_changed = False - - parent = frappe.get_doc(parent_doctype, parent_doctype_name) - allow_zero_qty = get_allow_zero_qty(parent_doctype) - - check_doc_permissions(parent, "write") - - if parent_doctype == "Quotation": - ordered_items = get_ordered_items(parent.name) - _removed_items = validate_and_delete_children(parent, data, ordered_items) - elif parent_doctype == "Supplier Quotation": - purchased_items = get_purchased_items(parent.name) - _removed_items = validate_and_delete_children(parent, data, purchased_items) - else: - _removed_items = validate_and_delete_children(parent, data) - - items_added_or_removed |= _removed_items - - for d in data: - new_child_flag = False - rate_unchanged = None - - if not d.get("item_code"): - # ignore empty rows - continue - - if not d.get("docname"): - new_child_flag = True - items_added_or_removed = True - check_doc_permissions(parent, "create") - child_item = get_new_child_item(d) - else: - check_doc_permissions(parent, "write") - child_item = frappe.get_doc(parent_doctype + " Item", d.get("docname")) - - change_state = get_child_item_change_state(parent_doctype, child_item, d) - rate_unchanged = change_state.rate_unchanged - any_conversion_factor_changed |= not change_state.conversion_factor_unchanged - if is_child_item_unchanged(change_state): - continue - - validate_quantity_and_rate(child_item, d) - - if flt(child_item.get("qty")) != flt(d.get("qty")): - any_qty_changed = True - - if parent.doctype in ["Sales Order", "Purchase Order"] and parent.is_subcontracted: - validate_fg_item_for_subcontracting(d, new_child_flag) - child_item.fg_item_qty = flt(d["fg_item_qty"]) - - if new_child_flag: - child_item.fg_item = d["fg_item"] - - child_item.qty = flt(d.get("qty")) - child_item.description = d.get("description") - update_child_item_rate_and_discount( - parent_doctype, child_item, d, allow_zero_qty, rate_unchanged=rate_unchanged - ) - update_child_item_uom_and_weight(child_item, d) - - if d.get("delivery_date") and parent_doctype == "Sales Order": - child_item.delivery_date = d.get("delivery_date") - - if d.get("schedule_date") and parent_doctype == "Purchase Order": - child_item.schedule_date = d.get("schedule_date") - - if d.get("bom_no") and parent_doctype == "Sales Order": - child_item.bom_no = d.get("bom_no") - - child_item.flags.ignore_validate_update_after_submit = True - if new_child_flag: - parent.load_from_db() - child_item.idx = len(parent.items) + 1 - child_item.insert() - else: - child_item.save(ignore_permissions=True) - - parent.reload() - parent.flags.ignore_validate_update_after_submit = True - parent.set_qty_as_per_stock_uom() - parent.calculate_taxes_and_totals() - parent.set_total_in_words() - if parent_doctype == "Sales Order" and not parent.is_subcontracted: - make_packing_list(parent) - parent.set_gross_profit() - frappe.get_cached_doc("Authorization Control").validate_approving_authority( - parent.doctype, parent.company, parent.base_grand_total - ) - - if parent_doctype != "Supplier Quotation": - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(parent).set_payment_schedule() - if parent_doctype == "Purchase Order": - parent.validate_minimum_order_qty() - parent.validate_budget() - if parent.is_against_so(): - parent.update_status_updater() - elif parent_doctype == "Sales Order": - parent.check_credit_limit() - - # reset index of child table - for idx, row in enumerate(parent.get(child_docname), start=1): - row.idx = idx - - parent.save() - - if parent_doctype == "Purchase Order": - update_last_purchase_rate(parent, is_submit=1) - - if any_qty_changed or items_added_or_removed or any_conversion_factor_changed: - parent.update_prevdoc_status() - - parent.update_requested_qty() - parent.update_ordered_qty() - parent.update_ordered_and_reserved_qty() - parent.update_receiving_percentage() - - if parent.is_subcontracted: - if not parent.can_update_items(): - frappe.throw( - _( - "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." - ).format(frappe.bold(parent.name)) - ) - elif parent_doctype == "Sales Order": # Sales Order - if parent.is_subcontracted and not parent.can_update_items(): - frappe.throw( - _( - "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." - ) - ) - parent.validate_selling_price() - parent.validate_for_duplicate_items() - parent.validate_warehouse() - parent.update_reserved_qty() - parent.update_project() - parent.update_prevdoc_status("submit") - parent.update_delivery_status() - - parent.reload() - validate_workflow_conditions(parent) - - if parent_doctype in ["Purchase Order", "Sales Order"]: - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() - - parent.validate_uom_is_integer("uom", "qty") - parent.validate_uom_is_integer("stock_uom", "stock_qty") - - # Cancel and Recreate Stock Reservation Entries. - if parent_doctype == "Sales Order" and not parent.is_subcontracted: - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - cancel_stock_reservation_entries, - has_reserved_stock, - ) - - if has_reserved_stock(parent.doctype, parent.name): - cancel_stock_reservation_entries(parent.doctype, parent.name) - - if parent.per_picked == 0: - parent.create_stock_reservation_entries() - - -def check_if_child_table_updated(child_table_before_update, child_table_after_update, fields_to_check): - fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"] - - # Check if any field affecting accounting entry is altered - for index, item in enumerate(child_table_before_update): - for field in fields_to_check: - if child_table_after_update[index].get(field) != item.get(field): - return True - - return False +from erpnext.accounts.services.child_item_update import update_child_qty_rate @erpnext.allow_regional From 0a0272763808a85e9eefabd5a32b214eba5a79f2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 04:03:30 +0530 Subject: [PATCH 34/65] fix: move ignore_linked_doctypes assignment to on_cancel in AssetRepair Semgrep rule frappe-modifying-but-not-comitting-other-method flags setting self.ignore_linked_doctypes inside make_gl_entries() instead of in the calling on_cancel method. Follows the same pattern used by AssetCapitalization. --- erpnext/assets/doctype/asset_repair/asset_repair.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 6347379d577..eb2a5e68c59 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -218,6 +218,7 @@ class AssetRepair(AccountsController): def on_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) if self.get("capitalize_repair_cost"): + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") self.update_asset_value() self.make_gl_entries(cancel=True) self.set_increase_in_asset_life() @@ -306,9 +307,6 @@ class AssetRepair(AccountsController): ) def make_gl_entries(self, cancel=False): - if cancel: - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") - if flt(self.total_repair_cost) > 0: gl_entries = self.get_gl_entries() make_gl_entries(gl_entries, cancel) From 25e3d6042a5e6c9cc13c113c1d21d8dd5f548008 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:14:27 +0530 Subject: [PATCH 35/65] refactor(sales_order): move mapping functions to mapper.py Separates all make_*/create_* document-creation functions from the SalesOrder controller into a dedicated mapper.py for better separation of concerns. Re-exports from sales_order.py preserve backward compat. --- erpnext/selling/doctype/sales_order/mapper.py | 1102 ++++++++++++++++ .../doctype/sales_order/sales_order.py | 1121 +---------------- 2 files changed, 1120 insertions(+), 1103 deletions(-) create mode 100644 erpnext/selling/doctype/sales_order/mapper.py diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py new file mode 100644 index 00000000000..967a423451c --- /dev/null +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -0,0 +1,1102 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.contacts.doctype.address.address import get_company_address +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.model.utils import get_fetch_values +from frappe.query_builder.functions import Sum +from frappe.utils import add_days, cint, flt, nowdate, strip_html + +from erpnext.accounts.party import get_party_account +from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_items_for_material_requests, + get_sales_orders, +) +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle, make_packing_list +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_details_for_voucher, + get_sre_reserved_qty_details_for_voucher, + get_ssb_bundle_for_voucher, +) +from erpnext.stock.get_item_details import ItemDetailsCtx, get_bin_details, get_price_list_rate + + +def get_requested_item_qty(sales_order: str) -> dict: + result = {} + + so = frappe.get_doc("Sales Order", sales_order) + + for item in so.items: + if is_product_bundle(item.item_code): + for packed_item in so.get("packed_items"): + if ( + packed_item.parent_item == item.item_code + and packed_item.parent_detail_docname == item.name + ): + result[packed_item.name] = frappe._dict({"qty": packed_item.requested_qty}) + else: + result[item.name] = frappe._dict({"qty": item.requested_qty}) + + return result + + +@frappe.whitelist() +def make_material_request(source_name: str, target_doc: str | Document | None = None): + requested_item_qty = get_requested_item_qty(source_name) + + def postprocess(source, target): + if source.tc_name and frappe.db.get_value("Terms and Conditions", source.tc_name, "buying") != 1: + target.tc_name = None + target.terms = None + + def get_remaining_qty(so_item): + return flt( + flt(so_item.qty) + - flt(requested_item_qty.get(so_item.name, {}).get("qty")) + - max( + flt(so_item.get("delivered_qty")), + 0, + ) + ) + + def get_remaining_packed_item_qty(so_item): + delivered_qty = frappe.db.get_value( + "Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"] + ) + + bundle_item_qty = frappe.db.get_value( + "Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"] + ) + + return flt( + flt(so_item.qty) + - flt(requested_item_qty.get(so_item.name, {}).get("qty")) + - max( + flt(delivered_qty) * flt(bundle_item_qty), + 0, + ) + ) + + def update_item(source, target, source_parent): + # qty is for packed items, because packed items don't have stock_qty field + target.project = source_parent.project + target.qty = ( + get_remaining_packed_item_qty(source) + if source.parentfield == "packed_items" + else get_remaining_qty(source) + ) + target.stock_qty = flt(target.qty) * flt(target.conversion_factor) + target.actual_qty = get_bin_details( + target.item_code, target.warehouse, source_parent.company, True + ).get("actual_qty", 0) + + ctx = ItemDetailsCtx(target.as_dict().copy()) + ctx.update( + { + "company": source_parent.get("company"), + "price_list": frappe.db.get_single_value("Buying Settings", "buying_price_list"), + "currency": source_parent.get("currency"), + "conversion_rate": source_parent.get("conversion_rate"), + } + ) + + target.rate = flt( + get_price_list_rate(ctx, item_doc=frappe.get_cached_doc("Item", target.item_code)).get( + "price_list_rate" + ) + ) + target.amount = target.qty * target.rate + + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": {"doctype": "Material Request", "validation": {"docstatus": ["=", 1]}}, + "Packed Item": { + "doctype": "Material Request Item", + "field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"}, + "condition": lambda item: get_remaining_packed_item_qty(item) > 0, + "postprocess": update_item, + }, + "Sales Order Item": { + "doctype": "Material Request Item", + "field_map": { + "name": "sales_order_item", + "parent": "sales_order", + "delivery_date": "schedule_date", + "bom_no": "bom_no", + }, + "condition": lambda item: not frappe.db.exists( + "Product Bundle", {"name": item.item_code, "disabled": 0} + ) + and get_remaining_qty(item) > 0, + "postprocess": update_item, + }, + }, + target_doc, + postprocess, + ) + if doc and doc.items: + return doc + else: + frappe.throw(_("Material Request already created for the ordered quantity")) + + +@frappe.whitelist() +def make_project(source_name: str, target_doc: str | Document | None = None): + def postprocess(source, doc): + doc.project_type = "External" + doc.project_name = source.name + + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Project", + "validation": {"docstatus": ["=", 1]}, + "field_map": { + "name": "sales_order", + "base_grand_total": "estimated_costing", + "net_total": "total_sales_amount", + }, + }, + }, + target_doc, + postprocess, + ) + + return doc + + +def set_serial_batch_for_bundle_reservation(source, target, use_serial_batch_fields, packed_sre): + for item in source.packed_items: + target_item = next( + ( + d + for d in target.packed_items + if (d.parent_item, d.item_code, d.warehouse) + == (item.parent_item, item.item_code, item.warehouse) + ), + None, + ) + if target_item and (sre := [sre for sre in packed_sre if sre.voucher_detail_no == item.name]): + if sre[0].reservation_based_on == "Serial and Batch": + qty = 0 + serial_nos = [] + batch_nos = [] + if use_serial_batch_fields: + target_item.use_serial_batch_fields = 1 + for item in sre: + qty += item.reserved_qty + if item.has_serial_no: + serial_nos.extend( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": item.name}, + pluck="serial_no", + ) + ) + if item.has_batch_no: + batch_nos.extend( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": item.name}, + pluck="batch_no", + ) + ) + + if len(batch_nos) == 1: + target_item.batch_no = batch_nos[0] if batch_nos else None + if serial_nos and len(batch_nos) < 2: + target_item.serial_no = "\n".join(serial_nos) + + if not use_serial_batch_fields or len(batch_nos) > 1: + target_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre).name + + +@frappe.whitelist() +def make_delivery_note( + source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None +): + if not kwargs: + kwargs = { + "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock, + "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping, + } + + kwargs = frappe._dict(kwargs) + + sre_details = {} + if kwargs.for_reserved_stock: + sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) + + mapper = { + "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, + "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + } + + # 0 qty is accepted, as the qty is uncertain for some items + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + + def select_item(d): + filtered_items = kwargs.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + def set_missing_values(source, target): + if kwargs.get("ignore_pricing_rule"): + # Skip pricing rule when the dn is creating from the pick list + target.ignore_pricing_rule = 1 + + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") + + if source.company_address: + target.update({"company_address": source.company_address}) + else: + # set company address + target.update(get_company_address(target.company)) + + if target.company_address: + target.update(get_fetch_values("Delivery Note", "company_address", target.company_address)) + + # if invoked in bulk creation, validations are ignored and thus this method is nerver invoked + if frappe.flags.bulk_transaction: + # set target items names to ensure proper linking with packed_items + target.set_new_name() + + make_packing_list(target) + + def condition(doc): + if doc.name in sre_details: + del sre_details[doc.name] + return False + + # make_mapped_doc sets js `args` into `frappe.flags.args` + if frappe.flags.args and frappe.flags.args.delivery_dates: + if frappe.utils.cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: + return False + if frappe.flags.args and frappe.flags.args.until_delivery_date: + if frappe.utils.cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date: + return False + + return ( + (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) + ) and doc.delivered_by_supplier != 1 + + def update_item(source, target, source_parent): + target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) + target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate) + target.qty = ( + flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty) + ) + + item = get_item_defaults(target.item_code, source_parent.company) + item_group = get_item_group_defaults(target.item_code, source_parent.company) + + if item: + target.cost_center = ( + frappe.db.get_value("Project", source_parent.project, "cost_center") + or item.get("buying_cost_center") + or item_group.get("buying_cost_center") + ) + + if not kwargs.skip_item_mapping: + mapper["Sales Order Item"] = { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "condition": lambda d: condition(d) and select_item(d), + "postprocess": update_item, + } + + so = frappe.get_doc("Sales Order", source_name) + target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc) + + packed_sre = [] + if not kwargs.skip_item_mapping and kwargs.for_reserved_stock: + sre_list = get_sre_details_for_voucher("Sales Order", source_name) + + if sre_list: + + def update_dn_item(source, target, source_parent): + update_item(source, target, so) + + so_items = {d.name: d for d in so.items if d.stock_reserved_qty} + + for sre in sre_list: + if not so_items.get(sre.voucher_detail_no): + packed_sre.append(sre) + continue + + if not condition(so_items[sre.voucher_detail_no]): + continue + + dn_item = get_mapped_doc( + "Sales Order Item", + sre.voucher_detail_no, + { + "Sales Order Item": { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "postprocess": update_dn_item, + } + }, + ignore_permissions=True, + ) + + dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1)) + dn_item.warehouse = sre.warehouse + + if ( + not use_serial_batch_fields + and sre.reservation_based_on == "Serial and Batch" + and (sre.has_serial_no or sre.has_batch_no) + ): + dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name + + target_doc.append("items", dn_item) + else: + # Correct rows index. + for idx, item in enumerate(target_doc.items): + item.idx = idx + 1 + + if not kwargs.skip_item_mapping and frappe.flags.bulk_transaction and not target_doc.items: + # the (date) condition filter resulted in an unintendedly created empty DN; remove it + del target_doc + return + + # Should be called after mapping items. + target_doc.packed_items = [] + set_missing_values(so, target_doc) + set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre) + + return target_doc + + +@frappe.whitelist() +def make_sales_invoice( + source_name: str, + target_doc: str | Document | None = None, + ignore_permissions: bool = False, + args: str | dict | None = None, +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + # 0 qty is accepted, as the qty is uncertain for some items + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + + def postprocess(source, target): + set_missing_values(source, target) + # Get the advance paid Journal Entries in Sales Invoice Advance + if target.get("allocate_advances_automatically"): + target.set_advances() + + make_packing_list(target) + set_serial_batch_for_bundle_reservation( + source, + target, + frappe.get_single_value("Stock Settings", "use_serial_batch_fields"), + get_sre_details_for_voucher("Sales Order", source_name), + ) + + def set_missing_values(source, target): + target.flags.ignore_permissions = True + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") + + if source.company_address: + target.update({"company_address": source.company_address}) + else: + # set company address + target.update(get_company_address(target.company)) + + if target.company_address: + target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address)) + + # set the redeem loyalty points if provided via shopping cart + if source.loyalty_points and source.order_type == "Shopping Cart": + target.redeem_loyalty_points = 1 + target.loyalty_points = source.loyalty_points + + target.debit_to = get_party_account("Customer", source.customer, source.company) + + def update_item(source, target, source_parent): + def get_billed_qty(so_item_name): + table = frappe.qb.DocType("Sales Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty).as_("qty")) + .where((table.docstatus == 1) & (table.so_detail == so_item_name)) + ) + return query.run(pluck="qty")[0] or 0 + + if source_parent.has_unit_price_items: + # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is + pending_amount = flt(source.amount) - flt(source.billed_amt) + target.amount = pending_amount if flt(source.amount) else 0 + else: + target.amount = flt(source.amount) - flt(source.billed_amt) + + target.base_amount = target.amount * flt(source_parent.conversion_rate) + target.qty = ( + source.qty - get_billed_qty(source.name) + if (source.qty and source.billed_amt) + else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty) + ) + + if source_parent.project: + target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center") + if target.item_code: + item = get_item_defaults(target.item_code, source_parent.company) + item_group = get_item_group_defaults(target.item_code, source_parent.company) + cost_center = item.get("selling_cost_center") or item_group.get("selling_cost_center") + + if cost_center: + target.cost_center = cost_center + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + def add_self_rm(doclist): + parent = frappe.qb.DocType("Subcontracting Inward Order") + child = frappe.qb.DocType("Subcontracting Inward Order Received Item") + query = ( + frappe.qb.from_(parent) + .join(child) + .on(parent.name == child.parent) + .select( + child.required_qty, + child.consumed_qty, + child.billed_qty, + child.rm_item_code, + child.stock_uom, + child.name, + ) + .where( + (parent.docstatus == 1) + & (parent.sales_order == source_name) + & (child.is_customer_provided_item == 0) + ) + ) + result = query.run(as_dict=True) + + if result: + idx = len(doclist.items) + 1 + for item in result: + if (qty := max(item.required_qty, item.consumed_qty) - item.billed_qty) > 0: + doclist.append( + "items", + { + "item_code": item.rm_item_code, + "qty": qty, + "uom": item.stock_uom, + "scio_detail": item.name, + }, + ) + doclist.process_item_selection(idx) + idx += 1 + doclist.has_subcontracted = 1 + + doclist = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Sales Invoice", + "field_map": { + "party_account_currency": "party_account_currency", + }, + "field_no_map": ["payment_terms_template"], + "validation": {"docstatus": ["=", 1]}, + }, + "Sales Order Item": { + "doctype": "Sales Invoice Item", + "field_map": { + "name": "so_detail", + "parent": "sales_order", + }, + "postprocess": update_item, + "condition": lambda doc: ( + True + if is_unit_price_row(doc) + else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) + ) + and select_item(doc), + }, + "Sales Taxes and Charges": { + "doctype": "Sales Taxes and Charges", + "reset_value": True, + }, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + }, + target_doc, + postprocess, + ignore_permissions=ignore_permissions, + ) + + if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"): + add_self_rm(doclist) + + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + if automatically_fetch_payment_terms: + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doclist).set_payment_schedule() + + return doclist + + +@frappe.whitelist() +def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None): + maint_schedule = frappe.db.exists( + "Maintenance Schedule Item", {"sales_order": source_name, "docstatus": 1} + ) + + if not maint_schedule: + doclist = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}}, + "Sales Order Item": { + "doctype": "Maintenance Schedule Item", + "field_map": {"parent": "sales_order"}, + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_maintenance_visit(source_name: str, target_doc: str | Document | None = None): + MaintenanceVisit = frappe.qb.DocType("Maintenance Visit") + MaintenanceVisitPurpose = frappe.qb.DocType("Maintenance Visit Purpose") + + query = ( + frappe.qb.from_(MaintenanceVisit) + .join(MaintenanceVisitPurpose) + .on(MaintenanceVisitPurpose.parent == MaintenanceVisit.name) + .select(MaintenanceVisit.name) + .where(MaintenanceVisitPurpose.prevdoc_docname == source_name) + .where(MaintenanceVisit.docstatus == 1) + .where(MaintenanceVisit.completion_status == "Fully Completed") + ) + + if not query.run(): + doclist = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": {"doctype": "Maintenance Visit", "validation": {"docstatus": ["=", 1]}}, + "Sales Order Item": { + "doctype": "Maintenance Visit Purpose", + "field_map": {"parent": "prevdoc_docname", "parenttype": "prevdoc_doctype"}, + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_purchase_order( + source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None +): + """Creates Purchase Order for each Supplier. Returns a list of doc objects.""" + + from erpnext.setup.utils import get_exchange_rate + + if not selected_items: + return + + if isinstance(selected_items, str): + selected_items = json.loads(selected_items) + + def set_missing_values(source, target): + target.supplier = supplier + company_currency = frappe.db.get_value( + "Company", filters={"name": target.company}, fieldname=["default_currency"] + ) + supplier_currency = frappe.db.get_value( + "Supplier", filters={"name": supplier}, fieldname=["default_currency"] + ) + + target.currency = supplier_currency if supplier_currency else company_currency + + target.conversion_rate = get_exchange_rate(target.currency, company_currency, args="for_buying") + + target.apply_discount_on = "" + target.additional_discount_percentage = 0.0 + target.discount_amount = 0.0 + target.inter_company_order_reference = "" + target.shipping_rule = "" + target.tc_name = "" + target.terms = "" + target.payment_terms_template = "" + target.payment_schedule = [] + + default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") + if default_price_list: + target.buying_price_list = default_price_list + + default_payment_terms = frappe.get_value("Supplier", supplier, "payment_terms") + if default_payment_terms: + target.payment_terms_template = default_payment_terms + + if any(item.delivered_by_supplier for item in target.items): + if source.shipping_address_name: + target.shipping_address = source.shipping_address_name + target.shipping_address_display = source.shipping_address + else: + target.shipping_address = source.customer_address + target.shipping_address_display = source.address_display + + target.customer_contact_person = source.contact_person + target.customer_contact_display = source.contact_display + target.customer_contact_mobile = source.contact_mobile + target.customer_contact_email = source.contact_email + + else: + target.customer = "" + target.customer_name = "" + + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(source, target, source_parent): + target.schedule_date = source.delivery_date + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = flt(source.stock_qty) - flt(source.ordered_qty) + target.project = source_parent.project + + def update_item_for_packed_item(source, target, _): + target.qty = flt(source.qty) - flt(source.ordered_qty) + + def filter_items(item, supplier): + if ( + item.ordered_qty < item.stock_qty + and not is_product_bundle(item.item_code) + and items_to_map.get(item.item_code) == supplier + ): + return True + + return False + + items_to_map = { + item.get("item_code"): item.get("supplier") for item in selected_items if item.get("item_code") + } + item_codes = list(set(items_to_map.keys())) + suppliers = list(set(items_to_map.values())) + + if not suppliers: + suppliers = [None] + + purchase_orders = [] + for supplier in suppliers: + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Purchase Order", + "field_no_map": [ + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "taxes_and_charges", + "shipping_address", + "dispatch_address", + ], + "validation": {"docstatus": ["=", 1]}, + }, + "Sales Order Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_item"], + ["parent", "sales_order"], + ["stock_uom", "stock_uom"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["delivery_date", "schedule_date"], + ], + "field_no_map": [ + "rate", + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "pricing_rules", + "margin_type", + "margin_rate_or_amount", + ], + "postprocess": update_item, + "condition": lambda doc, s=supplier: filter_items(doc, s), + }, + "Packed Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_packed_item"], + ["parent", "sales_order"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["parent_item", "product_bundle"], + ["rate", "rate"], + ], + "field_no_map": [ + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "supplier", + "pricing_rules", + ], + "postprocess": update_item_for_packed_item, + "condition": lambda doc: doc.parent_item in item_codes + and flt(doc.ordered_qty) < flt(doc.qty), + }, + }, + target_doc, + set_missing_values, + ) + + set_delivery_date(doc.items, source_name) + if doc.supplier: + doc.insert() + purchase_orders.append(doc) + + return purchase_orders + + +def set_delivery_date(items: list, sales_order: str) -> None: + delivery_dates = frappe.get_all( + "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"] + ) + + delivery_by_item = frappe._dict() + for date in delivery_dates: + delivery_by_item[date.item_code] = date.delivery_date + + for item in items: + if item.product_bundle: + item.schedule_date = delivery_by_item[item.product_bundle] + + +@frappe.whitelist() +def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None): + """Make Work Orders against the given Sales Order for the given `items`""" + items = json.loads(items).get("items") + out = [] + + for i in items: + if not i.get("bom"): + frappe.throw(_("Please select BOM against item {0}").format(i.get("item_code"))) + if not i.get("pending_qty"): + frappe.throw(_("Please select Qty against item {0}").format(i.get("item_code"))) + + work_order = frappe.get_doc( + doctype="Work Order", + production_item=i["item_code"], + bom_no=i.get("bom"), + qty=i["pending_qty"], + company=company, + sales_order=sales_order, + sales_order_item=i["sales_order_item"], + project=project, + fg_warehouse=i["warehouse"], + description=i["description"], + ).insert() + work_order.set_work_order_operations() + work_order.flags.ignore_mandatory = True + work_order.save() + out.append(work_order) + + return [p.name for p in out] + + +@frappe.whitelist() +def make_production_plan(source_name: str, target_doc: str | Document | None = None): + sales_order = frappe.get_doc("Sales Order", source_name) + + production_plan = frappe.new_doc( + "Production Plan", + company=sales_order.company, + get_items_from="Sales Order", + posting_date=nowdate(), + ) + + open_so = [data.name for data in get_sales_orders(production_plan)] + if sales_order.name not in open_so: + frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) + + production_plan.append( + "sales_orders", + { + "sales_order": sales_order.name, + "sales_order_date": sales_order.transaction_date, + "customer": sales_order.customer, + "grand_total": sales_order.base_grand_total, + }, + ) + production_plan.get_items() + if not production_plan.get("po_items"): + frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) + + return production_plan + + +@frappe.whitelist() +def make_raw_material_request( + items: str | frappe._dict, company: str, sales_order: str, project: str | None = None +): + if not frappe.has_permission("Sales Order", "write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + if isinstance(items, str): + items = frappe._dict(json.loads(items)) + + for item in items.get("items"): + item["include_exploded_items"] = items.get("include_exploded_items") + item["ignore_existing_ordered_qty"] = items.get("ignore_existing_ordered_qty") + item["include_raw_materials_from_sales_order"] = items.get("include_raw_materials_from_sales_order") + + items.update({"company": company, "sales_order": sales_order}) + + item_wh = {} + for item in items.get("items"): + if item.get("warehouse"): + item_wh[item.get("item_code")] = item.get("warehouse") + + raw_materials = get_items_for_material_requests(items) + if not raw_materials: + frappe.msgprint(_("Material Request not created, as quantity for Raw Materials already available.")) + return + + material_request = frappe.new_doc("Material Request") + material_request.update( + dict( + doctype="Material Request", + transaction_date=nowdate(), + company=company, + material_request_type="Purchase", + ) + ) + for item in raw_materials: + item_doc = frappe.get_cached_doc("Item", item.get("item_code")) + + schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days)) + row = material_request.append( + "items", + { + "item_code": item.get("item_code"), + "qty": item.get("quantity"), + "schedule_date": schedule_date, + "warehouse": item_wh.get(item.get("main_bom_item")) or item.get("warehouse"), + "sales_order": sales_order, + "project": project, + }, + ) + + if not (strip_html(item.get("description")) and strip_html(item_doc.description)): + row.description = item_doc.item_name or item.get("item_code") + + material_request.insert() + material_request.flags.ignore_permissions = 1 + material_request.run_method("set_missing_values") + material_request.submit() + return material_request + + +@frappe.whitelist() +def make_inter_company_purchase_order(source_name: str, target_doc: str | Document | None = None): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction + + return make_inter_company_transaction("Sales Order", source_name, target_doc) + + +@frappe.whitelist() +def create_pick_list(source_name: str, target_doc: str | Document | None = None): + def validate_sales_order(): + so = frappe.get_doc("Sales Order", source_name) + for item in so.items: + if item.stock_reserved_qty > 0: + frappe.throw( + _( + "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list." + ).format(frappe.bold(source_name)) + ) + + def update_item_quantity(source, target, source_parent) -> None: + picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) + qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty)) + + target.qty = qty_to_be_picked + target.stock_qty = qty_to_be_picked * flt(source.conversion_factor) + + # update available qty + bin_details = get_bin_details(source.item_code, source.warehouse, source_parent.company) + target.actual_qty = bin_details.get("actual_qty") + target.company_total_stock = bin_details.get("company_total_stock") + + def update_packed_item_qty(source, target, source_parent) -> None: + qty = flt(source.qty) + for item in source_parent.items: + if source.parent_detail_docname == item.name: + picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1) + pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty + target.qty = target.stock_qty = qty * pending_percent + return + + def should_pick_order_item(item) -> bool: + return ( + abs(item.delivered_qty) < abs(item.qty) + and item.delivered_by_supplier != 1 + and not is_product_bundle(item.item_code) + ) + + # Don't allow a Pick List to be created against a Sales Order that has reserved stock. + validate_sales_order() + + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Pick List", + "field_map": {"set_warehouse": "parent_warehouse"}, + "validation": {"docstatus": ["=", 1]}, + }, + "Sales Order Item": { + "doctype": "Pick List Item", + "field_map": {"parent": "sales_order", "name": "sales_order_item"}, + "postprocess": update_item_quantity, + "condition": should_pick_order_item, + }, + "Packed Item": { + "doctype": "Pick List Item", + "field_map": { + "parent": "sales_order", + "parent_detail_docname": "sales_order_item", + "name": "product_bundle_item", + }, + "field_no_map": ["picked_qty"], + "postprocess": update_packed_item_qty, + }, + }, + target_doc, + ) + + doc.purpose = "Delivery" + + doc.set_item_locations() + + return doc + + +@frappe.whitelist() +def make_subcontracting_inward_order(source_name: str, target_doc: str | Document | None = None): + if not is_so_fully_subcontracted(source_name): + return get_mapped_subcontracting_inward_order(source_name, target_doc) + else: + frappe.throw(_("This Sales Order has been fully subcontracted.")) + + +def is_so_fully_subcontracted(so_name: str) -> bool: + table = frappe.qb.DocType("Sales Order Item") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where((table.parent == so_name) & (table.qty != table.subcontracted_qty)) + ) + return not query.run(as_dict=True) + + +def get_mapped_subcontracting_inward_order( + source_name: str, target_doc: str | Document | None = None +) -> Document: + def post_process(source_doc, target_doc): + if ( + frappe.db.count( + "Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0} + ) + == 1 + ): + target_doc.customer_warehouse = frappe.get_cached_value( + "Warehouse", + {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}, + "name", + ) + target_doc.populate_items_table() + + if target_doc and isinstance(target_doc, str): + target_doc = json.loads(target_doc) + for key in ["service_items", "items", "received_items"]: + if key in target_doc: + del target_doc[key] + target_doc = json.dumps(target_doc) + + target_doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Subcontracting Inward Order", + "field_map": {}, + "field_no_map": ["total_qty", "total", "net_total"], + "validation": { + "docstatus": ["=", 1], + }, + }, + "Sales Order Item": { + "doctype": "Subcontracting Inward Order Service Item", + "field_map": { + "name": "sales_order_item", + }, + "field_no_map": ["qty", "fg_item_qty", "amount"], + "condition": lambda item: item.qty != item.subcontracted_qty, + }, + }, + target_doc, + post_process, + ) + + return target_doc diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index e2d43dee72b..bee4dfb4ee2 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -8,46 +8,45 @@ from typing import Literal import frappe import frappe.utils from frappe import _, qb -from frappe.contacts.doctype.address.address import get_company_address from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import Sum -from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, parse_json, strip_html +from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, parse_json from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.party import get_party_account from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, ) -from erpnext.manufacturing.doctype.production_plan.production_plan import ( - get_items_for_material_requests, - get_sales_orders, -) from erpnext.selling.doctype.customer.customer import check_credit_limit -from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_details_for_voucher, get_sre_reserved_qty_details_for_voucher, - get_ssb_bundle_for_voucher, has_reserved_stock, ) -from erpnext.stock.get_item_details import ( - ItemDetailsCtx, - get_bin_details, - get_default_bom, - get_price_list_rate, -) +from erpnext.stock.get_item_details import get_default_bom from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty +from .mapper import ( + create_pick_list, + make_delivery_note, + make_inter_company_purchase_order, + make_maintenance_schedule, + make_maintenance_visit, + make_material_request, + make_production_plan, + make_project, + make_purchase_order, + make_raw_material_request, + make_sales_invoice, + make_subcontracting_inward_order, + make_work_orders, +) + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -1054,620 +1053,6 @@ def close_or_unclose_sales_orders(names: str, status: str): frappe.local.message_log = [] -def get_requested_item_qty(sales_order): - result = {} - - so = frappe.get_doc("Sales Order", sales_order) - - for item in so.items: - if is_product_bundle(item.item_code): - for packed_item in so.get("packed_items"): - if ( - packed_item.parent_item == item.item_code - and packed_item.parent_detail_docname == item.name - ): - result[packed_item.name] = frappe._dict({"qty": packed_item.requested_qty}) - else: - result[item.name] = frappe._dict({"qty": item.requested_qty}) - - return result - - -@frappe.whitelist() -def make_material_request(source_name: str, target_doc: str | Document | None = None): - requested_item_qty = get_requested_item_qty(source_name) - - def postprocess(source, target): - if source.tc_name and frappe.db.get_value("Terms and Conditions", source.tc_name, "buying") != 1: - target.tc_name = None - target.terms = None - - def get_remaining_qty(so_item): - return flt( - flt(so_item.qty) - - flt(requested_item_qty.get(so_item.name, {}).get("qty")) - - max( - flt(so_item.get("delivered_qty")), - 0, - ) - ) - - def get_remaining_packed_item_qty(so_item): - delivered_qty = frappe.db.get_value( - "Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"] - ) - - bundle_item_qty = frappe.db.get_value( - "Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"] - ) - - return flt( - flt(so_item.qty) - - flt(requested_item_qty.get(so_item.name, {}).get("qty")) - - max( - flt(delivered_qty) * flt(bundle_item_qty), - 0, - ) - ) - - def update_item(source, target, source_parent): - # qty is for packed items, because packed items don't have stock_qty field - target.project = source_parent.project - target.qty = ( - get_remaining_packed_item_qty(source) - if source.parentfield == "packed_items" - else get_remaining_qty(source) - ) - target.stock_qty = flt(target.qty) * flt(target.conversion_factor) - target.actual_qty = get_bin_details( - target.item_code, target.warehouse, source_parent.company, True - ).get("actual_qty", 0) - - ctx = ItemDetailsCtx(target.as_dict().copy()) - ctx.update( - { - "company": source_parent.get("company"), - "price_list": frappe.db.get_single_value("Buying Settings", "buying_price_list"), - "currency": source_parent.get("currency"), - "conversion_rate": source_parent.get("conversion_rate"), - } - ) - - target.rate = flt( - get_price_list_rate(ctx, item_doc=frappe.get_cached_doc("Item", target.item_code)).get( - "price_list_rate" - ) - ) - target.amount = target.qty * target.rate - - doc = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": {"doctype": "Material Request", "validation": {"docstatus": ["=", 1]}}, - "Packed Item": { - "doctype": "Material Request Item", - "field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"}, - "condition": lambda item: get_remaining_packed_item_qty(item) > 0, - "postprocess": update_item, - }, - "Sales Order Item": { - "doctype": "Material Request Item", - "field_map": { - "name": "sales_order_item", - "parent": "sales_order", - "delivery_date": "schedule_date", - "bom_no": "bom_no", - }, - "condition": lambda item: not frappe.db.exists( - "Product Bundle", {"name": item.item_code, "disabled": 0} - ) - and get_remaining_qty(item) > 0, - "postprocess": update_item, - }, - }, - target_doc, - postprocess, - ) - if doc and doc.items: - return doc - else: - frappe.throw(_("Material Request already created for the ordered quantity")) - - -@frappe.whitelist() -def make_project(source_name: str, target_doc: str | Document | None = None): - def postprocess(source, doc): - doc.project_type = "External" - doc.project_name = source.name - - doc = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": { - "doctype": "Project", - "validation": {"docstatus": ["=", 1]}, - "field_map": { - "name": "sales_order", - "base_grand_total": "estimated_costing", - "net_total": "total_sales_amount", - }, - }, - }, - target_doc, - postprocess, - ) - - return doc - - -def set_serial_batch_for_bundle_reservation(source, target, use_serial_batch_fields, packed_sre): - for item in source.packed_items: - target_item = next( - ( - d - for d in target.packed_items - if (d.parent_item, d.item_code, d.warehouse) - == (item.parent_item, item.item_code, item.warehouse) - ), - None, - ) - if target_item and (sre := [sre for sre in packed_sre if sre.voucher_detail_no == item.name]): - if sre[0].reservation_based_on == "Serial and Batch": - qty = 0 - serial_nos = [] - batch_nos = [] - if use_serial_batch_fields: - target_item.use_serial_batch_fields = 1 - for item in sre: - qty += item.reserved_qty - if item.has_serial_no: - serial_nos.extend( - frappe.get_all( - "Serial and Batch Entry", - filters={"parent": item.name}, - pluck="serial_no", - ) - ) - if item.has_batch_no: - batch_nos.extend( - frappe.get_all( - "Serial and Batch Entry", - filters={"parent": item.name}, - pluck="batch_no", - ) - ) - - if len(batch_nos) == 1: - target_item.batch_no = batch_nos[0] if batch_nos else None - if serial_nos and len(batch_nos) < 2: - target_item.serial_no = "\n".join(serial_nos) - - if not use_serial_batch_fields or len(batch_nos) > 1: - target_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre).name - - -@frappe.whitelist() -def make_delivery_note( - source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None -): - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details_for_voucher, - ) - - if not kwargs: - kwargs = { - "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock, - "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping, - } - - kwargs = frappe._dict(kwargs) - - sre_details = {} - if kwargs.for_reserved_stock: - sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) - - mapper = { - "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, - "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, - "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, - } - - # 0 qty is accepted, as the qty is uncertain for some items - has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") - use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") - - def is_unit_price_row(source): - return has_unit_price_items and source.qty == 0 - - def select_item(d): - filtered_items = kwargs.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - def set_missing_values(source, target): - if kwargs.get("ignore_pricing_rule"): - # Skip pricing rule when the dn is creating from the pick list - target.ignore_pricing_rule = 1 - - target.run_method("set_missing_values") - target.run_method("set_po_nos") - target.run_method("calculate_taxes_and_totals") - target.run_method("set_use_serial_batch_fields") - - if source.company_address: - target.update({"company_address": source.company_address}) - else: - # set company address - target.update(get_company_address(target.company)) - - if target.company_address: - target.update(get_fetch_values("Delivery Note", "company_address", target.company_address)) - - # if invoked in bulk creation, validations are ignored and thus this method is nerver invoked - if frappe.flags.bulk_transaction: - # set target items names to ensure proper linking with packed_items - target.set_new_name() - - make_packing_list(target) - - def condition(doc): - if doc.name in sre_details: - del sre_details[doc.name] - return False - - # make_mapped_doc sets js `args` into `frappe.flags.args` - if frappe.flags.args and frappe.flags.args.delivery_dates: - if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: - return False - if frappe.flags.args and frappe.flags.args.until_delivery_date: - if cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date: - return False - - return ( - (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) - ) and doc.delivered_by_supplier != 1 - - def update_item(source, target, source_parent): - target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) - target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate) - target.qty = ( - flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty) - ) - - item = get_item_defaults(target.item_code, source_parent.company) - item_group = get_item_group_defaults(target.item_code, source_parent.company) - - if item: - target.cost_center = ( - frappe.db.get_value("Project", source_parent.project, "cost_center") - or item.get("buying_cost_center") - or item_group.get("buying_cost_center") - ) - - if not kwargs.skip_item_mapping: - mapper["Sales Order Item"] = { - "doctype": "Delivery Note Item", - "field_map": { - "rate": "rate", - "name": "so_detail", - "parent": "against_sales_order", - }, - "condition": lambda d: condition(d) and select_item(d), - "postprocess": update_item, - } - - so = frappe.get_doc("Sales Order", source_name) - target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc) - - packed_sre = [] - if not kwargs.skip_item_mapping and kwargs.for_reserved_stock: - sre_list = get_sre_details_for_voucher("Sales Order", source_name) - - if sre_list: - - def update_dn_item(source, target, source_parent): - update_item(source, target, so) - - so_items = {d.name: d for d in so.items if d.stock_reserved_qty} - - for sre in sre_list: - if not so_items.get(sre.voucher_detail_no): - packed_sre.append(sre) - continue - - if not condition(so_items[sre.voucher_detail_no]): - continue - - dn_item = get_mapped_doc( - "Sales Order Item", - sre.voucher_detail_no, - { - "Sales Order Item": { - "doctype": "Delivery Note Item", - "field_map": { - "rate": "rate", - "name": "so_detail", - "parent": "against_sales_order", - }, - "postprocess": update_dn_item, - } - }, - ignore_permissions=True, - ) - - dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1)) - dn_item.warehouse = sre.warehouse - - if ( - not use_serial_batch_fields - and sre.reservation_based_on == "Serial and Batch" - and (sre.has_serial_no or sre.has_batch_no) - ): - dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name - - target_doc.append("items", dn_item) - else: - # Correct rows index. - for idx, item in enumerate(target_doc.items): - item.idx = idx + 1 - - if not kwargs.skip_item_mapping and frappe.flags.bulk_transaction and not target_doc.items: - # the (date) condition filter resulted in an unintendedly created empty DN; remove it - del target_doc - return - - # Should be called after mapping items. - target_doc.packed_items = [] - set_missing_values(so, target_doc) - set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre) - - return target_doc - - -@frappe.whitelist() -def make_sales_invoice( - source_name: str, - target_doc: str | Document | None = None, - ignore_permissions: bool = False, - args: str | dict | None = None, -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - # 0 qty is accepted, as the qty is uncertain for some items - has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") - - def is_unit_price_row(source): - return has_unit_price_items and source.qty == 0 - - def postprocess(source, target): - set_missing_values(source, target) - # Get the advance paid Journal Entries in Sales Invoice Advance - if target.get("allocate_advances_automatically"): - target.set_advances() - - make_packing_list(target) - set_serial_batch_for_bundle_reservation( - source, - target, - frappe.get_single_value("Stock Settings", "use_serial_batch_fields"), - get_sre_details_for_voucher("Sales Order", source_name), - ) - - def set_missing_values(source, target): - target.flags.ignore_permissions = True - target.run_method("set_missing_values") - target.run_method("set_po_nos") - target.run_method("calculate_taxes_and_totals") - target.run_method("set_use_serial_batch_fields") - - if source.company_address: - target.update({"company_address": source.company_address}) - else: - # set company address - target.update(get_company_address(target.company)) - - if target.company_address: - target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address)) - - # set the redeem loyalty points if provided via shopping cart - if source.loyalty_points and source.order_type == "Shopping Cart": - target.redeem_loyalty_points = 1 - target.loyalty_points = source.loyalty_points - - target.debit_to = get_party_account("Customer", source.customer, source.company) - - def update_item(source, target, source_parent): - def get_billed_qty(so_item_name): - from frappe.query_builder.functions import Sum - - table = frappe.qb.DocType("Sales Invoice Item") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty).as_("qty")) - .where((table.docstatus == 1) & (table.so_detail == so_item_name)) - ) - return query.run(pluck="qty")[0] or 0 - - if source_parent.has_unit_price_items: - # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is - pending_amount = flt(source.amount) - flt(source.billed_amt) - target.amount = pending_amount if flt(source.amount) else 0 - else: - target.amount = flt(source.amount) - flt(source.billed_amt) - - target.base_amount = target.amount * flt(source_parent.conversion_rate) - target.qty = ( - source.qty - get_billed_qty(source.name) - if (source.qty and source.billed_amt) - else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty) - ) - - if source_parent.project: - target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center") - if target.item_code: - item = get_item_defaults(target.item_code, source_parent.company) - item_group = get_item_group_defaults(target.item_code, source_parent.company) - cost_center = item.get("selling_cost_center") or item_group.get("selling_cost_center") - - if cost_center: - target.cost_center = cost_center - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - def add_self_rm(doclist): - parent = frappe.qb.DocType("Subcontracting Inward Order") - child = frappe.qb.DocType("Subcontracting Inward Order Received Item") - query = ( - frappe.qb.from_(parent) - .join(child) - .on(parent.name == child.parent) - .select( - child.required_qty, - child.consumed_qty, - child.billed_qty, - child.rm_item_code, - child.stock_uom, - child.name, - ) - .where( - (parent.docstatus == 1) - & (parent.sales_order == source_name) - & (child.is_customer_provided_item == 0) - ) - ) - result = query.run(as_dict=True) - - if result: - idx = len(doclist.items) + 1 - for item in result: - if (qty := max(item.required_qty, item.consumed_qty) - item.billed_qty) > 0: - doclist.append( - "items", - { - "item_code": item.rm_item_code, - "qty": qty, - "uom": item.stock_uom, - "scio_detail": item.name, - }, - ) - doclist.process_item_selection(idx) - idx += 1 - doclist.has_subcontracted = 1 - - doclist = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": { - "doctype": "Sales Invoice", - "field_map": { - "party_account_currency": "party_account_currency", - }, - "field_no_map": ["payment_terms_template"], - "validation": {"docstatus": ["=", 1]}, - }, - "Sales Order Item": { - "doctype": "Sales Invoice Item", - "field_map": { - "name": "so_detail", - "parent": "sales_order", - }, - "postprocess": update_item, - "condition": lambda doc: ( - True - if is_unit_price_row(doc) - else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) - ) - and select_item(doc), - }, - "Sales Taxes and Charges": { - "doctype": "Sales Taxes and Charges", - "reset_value": True, - }, - "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, - }, - target_doc, - postprocess, - ignore_permissions=ignore_permissions, - ) - - if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"): - add_self_rm(doclist) - - automatically_fetch_payment_terms = cint( - frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") - ) - if automatically_fetch_payment_terms: - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(doclist).set_payment_schedule() - - return doclist - - -@frappe.whitelist() -def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None): - maint_schedule = frappe.db.exists( - "Maintenance Schedule Item", {"sales_order": source_name, "docstatus": 1} - ) - - if not maint_schedule: - doclist = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}}, - "Sales Order Item": { - "doctype": "Maintenance Schedule Item", - "field_map": {"parent": "sales_order"}, - }, - }, - target_doc, - ) - - return doclist - - -@frappe.whitelist() -def make_maintenance_visit(source_name: str, target_doc: str | Document | None = None): - MaintenanceVisit = frappe.qb.DocType("Maintenance Visit") - MaintenanceVisitPurpose = frappe.qb.DocType("Maintenance Visit Purpose") - - query = ( - frappe.qb.from_(MaintenanceVisit) - .join(MaintenanceVisitPurpose) - .on(MaintenanceVisitPurpose.parent == MaintenanceVisit.name) - .select(MaintenanceVisit.name) - .where(MaintenanceVisitPurpose.prevdoc_docname == source_name) - .where(MaintenanceVisit.docstatus == 1) - .where(MaintenanceVisit.completion_status == "Fully Completed") - ) - - if not query.run(): - doclist = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": {"doctype": "Maintenance Visit", "validation": {"docstatus": ["=", 1]}}, - "Sales Order Item": { - "doctype": "Maintenance Visit Purpose", - "field_map": {"parent": "prevdoc_docname", "parenttype": "prevdoc_doctype"}, - }, - }, - target_doc, - ) - - return doclist - - @frappe.whitelist() def get_events(start: str, end: str, filters: str | dict | None = None): """Returns events for Gantt / Calendar view rendering. @@ -1712,414 +1097,12 @@ def get_events(start: str, end: str, filters: str | dict | None = None): return data -@frappe.whitelist() -def make_purchase_order( - source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None -): - """Creates Purchase Order for each Supplier. Returns a list of doc objects.""" - - from erpnext.setup.utils import get_exchange_rate - - if not selected_items: - return - - if isinstance(selected_items, str): - selected_items = json.loads(selected_items) - - def set_missing_values(source, target): - target.supplier = supplier - company_currency = frappe.db.get_value( - "Company", filters={"name": target.company}, fieldname=["default_currency"] - ) - supplier_currency = frappe.db.get_value( - "Supplier", filters={"name": supplier}, fieldname=["default_currency"] - ) - - target.currency = supplier_currency if supplier_currency else company_currency - - target.conversion_rate = get_exchange_rate(target.currency, company_currency, args="for_buying") - - target.apply_discount_on = "" - target.additional_discount_percentage = 0.0 - target.discount_amount = 0.0 - target.inter_company_order_reference = "" - target.shipping_rule = "" - target.tc_name = "" - target.terms = "" - target.payment_terms_template = "" - target.payment_schedule = [] - - default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") - if default_price_list: - target.buying_price_list = default_price_list - - default_payment_terms = frappe.get_value("Supplier", supplier, "payment_terms") - if default_payment_terms: - target.payment_terms_template = default_payment_terms - - if any(item.delivered_by_supplier for item in target.items): - if source.shipping_address_name: - target.shipping_address = source.shipping_address_name - target.shipping_address_display = source.shipping_address - else: - target.shipping_address = source.customer_address - target.shipping_address_display = source.address_display - - target.customer_contact_person = source.contact_person - target.customer_contact_display = source.contact_display - target.customer_contact_mobile = source.contact_mobile - target.customer_contact_email = source.contact_email - - else: - target.customer = "" - target.customer_name = "" - - target.run_method("set_missing_values") - target.run_method("calculate_taxes_and_totals") - - def update_item(source, target, source_parent): - target.schedule_date = source.delivery_date - target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) - target.stock_qty = flt(source.stock_qty) - flt(source.ordered_qty) - target.project = source_parent.project - - def update_item_for_packed_item(source, target, _): - target.qty = flt(source.qty) - flt(source.ordered_qty) - - def filter_items(item, supplier): - if ( - item.ordered_qty < item.stock_qty - and not is_product_bundle(item.item_code) - and items_to_map.get(item.item_code) == supplier - ): - return True - - return False - - items_to_map = { - item.get("item_code"): item.get("supplier") for item in selected_items if item.get("item_code") - } - item_codes = list(set(items_to_map.keys())) - suppliers = list(set(items_to_map.values())) - - if not suppliers: - suppliers = [None] - - purchase_orders = [] - for supplier in suppliers: - doc = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": { - "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], - "validation": {"docstatus": ["=", 1]}, - }, - "Sales Order Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "sales_order_item"], - ["parent", "sales_order"], - ["stock_uom", "stock_uom"], - ["uom", "uom"], - ["conversion_factor", "conversion_factor"], - ["delivery_date", "schedule_date"], - ], - "field_no_map": [ - "rate", - "price_list_rate", - "item_tax_template", - "discount_percentage", - "discount_amount", - "pricing_rules", - "margin_type", - "margin_rate_or_amount", - ], - "postprocess": update_item, - "condition": lambda doc, s=supplier: filter_items(doc, s), - }, - "Packed Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "sales_order_packed_item"], - ["parent", "sales_order"], - ["uom", "uom"], - ["conversion_factor", "conversion_factor"], - ["parent_item", "product_bundle"], - ["rate", "rate"], - ], - "field_no_map": [ - "price_list_rate", - "item_tax_template", - "discount_percentage", - "discount_amount", - "supplier", - "pricing_rules", - ], - "postprocess": update_item_for_packed_item, - "condition": lambda doc: doc.parent_item in item_codes - and flt(doc.ordered_qty) < flt(doc.qty), - }, - }, - target_doc, - set_missing_values, - ) - - set_delivery_date(doc.items, source_name) - if doc.supplier: - doc.insert() - purchase_orders.append(doc) - - return purchase_orders - - -def set_delivery_date(items, sales_order): - delivery_dates = frappe.get_all( - "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"] - ) - - delivery_by_item = frappe._dict() - for date in delivery_dates: - delivery_by_item[date.item_code] = date.delivery_date - - for item in items: - if item.product_bundle: - item.schedule_date = delivery_by_item[item.product_bundle] - - -def is_product_bundle(item_code): - return frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}) - - -@frappe.whitelist() -def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None): - """Make Work Orders against the given Sales Order for the given `items`""" - items = json.loads(items).get("items") - out = [] - - for i in items: - if not i.get("bom"): - frappe.throw(_("Please select BOM against item {0}").format(i.get("item_code"))) - if not i.get("pending_qty"): - frappe.throw(_("Please select Qty against item {0}").format(i.get("item_code"))) - - work_order = frappe.get_doc( - doctype="Work Order", - production_item=i["item_code"], - bom_no=i.get("bom"), - qty=i["pending_qty"], - company=company, - sales_order=sales_order, - sales_order_item=i["sales_order_item"], - project=project, - fg_warehouse=i["warehouse"], - description=i["description"], - ).insert() - work_order.set_work_order_operations() - work_order.flags.ignore_mandatory = True - work_order.save() - out.append(work_order) - - return [p.name for p in out] - - -@frappe.whitelist() -def make_production_plan(source_name: str, target_doc: str | Document | None = None): - sales_order = frappe.get_doc("Sales Order", source_name) - - production_plan = frappe.new_doc( - "Production Plan", - company=sales_order.company, - get_items_from="Sales Order", - posting_date=nowdate(), - ) - - open_so = [data.name for data in get_sales_orders(production_plan)] - if sales_order.name not in open_so: - frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) - - production_plan.append( - "sales_orders", - { - "sales_order": sales_order.name, - "sales_order_date": sales_order.transaction_date, - "customer": sales_order.customer, - "grand_total": sales_order.base_grand_total, - }, - ) - production_plan.get_items() - if not production_plan.get("po_items"): - frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) - - return production_plan - - @frappe.whitelist() def update_status(status: str, name: str): so = frappe.get_doc("Sales Order", name, check_permission="submit") so.update_status(status) -@frappe.whitelist() -def make_raw_material_request( - items: str | frappe._dict, company: str, sales_order: str, project: str | None = None -): - if not frappe.has_permission("Sales Order", "write"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - - if isinstance(items, str): - items = frappe._dict(json.loads(items)) - - for item in items.get("items"): - item["include_exploded_items"] = items.get("include_exploded_items") - item["ignore_existing_ordered_qty"] = items.get("ignore_existing_ordered_qty") - item["include_raw_materials_from_sales_order"] = items.get("include_raw_materials_from_sales_order") - - items.update({"company": company, "sales_order": sales_order}) - - item_wh = {} - for item in items.get("items"): - if item.get("warehouse"): - item_wh[item.get("item_code")] = item.get("warehouse") - - raw_materials = get_items_for_material_requests(items) - if not raw_materials: - frappe.msgprint(_("Material Request not created, as quantity for Raw Materials already available.")) - return - - material_request = frappe.new_doc("Material Request") - material_request.update( - dict( - doctype="Material Request", - transaction_date=nowdate(), - company=company, - material_request_type="Purchase", - ) - ) - for item in raw_materials: - item_doc = frappe.get_cached_doc("Item", item.get("item_code")) - - schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days)) - row = material_request.append( - "items", - { - "item_code": item.get("item_code"), - "qty": item.get("quantity"), - "schedule_date": schedule_date, - "warehouse": item_wh.get(item.get("main_bom_item")) or item.get("warehouse"), - "sales_order": sales_order, - "project": project, - }, - ) - - if not (strip_html(item.get("description")) and strip_html(item_doc.description)): - row.description = item_doc.item_name or item.get("item_code") - - material_request.insert() - material_request.flags.ignore_permissions = 1 - material_request.run_method("set_missing_values") - material_request.submit() - return material_request - - -@frappe.whitelist() -def make_inter_company_purchase_order(source_name: str, target_doc: str | Document | None = None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction - - return make_inter_company_transaction("Sales Order", source_name, target_doc) - - -@frappe.whitelist() -def create_pick_list(source_name: str, target_doc: str | Document | None = None): - from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle - - def validate_sales_order(): - so = frappe.get_doc("Sales Order", source_name) - for item in so.items: - if item.stock_reserved_qty > 0: - frappe.throw( - _( - "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list." - ).format(frappe.bold(source_name)) - ) - - def update_item_quantity(source, target, source_parent) -> None: - picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) - qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty)) - - target.qty = qty_to_be_picked - target.stock_qty = qty_to_be_picked * flt(source.conversion_factor) - - # update available qty - bin_details = get_bin_details(source.item_code, source.warehouse, source_parent.company) - target.actual_qty = bin_details.get("actual_qty") - target.company_total_stock = bin_details.get("company_total_stock") - - def update_packed_item_qty(source, target, source_parent) -> None: - qty = flt(source.qty) - for item in source_parent.items: - if source.parent_detail_docname == item.name: - picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1) - pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty - target.qty = target.stock_qty = qty * pending_percent - return - - def should_pick_order_item(item) -> bool: - return ( - abs(item.delivered_qty) < abs(item.qty) - and item.delivered_by_supplier != 1 - and not is_product_bundle(item.item_code) - ) - - # Don't allow a Pick List to be created against a Sales Order that has reserved stock. - validate_sales_order() - - doc = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": { - "doctype": "Pick List", - "field_map": {"set_warehouse": "parent_warehouse"}, - "validation": {"docstatus": ["=", 1]}, - }, - "Sales Order Item": { - "doctype": "Pick List Item", - "field_map": {"parent": "sales_order", "name": "sales_order_item"}, - "postprocess": update_item_quantity, - "condition": should_pick_order_item, - }, - "Packed Item": { - "doctype": "Pick List Item", - "field_map": { - "parent": "sales_order", - "parent_detail_docname": "sales_order_item", - "name": "product_bundle_item", - }, - "field_no_map": ["picked_qty"], - "postprocess": update_packed_item_qty, - }, - }, - target_doc, - ) - - doc.purpose = "Delivery" - - doc.set_item_locations() - - return doc - - def update_produced_qty_in_so_item(sales_order, sales_order_item): # for multiple work orders against same sales order item linked_wo_with_so_item = frappe.db.get_all( @@ -2205,71 +1188,3 @@ def get_work_order_items(sales_order: str, for_raw_material_request: int = 0): @frappe.whitelist() def get_stock_reservation_status(): return frappe.get_single_value("Stock Settings", "enable_stock_reservation") - - -@frappe.whitelist() -def make_subcontracting_inward_order(source_name: str, target_doc: str | Document | None = None): - if not is_so_fully_subcontracted(source_name): - return get_mapped_subcontracting_inward_order(source_name, target_doc) - else: - frappe.throw(_("This Sales Order has been fully subcontracted.")) - - -def is_so_fully_subcontracted(so_name): - table = frappe.qb.DocType("Sales Order Item") - query = ( - frappe.qb.from_(table) - .select(table.name) - .where((table.parent == so_name) & (table.qty != table.subcontracted_qty)) - ) - return not query.run(as_dict=True) - - -def get_mapped_subcontracting_inward_order(source_name, target_doc=None): - def post_process(source_doc, target_doc): - if ( - frappe.db.count( - "Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0} - ) - == 1 - ): - target_doc.customer_warehouse = frappe.get_cached_value( - "Warehouse", - {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}, - "name", - ) - target_doc.populate_items_table() - - if target_doc and isinstance(target_doc, str): - target_doc = json.loads(target_doc) - for key in ["service_items", "items", "received_items"]: - if key in target_doc: - del target_doc[key] - target_doc = json.dumps(target_doc) - - target_doc = get_mapped_doc( - "Sales Order", - source_name, - { - "Sales Order": { - "doctype": "Subcontracting Inward Order", - "field_map": {}, - "field_no_map": ["total_qty", "total", "net_total"], - "validation": { - "docstatus": ["=", 1], - }, - }, - "Sales Order Item": { - "doctype": "Subcontracting Inward Order Service Item", - "field_map": { - "name": "sales_order_item", - }, - "field_no_map": ["qty", "fg_item_qty", "amount"], - "condition": lambda item: item.qty != item.subcontracted_qty, - }, - }, - target_doc, - post_process, - ) - - return target_doc From cfff10463cfe8f435188e2a3f1d963be7428a8d6 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:16:53 +0530 Subject: [PATCH 36/65] refactor(quotation): move mapping functions to mapper.py --- erpnext/selling/doctype/quotation/mapper.py | 280 +++++++++++++++++ .../selling/doctype/quotation/quotation.py | 284 +----------------- 2 files changed, 290 insertions(+), 274 deletions(-) create mode 100644 erpnext/selling/doctype/quotation/mapper.py diff --git a/erpnext/selling/doctype/quotation/mapper.py b/erpnext/selling/doctype/quotation/mapper.py new file mode 100644 index 00000000000..166bd5278ab --- /dev/null +++ b/erpnext/selling/doctype/quotation/mapper.py @@ -0,0 +1,280 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import cint, flt, getdate, nowdate + + +@frappe.whitelist() +def make_sales_order( + source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None +): + if not frappe.db.get_singles_value( + "Selling Settings", "allow_sales_order_creation_for_expired_quotation" + ): + quotation = frappe.db.get_value( + "Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1 + ) + if quotation.valid_till and ( + quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate()) + ): + frappe.throw(_("Validity period of this quotation has ended.")) + + return _make_sales_order(source_name, target_doc, args=args) + + +def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, args=None): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + customer = _make_customer(source_name, ignore_permissions) + ordered_items = get_ordered_items(source_name) + + selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] + + # 0 qty is accepted, as the qty uncertain for some items + has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items") + + def is_unit_price_row(source) -> bool: + return has_unit_price_items and source.qty == 0 + + def set_missing_values(source, target): + if customer: + target.customer = customer.name + target.customer_name = customer.customer_name + + # sales team + if not target.get("sales_team"): + for d in customer.get("sales_team") or []: + target.append( + "sales_team", + { + "sales_person": d.sales_person, + "allocated_percentage": d.allocated_percentage or None, + "commission_rate": d.commission_rate, + }, + ) + + if source.referral_sales_partner: + target.sales_partner = source.referral_sales_partner + target.commission_rate = frappe.get_value( + "Sales Partner", source.referral_sales_partner, "commission_rate" + ) + + target.flags.ignore_permissions = ignore_permissions + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(obj, target, source_parent): + balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0) + target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0 + target.qty = flt(target.stock_qty) / flt(obj.conversion_factor) + + if obj.against_blanket_order: + target.against_blanket_order = obj.against_blanket_order + target.blanket_order = obj.blanket_order + target.blanket_order_rate = obj.blanket_order_rate + + def can_map_row(item) -> bool: + """ + Row mapping from Quotation to Sales order: + 1. If no selections, map all non-alternative rows (that sum up to the grand total) + 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty + 3. If no selections: Simple row: Map if adequate qty + """ + if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)): + return False + + if not selected_rows: + return not item.is_alternative + + if selected_rows and (item.is_alternative or item.has_alternative_item): + return item.name in selected_rows + + # Simple row + return True + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + + doclist = get_mapped_doc( + "Quotation", + source_name, + { + "Quotation": { + "doctype": "Sales Order", + "validation": {"docstatus": ["=", 1]}, + "field_no_map": ["payment_terms_template"], + }, + "Quotation Item": { + "doctype": "Sales Order Item", + "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"}, + "postprocess": update_item, + "condition": lambda d: can_map_row(d) and select_item(d), + }, + "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + }, + target_doc, + set_missing_values, + ignore_permissions=ignore_permissions, + ) + + if automatically_fetch_payment_terms: + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doclist).set_payment_schedule() + + return doclist + + +@frappe.whitelist() +def make_sales_invoice( + source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None +): + return _make_sales_invoice(source_name, target_doc, args=args) + + +def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + customer = _make_customer(source_name, ignore_permissions) + + def set_missing_values(source, target): + if customer: + target.customer = customer.name + target.customer_name = customer.customer_name + + target.flags.ignore_permissions = ignore_permissions + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(obj, target, source_parent): + target.cost_center = None + target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + doclist = get_mapped_doc( + "Quotation", + source_name, + { + "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}}, + "Quotation Item": { + "doctype": "Sales Invoice Item", + "postprocess": update_item, + "condition": lambda row: not row.is_alternative and select_item(row), + }, + "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + }, + target_doc, + set_missing_values, + ignore_permissions=ignore_permissions, + ) + + return doclist + + +def _make_customer(source_name, ignore_permissions=False): + quotation = frappe.db.get_value( + "Quotation", + source_name, + ["order_type", "quotation_to", "party_name", "customer_name"], + as_dict=1, + ) + + if quotation.quotation_to == "Customer": + return frappe.get_doc("Customer", quotation.party_name) + elif quotation.quotation_to == "CRM Deal": + customer_name = frappe.get_value("Customer", {"crm_deal": quotation.party_name}) + if customer_name: + return frappe.get_doc("Customer", customer_name) + + # Check if a Customer already exists for the Lead or Prospect. + existing_customer = None + if quotation.quotation_to == "Lead": + existing_customer = frappe.db.get_value("Customer", {"lead_name": quotation.party_name}) + elif quotation.quotation_to == "Prospect": + existing_customer = frappe.db.get_value("Customer", {"prospect_name": quotation.party_name}) + + if existing_customer: + return frappe.get_doc("Customer", existing_customer) + + # If no Customer exists, create a new Customer or Prospect. + if quotation.quotation_to == "Lead": + return create_customer_from_lead(quotation.party_name, ignore_permissions=ignore_permissions) + elif quotation.quotation_to == "Prospect": + return create_customer_from_prospect(quotation.party_name, ignore_permissions=ignore_permissions) + + return None + + +def create_customer_from_lead(lead_name, ignore_permissions=False): + from erpnext.crm.doctype.lead.lead import _make_customer + + customer = _make_customer(lead_name, ignore_permissions=ignore_permissions) + customer.flags.ignore_permissions = ignore_permissions + + try: + customer.insert() + return customer + except frappe.MandatoryError as e: + handle_mandatory_error(e, customer, lead_name) + + +def create_customer_from_prospect(prospect_name, ignore_permissions=False): + from erpnext.crm.doctype.prospect.prospect import make_customer as make_customer_from_prospect + + customer = make_customer_from_prospect(prospect_name) + customer.flags.ignore_permissions = ignore_permissions + + try: + customer.insert() + return customer + except frappe.MandatoryError as e: + handle_mandatory_error(e, customer, prospect_name) + + +def handle_mandatory_error(e, customer, lead_name): + from frappe.utils import get_link_to_form + + mandatory_fields = e.args[0].split(":")[1].split(",") + mandatory_fields = [_(customer.meta.get_label(field.strip())) for field in mandatory_fields] + + frappe.local.message_log = [] + message = _("Could not auto create Customer due to the following missing mandatory field(s):") + "
" + message += "
  • " + "
  • ".join(mandatory_fields) + "
" + message += _("Please create Customer from Lead {0}.").format(get_link_to_form("Lead", lead_name)) + + frappe.throw(message, title=_("Mandatory Missing")) + + +def get_ordered_items(quotation: str) -> frappe._dict: + return frappe._dict( + frappe.get_all( + "Quotation Item", + {"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)}, + ["name", "ordered_qty"], + as_list=True, + ) + ) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index e453ae546fd..864106613bb 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -2,16 +2,22 @@ # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe import _ from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, flt, getdate, nowdate +from frappe.utils import getdate, nowdate from erpnext.controllers.selling_controller import SellingController +from .mapper import ( + _make_sales_order, + create_customer_from_lead, + create_customer_from_prospect, + get_ordered_items, + make_sales_invoice, + make_sales_order, +) + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -356,137 +362,6 @@ def get_list_context(context=None): return list_context -@frappe.whitelist() -def make_sales_order( - source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None -): - if not frappe.db.get_singles_value( - "Selling Settings", "allow_sales_order_creation_for_expired_quotation" - ): - quotation = frappe.db.get_value( - "Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1 - ) - if quotation.valid_till and ( - quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate()) - ): - frappe.throw(_("Validity period of this quotation has ended.")) - - return _make_sales_order(source_name, target_doc, args=args) - - -def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, args=None): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - customer = _make_customer(source_name, ignore_permissions) - ordered_items = get_ordered_items(source_name) - - selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] - - # 0 qty is accepted, as the qty uncertain for some items - has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items") - - def is_unit_price_row(source) -> bool: - return has_unit_price_items and source.qty == 0 - - def set_missing_values(source, target): - if customer: - target.customer = customer.name - target.customer_name = customer.customer_name - - # sales team - if not target.get("sales_team"): - for d in customer.get("sales_team") or []: - target.append( - "sales_team", - { - "sales_person": d.sales_person, - "allocated_percentage": d.allocated_percentage or None, - "commission_rate": d.commission_rate, - }, - ) - - if source.referral_sales_partner: - target.sales_partner = source.referral_sales_partner - target.commission_rate = frappe.get_value( - "Sales Partner", source.referral_sales_partner, "commission_rate" - ) - - target.flags.ignore_permissions = ignore_permissions - target.run_method("set_missing_values") - target.run_method("calculate_taxes_and_totals") - - def update_item(obj, target, source_parent): - balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0) - target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0 - target.qty = flt(target.stock_qty) / flt(obj.conversion_factor) - - if obj.against_blanket_order: - target.against_blanket_order = obj.against_blanket_order - target.blanket_order = obj.blanket_order - target.blanket_order_rate = obj.blanket_order_rate - - def can_map_row(item) -> bool: - """ - Row mapping from Quotation to Sales order: - 1. If no selections, map all non-alternative rows (that sum up to the grand total) - 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty - 3. If no selections: Simple row: Map if adequate qty - """ - if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)): - return False - - if not selected_rows: - return not item.is_alternative - - if selected_rows and (item.is_alternative or item.has_alternative_item): - return item.name in selected_rows - - # Simple row - return True - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - automatically_fetch_payment_terms = cint( - frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") - ) - - doclist = get_mapped_doc( - "Quotation", - source_name, - { - "Quotation": { - "doctype": "Sales Order", - "validation": {"docstatus": ["=", 1]}, - "field_no_map": ["payment_terms_template"], - }, - "Quotation Item": { - "doctype": "Sales Order Item", - "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"}, - "postprocess": update_item, - "condition": lambda d: can_map_row(d) and select_item(d), - }, - "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, - "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, - }, - target_doc, - set_missing_values, - ignore_permissions=ignore_permissions, - ) - - if automatically_fetch_payment_terms: - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(doclist).set_payment_schedule() - - return doclist - - def set_expired_status(): # filter out submitted non expired quotations whose validity has been ended cond = "`tabQuotation`.docstatus = 1 and `tabQuotation`.status NOT IN ('Expired', 'Lost') and `tabQuotation`.valid_till < %s" @@ -507,142 +382,3 @@ def set_expired_status(): }, (nowdate()), ) - - -@frappe.whitelist() -def make_sales_invoice( - source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None -): - return _make_sales_invoice(source_name, target_doc, args=args) - - -def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - customer = _make_customer(source_name, ignore_permissions) - - def set_missing_values(source, target): - if customer: - target.customer = customer.name - target.customer_name = customer.customer_name - - target.flags.ignore_permissions = ignore_permissions - target.run_method("set_missing_values") - target.run_method("calculate_taxes_and_totals") - - def update_item(obj, target, source_parent): - target.cost_center = None - target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - doclist = get_mapped_doc( - "Quotation", - source_name, - { - "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}}, - "Quotation Item": { - "doctype": "Sales Invoice Item", - "postprocess": update_item, - "condition": lambda row: not row.is_alternative and select_item(row), - }, - "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, - "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, - }, - target_doc, - set_missing_values, - ignore_permissions=ignore_permissions, - ) - - return doclist - - -def _make_customer(source_name, ignore_permissions=False): - quotation = frappe.db.get_value( - "Quotation", - source_name, - ["order_type", "quotation_to", "party_name", "customer_name"], - as_dict=1, - ) - - if quotation.quotation_to == "Customer": - return frappe.get_doc("Customer", quotation.party_name) - elif quotation.quotation_to == "CRM Deal": - customer_name = frappe.get_value("Customer", {"crm_deal": quotation.party_name}) - if customer_name: - return frappe.get_doc("Customer", customer_name) - - # Check if a Customer already exists for the Lead or Prospect. - existing_customer = None - if quotation.quotation_to == "Lead": - existing_customer = frappe.db.get_value("Customer", {"lead_name": quotation.party_name}) - elif quotation.quotation_to == "Prospect": - existing_customer = frappe.db.get_value("Customer", {"prospect_name": quotation.party_name}) - - if existing_customer: - return frappe.get_doc("Customer", existing_customer) - - # If no Customer exists, create a new Customer or Prospect. - if quotation.quotation_to == "Lead": - return create_customer_from_lead(quotation.party_name, ignore_permissions=ignore_permissions) - elif quotation.quotation_to == "Prospect": - return create_customer_from_prospect(quotation.party_name, ignore_permissions=ignore_permissions) - - return None - - -def create_customer_from_lead(lead_name, ignore_permissions=False): - from erpnext.crm.doctype.lead.lead import _make_customer - - customer = _make_customer(lead_name, ignore_permissions=ignore_permissions) - customer.flags.ignore_permissions = ignore_permissions - - try: - customer.insert() - return customer - except frappe.MandatoryError as e: - handle_mandatory_error(e, customer, lead_name) - - -def create_customer_from_prospect(prospect_name, ignore_permissions=False): - from erpnext.crm.doctype.prospect.prospect import make_customer as make_customer_from_prospect - - customer = make_customer_from_prospect(prospect_name) - customer.flags.ignore_permissions = ignore_permissions - - try: - customer.insert() - return customer - except frappe.MandatoryError as e: - handle_mandatory_error(e, customer, prospect_name) - - -def handle_mandatory_error(e, customer, lead_name): - from frappe.utils import get_link_to_form - - mandatory_fields = e.args[0].split(":")[1].split(",") - mandatory_fields = [_(customer.meta.get_label(field.strip())) for field in mandatory_fields] - - frappe.local.message_log = [] - message = _("Could not auto create Customer due to the following missing mandatory field(s):") + "
" - message += "
  • " + "
  • ".join(mandatory_fields) + "
" - message += _("Please create Customer from Lead {0}.").format(get_link_to_form("Lead", lead_name)) - - frappe.throw(message, title=_("Mandatory Missing")) - - -def get_ordered_items(quotation: str): - return frappe._dict( - frappe.get_all( - "Quotation Item", - {"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)}, - ["name", "ordered_qty"], - as_list=True, - ) - ) From cfd37f22db24cdd7493b4076426393776b05e7a0 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:19:09 +0530 Subject: [PATCH 37/65] refactor(customer): move mapping functions to mapper.py --- erpnext/selling/doctype/customer/customer.py | 215 +------------------ erpnext/selling/doctype/customer/mapper.py | 212 ++++++++++++++++++ 2 files changed, 221 insertions(+), 206 deletions(-) create mode 100644 erpnext/selling/doctype/customer/mapper.py diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8368fd90ee9..f4787a6ab9b 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -12,7 +12,6 @@ from frappe.contacts.address_and_contact import ( load_address_and_contact, ) from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes from frappe.query_builder import CustomFunction, Field, functions @@ -28,6 +27,15 @@ from erpnext.accounts.party import ( from erpnext.controllers.website_list_for_contact import add_role_for_portal_user from erpnext.utilities.transaction_base import TransactionBase +from .mapper import ( + make_address, + make_contact, + make_opportunity, + make_payment_entry, + make_quotation, + parse_full_name, +) + class Customer(TransactionBase): # begin: auto-generated types @@ -440,117 +448,6 @@ class Customer(TransactionBase): return None -@frappe.whitelist() -def make_quotation(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - _set_missing_values(source, target) - - target_doc = get_mapped_doc( - "Customer", - source_name, - {"Customer": {"doctype": "Quotation", "field_map": {"name": "party_name"}}}, - target_doc, - set_missing_values, - ) - - target_doc.quotation_to = "Customer" - target_doc.run_method("set_missing_values") - target_doc.run_method("set_other_charges") - target_doc.run_method("calculate_taxes_and_totals") - - price_list, currency = frappe.db.get_value( - "Customer", {"name": source_name}, ["default_price_list", "default_currency"] - ) - if price_list: - target_doc.selling_price_list = price_list - if currency: - target_doc.currency = currency - - return target_doc - - -@frappe.whitelist() -def make_opportunity(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - _set_missing_values(source, target) - - target_doc = get_mapped_doc( - "Customer", - source_name, - { - "Customer": { - "doctype": "Opportunity", - "field_map": { - "name": "party_name", - "doctype": "opportunity_from", - }, - } - }, - target_doc, - set_missing_values, - ) - - return target_doc - - -@frappe.whitelist() -def make_payment_entry(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - _set_missing_values(source, target) - - target_doc = get_mapped_doc( - "Customer", - source_name, - { - "Customer": { - "doctype": "Payment Entry", - "field_map": { - "name": "party", - }, - } - }, - target_doc, - set_missing_values, - ) - target_doc.party_type = "Customer" - target_doc.party_name = target_doc.party - - return target_doc - - -def _set_missing_values(source, target): - address = frappe.get_all( - "Dynamic Link", - { - "link_doctype": source.doctype, - "link_name": source.name, - "parenttype": "Address", - }, - ["parent"], - limit=1, - ) - - contact = frappe.get_all( - "Dynamic Link", - { - "link_doctype": source.doctype, - "link_name": source.name, - "parenttype": "Contact", - }, - ["parent"], - limit=1, - ) - - if address: - target.customer_address = address[0].parent - - if contact: - target.contact_person = contact[0].parent - target.contact_display, target.contact_email, target.contact_mobile = frappe.get_value( - "Contact", contact[0].parent, ["full_name", "email_id", "mobile_no"] - ) - - @frappe.whitelist() def get_loyalty_programs(doc: Document): """returns applicable loyalty programs for a customer""" @@ -790,90 +687,6 @@ def get_credit_limit(customer, company): return flt(credit_limit) -def make_contact(args, is_primary_contact=1): - values = { - "doctype": "Contact", - "is_primary_contact": is_primary_contact, - "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}], - } - - party_type = args.customer_type if args.doctype == "Customer" else args.supplier_type - party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name" - - if party_type == "Individual": - first, middle, last = parse_full_name(args.get(party_name_key)) - values.update( - { - "first_name": first, - "middle_name": middle, - "last_name": last, - } - ) - else: - values.update( - { - "company_name": args.get(party_name_key), - } - ) - - contact = frappe.get_doc(values) - - if args.get("email_id"): - contact.add_email(args.get("email_id"), is_primary=True) - if args.get("mobile_no"): - contact.add_phone(args.get("mobile_no"), is_primary_mobile_no=True) - if args.get("first_name"): - contact.first_name = args.get("first_name") - if args.get("last_name"): - contact.last_name = args.get("last_name") - - if flags := args.get("flags"): - contact.insert(ignore_permissions=flags.get("ignore_permissions")) - else: - contact.insert() - - return contact - - -def make_address(args, is_primary_address=1, is_shipping_address=1): - reqd_fields = [] - for field in ["city", "country"]: - if not args.get(field): - reqd_fields.append("
  • " + field.title() + "
  • ") - - if reqd_fields: - msg = _("Following fields are mandatory to create address:") - frappe.throw( - "{}

      {}
    ".format(msg, "\n".join(reqd_fields)), - title=_("Missing Values Required"), - ) - - party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name" - - address = frappe.get_doc( - { - "doctype": "Address", - "address_title": args.get(party_name_key), - "address_line1": args.get("address_line1"), - "address_line2": args.get("address_line2"), - "city": args.get("city"), - "state": args.get("state"), - "pincode": args.get("pincode"), - "country": args.get("country"), - "is_primary_address": is_primary_address, - "is_shipping_address": is_shipping_address, - "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}], - } - ) - - if flags := args.get("flags"): - address.insert(ignore_permissions=flags.get("ignore_permissions")) - else: - address.insert() - - return address - - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_customer_primary(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict): @@ -898,13 +711,3 @@ def get_customer_primary(doctype: str, txt: str, searchfield: str, start: int, p query = query.select(type_doctype.email_id) return query.run() - - -def parse_full_name(full_name: str) -> tuple[str, str | None, str | None]: - """Parse full name into first name, middle name and last name""" - names = full_name.split() - first_name = names[0] - middle_name = " ".join(names[1:-1]) if len(names) > 2 else None - last_name = names[-1] if len(names) > 1 else None - - return first_name, middle_name, last_name diff --git a/erpnext/selling/doctype/customer/mapper.py b/erpnext/selling/doctype/customer/mapper.py new file mode 100644 index 00000000000..7f30aef8cc0 --- /dev/null +++ b/erpnext/selling/doctype/customer/mapper.py @@ -0,0 +1,212 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc + + +@frappe.whitelist() +def make_quotation(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + _set_missing_values(source, target) + + target_doc = get_mapped_doc( + "Customer", + source_name, + {"Customer": {"doctype": "Quotation", "field_map": {"name": "party_name"}}}, + target_doc, + set_missing_values, + ) + + target_doc.quotation_to = "Customer" + target_doc.run_method("set_missing_values") + target_doc.run_method("set_other_charges") + target_doc.run_method("calculate_taxes_and_totals") + + price_list, currency = frappe.db.get_value( + "Customer", {"name": source_name}, ["default_price_list", "default_currency"] + ) + if price_list: + target_doc.selling_price_list = price_list + if currency: + target_doc.currency = currency + + return target_doc + + +@frappe.whitelist() +def make_opportunity(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + _set_missing_values(source, target) + + target_doc = get_mapped_doc( + "Customer", + source_name, + { + "Customer": { + "doctype": "Opportunity", + "field_map": { + "name": "party_name", + "doctype": "opportunity_from", + }, + } + }, + target_doc, + set_missing_values, + ) + + return target_doc + + +@frappe.whitelist() +def make_payment_entry(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + _set_missing_values(source, target) + + target_doc = get_mapped_doc( + "Customer", + source_name, + { + "Customer": { + "doctype": "Payment Entry", + "field_map": { + "name": "party", + }, + } + }, + target_doc, + set_missing_values, + ) + target_doc.party_type = "Customer" + target_doc.party_name = target_doc.party + + return target_doc + + +def _set_missing_values(source, target): + address = frappe.get_all( + "Dynamic Link", + { + "link_doctype": source.doctype, + "link_name": source.name, + "parenttype": "Address", + }, + ["parent"], + limit=1, + ) + + contact = frappe.get_all( + "Dynamic Link", + { + "link_doctype": source.doctype, + "link_name": source.name, + "parenttype": "Contact", + }, + ["parent"], + limit=1, + ) + + if address: + target.customer_address = address[0].parent + + if contact: + target.contact_person = contact[0].parent + target.contact_display, target.contact_email, target.contact_mobile = frappe.get_value( + "Contact", contact[0].parent, ["full_name", "email_id", "mobile_no"] + ) + + +def make_contact(args, is_primary_contact=1): + values = { + "doctype": "Contact", + "is_primary_contact": is_primary_contact, + "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}], + } + + party_type = args.customer_type if args.doctype == "Customer" else args.supplier_type + party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name" + + if party_type == "Individual": + first, middle, last = parse_full_name(args.get(party_name_key)) + values.update( + { + "first_name": first, + "middle_name": middle, + "last_name": last, + } + ) + else: + values.update( + { + "company_name": args.get(party_name_key), + } + ) + + contact = frappe.get_doc(values) + + if args.get("email_id"): + contact.add_email(args.get("email_id"), is_primary=True) + if args.get("mobile_no"): + contact.add_phone(args.get("mobile_no"), is_primary_mobile_no=True) + if args.get("first_name"): + contact.first_name = args.get("first_name") + if args.get("last_name"): + contact.last_name = args.get("last_name") + + if flags := args.get("flags"): + contact.insert(ignore_permissions=flags.get("ignore_permissions")) + else: + contact.insert() + + return contact + + +def make_address(args, is_primary_address=1, is_shipping_address=1): + reqd_fields = [] + for field in ["city", "country"]: + if not args.get(field): + reqd_fields.append("
  • " + field.title() + "
  • ") + + if reqd_fields: + msg = _("Following fields are mandatory to create address:") + frappe.throw( + "{}

      {}
    ".format(msg, "\n".join(reqd_fields)), + title=_("Missing Values Required"), + ) + + party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name" + + address = frappe.get_doc( + { + "doctype": "Address", + "address_title": args.get(party_name_key), + "address_line1": args.get("address_line1"), + "address_line2": args.get("address_line2"), + "city": args.get("city"), + "state": args.get("state"), + "pincode": args.get("pincode"), + "country": args.get("country"), + "is_primary_address": is_primary_address, + "is_shipping_address": is_shipping_address, + "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}], + } + ) + + if flags := args.get("flags"): + address.insert(ignore_permissions=flags.get("ignore_permissions")) + else: + address.insert() + + return address + + +def parse_full_name(full_name: str) -> tuple[str, str | None, str | None]: + """Parse full name into first name, middle name and last name""" + names = full_name.split() + first_name = names[0] + middle_name = " ".join(names[1:-1]) if len(names) > 2 else None + last_name = names[-1] if len(names) > 1 else None + + return first_name, middle_name, last_name From 18d1a88a64e37998048a965e7caf4a8d707e0182 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:22:17 +0530 Subject: [PATCH 38/65] refactor(purchase_order): move mapping functions to mapper.py --- .../buying/doctype/purchase_order/mapper.py | 315 +++++++++++++++++ .../doctype/purchase_order/purchase_order.py | 317 +----------------- 2 files changed, 326 insertions(+), 306 deletions(-) create mode 100644 erpnext/buying/doctype/purchase_order/mapper.py diff --git a/erpnext/buying/doctype/purchase_order/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py new file mode 100644 index 00000000000..23aa32f4410 --- /dev/null +++ b/erpnext/buying/doctype/purchase_order/mapper.py @@ -0,0 +1,315 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt, get_link_to_form + +from erpnext.accounts.party import get_party_account +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_item_defaults + + +def set_missing_values(source, target): + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") + + +@frappe.whitelist() +def make_purchase_receipt( + source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + + def update_item(obj, target, source_parent): + target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) + target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) + target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) + target.base_amount = ( + (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) + ) + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + doc = get_mapped_doc( + "Purchase Order", + source_name, + { + "Purchase Order": { + "doctype": "Purchase Receipt", + "field_map": {"supplier_warehouse": "supplier_warehouse"}, + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Order Item": { + "doctype": "Purchase Receipt Item", + "field_map": { + "name": "purchase_order_item", + "parent": "purchase_order", + "bom": "bom", + "material_request": "material_request", + "material_request_item": "material_request_item", + "sales_order": "sales_order", + "sales_order_item": "sales_order_item", + "wip_composite_asset": "wip_composite_asset", + }, + "postprocess": update_item, + "condition": lambda doc: ( + True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) + ) + and doc.delivered_by_supplier != 1 + and select_item(doc), + }, + "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, + }, + target_doc, + set_missing_values, + ) + + return doc + + +@frappe.whitelist() +def make_purchase_invoice( + source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None +): + return get_mapped_purchase_invoice(source_name, target_doc, args=args) + + +@frappe.whitelist() +def make_purchase_invoice_from_portal(purchase_order_name: str): + doc = get_mapped_purchase_invoice(purchase_order_name, ignore_permissions=True) + if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"): + frappe.throw(_("Not Permitted"), frappe.PermissionError) + doc.save() + if not frappe.in_test: + frappe.db.commit() + frappe.response["type"] = "redirect" + frappe.response.location = "/purchase-invoices/" + doc.name + + +def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + def postprocess(source, target): + target.flags.ignore_permissions = ignore_permissions + set_missing_values(source, target) + + # Get the advance paid Journal Entries in Purchase Invoice Advance + if target.get("allocate_advances_automatically"): + target.set_advances() + + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(target).set_payment_schedule() + target.credit_to = get_party_account("Supplier", source.supplier, source.company) + + def get_billed_qty(po_item_name): + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty).as_("qty")) + .where((table.docstatus == 1) & (table.po_detail == po_item_name)) + ) + return query.run(pluck="qty")[0] or 0 + + def update_item(obj, target, source_parent): + billed_qty = flt(get_billed_qty(obj.name)) + target.qty = flt(obj.qty) - billed_qty + + item = get_item_defaults(target.item_code, source_parent.company) + item_group = get_item_group_defaults(target.item_code, source_parent.company) + target.cost_center = ( + obj.cost_center + or frappe.db.get_value("Project", obj.project, "cost_center") + or item.get("buying_cost_center") + or item_group.get("buying_cost_center") + ) + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + fields = { + "Purchase Order": { + "doctype": "Purchase Invoice", + "field_map": { + "party_account_currency": "party_account_currency", + "supplier_warehouse": "supplier_warehouse", + }, + "field_no_map": ["payment_terms_template"], + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Order Item": { + "doctype": "Purchase Invoice Item", + "field_map": { + "name": "po_detail", + "parent": "purchase_order", + "material_request": "material_request", + "material_request_item": "material_request_item", + "wip_composite_asset": "wip_composite_asset", + }, + "postprocess": update_item, + "condition": lambda doc: ( + doc.base_amount == 0 + or abs(doc.billed_amt) < abs(doc.amount) + or doc.qty > flt(get_billed_qty(doc.name)) + ) + and select_item(doc), + }, + "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, + } + + doc = get_mapped_doc( + "Purchase Order", + source_name, + fields, + target_doc, + postprocess, + ignore_permissions=ignore_permissions, + ) + + return doc + + +@frappe.whitelist() +def make_inter_company_sales_order(source_name: str, target_doc: str | Document | None = None): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction + + return make_inter_company_transaction("Purchase Order", source_name, target_doc) + + +@frappe.whitelist() +def make_subcontracting_order( + source_name: str, + target_doc: str | Document | None = None, + save: bool = False, + submit: bool = False, + notify: bool = False, +): + if not is_po_fully_subcontracted(source_name): + target_doc = get_mapped_subcontracting_order(source_name, target_doc) + + if (save or submit) and frappe.has_permission(target_doc.doctype, "create"): + target_doc.save() + + if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc): + try: + target_doc.submit() + except Exception as e: + target_doc.add_comment("Comment", _("Submit Action Failed") + "

    " + str(e)) + + if notify: + frappe.msgprint( + _("Subcontracting Order {0} created.").format( + get_link_to_form(target_doc.doctype, target_doc.name) + ), + indicator="green", + alert=True, + ) + + return target_doc + else: + frappe.throw(_("This Purchase Order has been fully subcontracted.")) + + +def is_po_fully_subcontracted(po_name: str) -> bool: + table = frappe.qb.DocType("Purchase Order Item") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where((table.parent == po_name) & (table.qty != table.subcontracted_qty)) + ) + return not query.run(as_dict=True) + + +def get_mapped_subcontracting_order(source_name: str, target_doc: str | Document | None = None) -> Document: + def post_process(source_doc, target_doc): + target_doc.populate_items_table() + + if target_doc.set_warehouse: + for item in target_doc.items: + item.warehouse = target_doc.set_warehouse + else: + if source_doc.set_warehouse: + for item in target_doc.items: + item.warehouse = source_doc.set_warehouse + else: + for idx, item in enumerate(target_doc.items): + item.warehouse = source_doc.items[idx].warehouse + + for idx, item in enumerate(target_doc.items): + item.job_card = source_doc.items[idx].job_card + if not target_doc.supplier_warehouse: + # WIP warehouse is set as Supplier Warehouse in Job Card + target_doc.supplier_warehouse = frappe.get_cached_value( + "Job Card", item.job_card, "wip_warehouse" + ) + + production_plan = set([item.production_plan for item in source_doc.items if item.production_plan]) + if production_plan: + target_doc.production_plan = production_plan.pop() + target_doc.reserve_stock = frappe.get_single_value( + "Stock Settings", "auto_reserve_stock" + ) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock") + + if target_doc and isinstance(target_doc, str): + target_doc = json.loads(target_doc) + for key in ["service_items", "items", "supplied_items"]: + if key in target_doc: + del target_doc[key] + target_doc = json.dumps(target_doc) + + target_doc = get_mapped_doc( + "Purchase Order", + source_name, + { + "Purchase Order": { + "doctype": "Subcontracting Order", + "field_map": {}, + "field_no_map": ["total_qty", "total", "net_total"], + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Order Item": { + "doctype": "Subcontracting Order Service Item", + "field_map": { + "name": "purchase_order_item", + "material_request": "material_request", + "material_request_item": "material_request_item", + }, + "field_no_map": ["qty", "fg_item_qty", "amount"], + "condition": lambda item: item.qty != item.subcontracted_qty, + }, + }, + target_doc, + post_process, + ) + + return target_doc diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 7ff80d31d58..c57666643ff 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -8,28 +8,34 @@ import frappe from frappe import _, msgprint from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, get_link_to_form +from frappe.utils import cint, cstr, flt from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.party import get_party_account, get_party_account_currency +from erpnext.accounts.party import get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, ) -from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details +from erpnext.stock.doctype.item.item import get_last_purchase_details from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty from erpnext.stock.utils import get_bin from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( get_subcontracting_boms_for_finished_goods, ) +from .mapper import ( + make_inter_company_sales_order, + make_purchase_invoice, + make_purchase_invoice_from_portal, + make_purchase_receipt, + make_subcontracting_order, +) + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -737,189 +743,6 @@ def close_or_unclose_purchase_orders(names: str, status: str): frappe.local.message_log = [] -def set_missing_values(source, target): - target.run_method("set_missing_values") - target.run_method("calculate_taxes_and_totals") - target.run_method("set_use_serial_batch_fields") - - -@frappe.whitelist() -def make_purchase_receipt( - source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items") - - def is_unit_price_row(source): - return has_unit_price_items and source.qty == 0 - - def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - doc = get_mapped_doc( - "Purchase Order", - source_name, - { - "Purchase Order": { - "doctype": "Purchase Receipt", - "field_map": {"supplier_warehouse": "supplier_warehouse"}, - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Order Item": { - "doctype": "Purchase Receipt Item", - "field_map": { - "name": "purchase_order_item", - "parent": "purchase_order", - "bom": "bom", - "material_request": "material_request", - "material_request_item": "material_request_item", - "sales_order": "sales_order", - "sales_order_item": "sales_order_item", - "wip_composite_asset": "wip_composite_asset", - }, - "postprocess": update_item, - "condition": lambda doc: ( - True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) - ) - and doc.delivered_by_supplier != 1 - and select_item(doc), - }, - "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, - }, - target_doc, - set_missing_values, - ) - - return doc - - -@frappe.whitelist() -def make_purchase_invoice( - source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None -): - return get_mapped_purchase_invoice(source_name, target_doc, args=args) - - -@frappe.whitelist() -def make_purchase_invoice_from_portal(purchase_order_name: str): - doc = get_mapped_purchase_invoice(purchase_order_name, ignore_permissions=True) - if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"): - frappe.throw(_("Not Permitted"), frappe.PermissionError) - doc.save() - if not frappe.in_test: - frappe.db.commit() - frappe.response["type"] = "redirect" - frappe.response.location = "/purchase-invoices/" + doc.name - - -def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - def postprocess(source, target): - target.flags.ignore_permissions = ignore_permissions - set_missing_values(source, target) - - # Get the advance paid Journal Entries in Purchase Invoice Advance - if target.get("allocate_advances_automatically"): - target.set_advances() - - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(target).set_payment_schedule() - target.credit_to = get_party_account("Supplier", source.supplier, source.company) - - def get_billed_qty(po_item_name): - from frappe.query_builder.functions import Sum - - table = frappe.qb.DocType("Purchase Invoice Item") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty).as_("qty")) - .where((table.docstatus == 1) & (table.po_detail == po_item_name)) - ) - return query.run(pluck="qty")[0] or 0 - - def update_item(obj, target, source_parent): - billed_qty = flt(get_billed_qty(obj.name)) - target.qty = flt(obj.qty) - billed_qty - - item = get_item_defaults(target.item_code, source_parent.company) - item_group = get_item_group_defaults(target.item_code, source_parent.company) - target.cost_center = ( - obj.cost_center - or frappe.db.get_value("Project", obj.project, "cost_center") - or item.get("buying_cost_center") - or item_group.get("buying_cost_center") - ) - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - fields = { - "Purchase Order": { - "doctype": "Purchase Invoice", - "field_map": { - "party_account_currency": "party_account_currency", - "supplier_warehouse": "supplier_warehouse", - }, - "field_no_map": ["payment_terms_template"], - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Order Item": { - "doctype": "Purchase Invoice Item", - "field_map": { - "name": "po_detail", - "parent": "purchase_order", - "material_request": "material_request", - "material_request_item": "material_request_item", - "wip_composite_asset": "wip_composite_asset", - }, - "postprocess": update_item, - "condition": lambda doc: ( - doc.base_amount == 0 - or abs(doc.billed_amt) < abs(doc.amount) - or doc.qty > flt(get_billed_qty(doc.name)) - ) - and select_item(doc), - }, - "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, - } - - doc = get_mapped_doc( - "Purchase Order", - source_name, - fields, - target_doc, - postprocess, - ignore_permissions=ignore_permissions, - ) - - return doc - - def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -941,121 +764,3 @@ def update_status(status: str, name: str): po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit") po.update_status(status) po.update_delivered_qty_in_sales_order() - - -@frappe.whitelist() -def make_inter_company_sales_order(source_name: str, target_doc: str | Document | None = None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction - - return make_inter_company_transaction("Purchase Order", source_name, target_doc) - - -@frappe.whitelist() -def make_subcontracting_order( - source_name: str, - target_doc: str | Document | None = None, - save: bool = False, - submit: bool = False, - notify: bool = False, -): - if not is_po_fully_subcontracted(source_name): - target_doc = get_mapped_subcontracting_order(source_name, target_doc) - - if (save or submit) and frappe.has_permission(target_doc.doctype, "create"): - target_doc.save() - - if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc): - try: - target_doc.submit() - except Exception as e: - target_doc.add_comment("Comment", _("Submit Action Failed") + "

    " + str(e)) - - if notify: - frappe.msgprint( - _("Subcontracting Order {0} created.").format( - get_link_to_form(target_doc.doctype, target_doc.name) - ), - indicator="green", - alert=True, - ) - - return target_doc - else: - frappe.throw(_("This Purchase Order has been fully subcontracted.")) - - -def is_po_fully_subcontracted(po_name): - table = frappe.qb.DocType("Purchase Order Item") - query = ( - frappe.qb.from_(table) - .select(table.name) - .where((table.parent == po_name) & (table.qty != table.subcontracted_qty)) - ) - return not query.run(as_dict=True) - - -def get_mapped_subcontracting_order(source_name, target_doc=None): - def post_process(source_doc, target_doc): - target_doc.populate_items_table() - - if target_doc.set_warehouse: - for item in target_doc.items: - item.warehouse = target_doc.set_warehouse - else: - if source_doc.set_warehouse: - for item in target_doc.items: - item.warehouse = source_doc.set_warehouse - else: - for idx, item in enumerate(target_doc.items): - item.warehouse = source_doc.items[idx].warehouse - - for idx, item in enumerate(target_doc.items): - item.job_card = source_doc.items[idx].job_card - if not target_doc.supplier_warehouse: - # WIP warehouse is set as Supplier Warehouse in Job Card - target_doc.supplier_warehouse = frappe.get_cached_value( - "Job Card", item.job_card, "wip_warehouse" - ) - - production_plan = set([item.production_plan for item in source_doc.items if item.production_plan]) - if production_plan: - target_doc.production_plan = production_plan.pop() - target_doc.reserve_stock = frappe.get_single_value( - "Stock Settings", "auto_reserve_stock" - ) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock") - - if target_doc and isinstance(target_doc, str): - target_doc = json.loads(target_doc) - for key in ["service_items", "items", "supplied_items"]: - if key in target_doc: - del target_doc[key] - target_doc = json.dumps(target_doc) - - target_doc = get_mapped_doc( - "Purchase Order", - source_name, - { - "Purchase Order": { - "doctype": "Subcontracting Order", - "field_map": {}, - "field_no_map": ["total_qty", "total", "net_total"], - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Order Item": { - "doctype": "Subcontracting Order Service Item", - "field_map": { - "name": "purchase_order_item", - "material_request": "material_request", - "material_request_item": "material_request_item", - }, - "field_no_map": ["qty", "fg_item_qty", "amount"], - "condition": lambda item: item.qty != item.subcontracted_qty, - }, - }, - target_doc, - post_process, - ) - - return target_doc From 01e7224210aa0a9f17868a750da7970c86e27b06 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:23:31 +0530 Subject: [PATCH 39/65] refactor(supplier_quotation): move mapping functions to mapper.py --- .../doctype/supplier_quotation/mapper.py | 110 ++++++++++++++++++ .../supplier_quotation/supplier_quotation.py | 108 +---------------- 2 files changed, 113 insertions(+), 105 deletions(-) create mode 100644 erpnext/buying/doctype/supplier_quotation/mapper.py diff --git a/erpnext/buying/doctype/supplier_quotation/mapper.py b/erpnext/buying/doctype/supplier_quotation/mapper.py new file mode 100644 index 00000000000..aebe5d94a4c --- /dev/null +++ b/erpnext/buying/doctype/supplier_quotation/mapper.py @@ -0,0 +1,110 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt + + +@frappe.whitelist() +def make_purchase_order( + source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + def set_missing_values(source, target): + target.run_method("set_missing_values") + target.run_method("get_schedule_dates") + target.run_method("calculate_taxes_and_totals") + + def update_item(obj, target, source_parent): + target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + doclist = get_mapped_doc( + "Supplier Quotation", + source_name, + { + "Supplier Quotation": { + "doctype": "Purchase Order", + "field_no_map": ["transaction_date"], + "validation": { + "docstatus": ["=", 1], + }, + }, + "Supplier Quotation Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "supplier_quotation_item"], + ["parent", "supplier_quotation"], + ["material_request", "material_request"], + ["material_request_item", "material_request_item"], + ["sales_order", "sales_order"], + ], + "postprocess": update_item, + "condition": select_item, + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_purchase_invoice(source_name: str, target_doc: str | Document | None = None): + doc = get_mapped_doc( + "Supplier Quotation", + source_name, + { + "Supplier Quotation": { + "doctype": "Purchase Invoice", + "validation": { + "docstatus": ["=", 1], + }, + }, + "Supplier Quotation Item": {"doctype": "Purchase Invoice Item"}, + "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"}, + }, + target_doc, + ) + + return doc + + +@frappe.whitelist() +def make_quotation(source_name: str, target_doc: str | Document | None = None): + doclist = get_mapped_doc( + "Supplier Quotation", + source_name, + { + "Supplier Quotation": { + "doctype": "Quotation", + "field_map": { + "name": "supplier_quotation", + }, + }, + "Supplier Quotation Item": { + "doctype": "Quotation Item", + "condition": lambda doc: frappe.db.get_value("Item", doc.item_code, "is_sales_item") == 1, + "add_if_empty": True, + }, + }, + target_doc, + ) + + return doclist diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index c7fa6ecfc63..e267f6228c4 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -2,17 +2,16 @@ # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe import _ from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.utils import flt, getdate, nowdate +from frappe.utils import getdate, nowdate from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController +from .mapper import make_purchase_invoice, make_purchase_order, make_quotation + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -245,107 +244,6 @@ def get_list_context(context=None): return list_context -@frappe.whitelist() -def make_purchase_order( - source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - def set_missing_values(source, target): - target.run_method("set_missing_values") - target.run_method("get_schedule_dates") - target.run_method("calculate_taxes_and_totals") - - def update_item(obj, target, source_parent): - target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - doclist = get_mapped_doc( - "Supplier Quotation", - source_name, - { - "Supplier Quotation": { - "doctype": "Purchase Order", - "field_no_map": ["transaction_date"], - "validation": { - "docstatus": ["=", 1], - }, - }, - "Supplier Quotation Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "supplier_quotation_item"], - ["parent", "supplier_quotation"], - ["material_request", "material_request"], - ["material_request_item", "material_request_item"], - ["sales_order", "sales_order"], - ], - "postprocess": update_item, - "condition": select_item, - }, - "Purchase Taxes and Charges": { - "doctype": "Purchase Taxes and Charges", - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_purchase_invoice(source_name: str, target_doc: str | Document | None = None): - doc = get_mapped_doc( - "Supplier Quotation", - source_name, - { - "Supplier Quotation": { - "doctype": "Purchase Invoice", - "validation": { - "docstatus": ["=", 1], - }, - }, - "Supplier Quotation Item": {"doctype": "Purchase Invoice Item"}, - "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"}, - }, - target_doc, - ) - - return doc - - -@frappe.whitelist() -def make_quotation(source_name: str, target_doc: str | Document | None = None): - doclist = get_mapped_doc( - "Supplier Quotation", - source_name, - { - "Supplier Quotation": { - "doctype": "Quotation", - "field_map": { - "name": "supplier_quotation", - }, - }, - "Supplier Quotation Item": { - "doctype": "Quotation Item", - "condition": lambda doc: frappe.db.get_value("Item", doc.item_code, "is_sales_item") == 1, - "add_if_empty": True, - }, - }, - target_doc, - ) - - return doclist - - def set_expired_status(): frappe.db.set_value( "Supplier Quotation", From 2cf51a0367748d5913712d227d046a29be793e70 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:26:55 +0530 Subject: [PATCH 40/65] refactor(request_for_quotation): move mapping functions to mapper.py --- .../doctype/request_for_quotation/mapper.py | 185 ++++++++++++++++++ .../request_for_quotation.py | 184 +---------------- 2 files changed, 191 insertions(+), 178 deletions(-) create mode 100644 erpnext/buying/doctype/request_for_quotation/mapper.py diff --git a/erpnext/buying/doctype/request_for_quotation/mapper.py b/erpnext/buying/doctype/request_for_quotation/mapper.py new file mode 100644 index 00000000000..1f9878b03ab --- /dev/null +++ b/erpnext/buying/doctype/request_for_quotation/mapper.py @@ -0,0 +1,185 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc + +from erpnext.accounts.party import get_party_account_currency, get_party_details +from erpnext.stock.doctype.material_request.material_request import set_missing_values + + +@frappe.whitelist() +def make_supplier_quotation_from_rfq( + source_name: str, target_doc: str | Document | None = None, for_supplier: str | None = None +): + def postprocess(source, target_doc): + if for_supplier: + target_doc.supplier = for_supplier + args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True) + target_doc.currency = args.currency or get_party_account_currency( + "Supplier", for_supplier, source.company + ) + target_doc.buying_price_list = args.buying_price_list or frappe.db.get_single_value( + "Buying Settings", "buying_price_list" + ) + set_missing_values(source, target_doc) + + doclist = get_mapped_doc( + "Request for Quotation", + source_name, + { + "Request for Quotation": { + "doctype": "Supplier Quotation", + "validation": {"docstatus": ["=", 1]}, + "field_map": {"opportunity": "opportunity"}, + }, + "Request for Quotation Item": { + "doctype": "Supplier Quotation Item", + "field_map": { + "name": "request_for_quotation_item", + "parent": "request_for_quotation", + "project_name": "project", + }, + }, + }, + target_doc, + postprocess, + ) + + return doclist + + +# This method is used to make supplier quotation from supplier's portal. +@frappe.whitelist() +def create_supplier_quotation(doc: str | Document | dict): + if isinstance(doc, str): + doc = json.loads(doc) + + if frappe.session.user not in frappe.get_all( + "Portal User", {"parent": doc.get("supplier")}, pluck="user" + ): + frappe.throw(_("Not Permitted"), frappe.PermissionError) + + try: + sq_doc = frappe.get_doc( + { + "doctype": "Supplier Quotation", + "supplier": doc.get("supplier"), + "terms": doc.get("terms"), + "company": doc.get("company"), + "currency": doc.get("currency") + or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")), + "buying_price_list": doc.get("buying_price_list") + or frappe.db.get_single_value("Buying Settings", "buying_price_list"), + } + ) + add_items(sq_doc, doc.get("supplier"), doc.get("items")) + sq_doc.flags.ignore_permissions = True + sq_doc.run_method("set_missing_values") + sq_doc.save() + frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name)) + return sq_doc.name + except Exception: + return None + + +def add_items(sq_doc, supplier, items): + for data in items: + if isinstance(data, dict): + data = frappe._dict(data) + + create_rfq_items(sq_doc, supplier, data) + + +def create_rfq_items(sq_doc, supplier, data): + args = {} + + for field in [ + "item_code", + "item_name", + "description", + "qty", + "rate", + "conversion_factor", + "warehouse", + "material_request", + "material_request_item", + "stock_qty", + "uom", + ]: + args[field] = data.get(field) + + args.update( + { + "request_for_quotation_item": data.name, + "request_for_quotation": data.parent, + "supplier_part_no": frappe.db.get_value( + "Item Supplier", {"parent": data.item_code, "supplier": supplier}, "supplier_part_no" + ), + } + ) + + sq_doc.append("items", args) + + +@frappe.whitelist() +def get_item_from_material_requests_based_on_supplier( + source_name: str, target_doc: str | Document | None = None +): + Item = frappe.qb.DocType("Item") + Item_Supp = frappe.qb.DocType("Item Supplier") + MR = frappe.qb.DocType("Material Request") + MR_Item = frappe.qb.DocType("Material Request Item") + + query = ( + frappe.qb.from_(MR_Item) + .join(MR) + .on(MR_Item.parent == MR.name) + .join(Item) + .on(MR_Item.item_code == Item.name) + .join(Item_Supp) + .on(Item.name == Item_Supp.parent) + .select(MR.name, MR_Item.item_code) + .where(Item_Supp.supplier == source_name) + .where(MR.status != "Stopped") + .where(MR.material_request_type == "Purchase") + .where(MR.docstatus == 1) + .where(MR.per_ordered < 99.99) + ) + + mr_items_list = query.run(as_dict=True) + + material_requests = {} + for d in mr_items_list: + material_requests.setdefault(d.name, []).append(d.item_code) + + for mr, items in material_requests.items(): + target_doc = get_mapped_doc( + "Material Request", + mr, + { + "Material Request": { + "doctype": "Request for Quotation", + "validation": { + "docstatus": ["=", 1], + "material_request_type": ["=", "Purchase"], + }, + }, + "Material Request Item": { + "doctype": "Request for Quotation Item", + "condition": lambda row: row.item_code in items, + "field_map": [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["uom", "uom"], + ], + }, + }, + target_doc, + ) + + return target_doc diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 3ba026c8a81..0dce4fce279 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -2,23 +2,24 @@ # For license information, please see license.txt -import json - import frappe from frappe import _ from frappe.core.doctype.communication.email import make from frappe.desk.form.load import get_attachments from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Order from frappe.utils import get_url from frappe.utils.print_format import download_pdf from frappe.utils.user import get_user_fullname -from erpnext.accounts.party import get_party_account_currency, get_party_details from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController -from erpnext.stock.doctype.material_request.material_request import set_missing_values + +from .mapper import ( + create_supplier_quotation, + get_item_from_material_requests_based_on_supplier, + make_supplier_quotation_from_rfq, +) STANDARD_USERS = ("Guest", "Administrator") @@ -438,120 +439,6 @@ def get_list_context(context=None): return list_context -@frappe.whitelist() -def make_supplier_quotation_from_rfq( - source_name: str, target_doc: str | Document | None = None, for_supplier: str | None = None -): - def postprocess(source, target_doc): - if for_supplier: - target_doc.supplier = for_supplier - args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True) - target_doc.currency = args.currency or get_party_account_currency( - "Supplier", for_supplier, source.company - ) - target_doc.buying_price_list = args.buying_price_list or frappe.db.get_single_value( - "Buying Settings", "buying_price_list" - ) - set_missing_values(source, target_doc) - - doclist = get_mapped_doc( - "Request for Quotation", - source_name, - { - "Request for Quotation": { - "doctype": "Supplier Quotation", - "validation": {"docstatus": ["=", 1]}, - "field_map": {"opportunity": "opportunity"}, - }, - "Request for Quotation Item": { - "doctype": "Supplier Quotation Item", - "field_map": { - "name": "request_for_quotation_item", - "parent": "request_for_quotation", - "project_name": "project", - }, - }, - }, - target_doc, - postprocess, - ) - - return doclist - - -# This method is used to make supplier quotation from supplier's portal. -@frappe.whitelist() -def create_supplier_quotation(doc: str | Document | dict): - if isinstance(doc, str): - doc = json.loads(doc) - - if frappe.session.user not in frappe.get_all( - "Portal User", {"parent": doc.get("supplier")}, pluck="user" - ): - frappe.throw(_("Not Permitted"), frappe.PermissionError) - - try: - sq_doc = frappe.get_doc( - { - "doctype": "Supplier Quotation", - "supplier": doc.get("supplier"), - "terms": doc.get("terms"), - "company": doc.get("company"), - "currency": doc.get("currency") - or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")), - "buying_price_list": doc.get("buying_price_list") - or frappe.db.get_single_value("Buying Settings", "buying_price_list"), - } - ) - add_items(sq_doc, doc.get("supplier"), doc.get("items")) - sq_doc.flags.ignore_permissions = True - sq_doc.run_method("set_missing_values") - sq_doc.save() - frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name)) - return sq_doc.name - except Exception: - return None - - -def add_items(sq_doc, supplier, items): - for data in items: - if isinstance(data, dict): - data = frappe._dict(data) - - create_rfq_items(sq_doc, supplier, data) - - -def create_rfq_items(sq_doc, supplier, data): - args = {} - - for field in [ - "item_code", - "item_name", - "description", - "qty", - "rate", - "conversion_factor", - "warehouse", - "material_request", - "material_request_item", - "stock_qty", - "uom", - ]: - args[field] = data.get(field) - - args.update( - { - "request_for_quotation_item": data.name, - "request_for_quotation": data.parent, - "supplier_part_no": frappe.db.get_value( - "Item Supplier", {"parent": data.item_code, "supplier": supplier}, "supplier_part_no" - ), - } - ) - - sq_doc.append("items", args) - - @frappe.whitelist() def get_pdf( name: str, @@ -575,65 +462,6 @@ def get_pdf( ) -@frappe.whitelist() -def get_item_from_material_requests_based_on_supplier( - source_name: str, target_doc: str | Document | None = None -): - Item = frappe.qb.DocType("Item") - Item_Supp = frappe.qb.DocType("Item Supplier") - MR = frappe.qb.DocType("Material Request") - MR_Item = frappe.qb.DocType("Material Request Item") - - query = ( - frappe.qb.from_(MR_Item) - .join(MR) - .on(MR_Item.parent == MR.name) - .join(Item) - .on(MR_Item.item_code == Item.name) - .join(Item_Supp) - .on(Item.name == Item_Supp.parent) - .select(MR.name, MR_Item.item_code) - .where(Item_Supp.supplier == source_name) - .where(MR.status != "Stopped") - .where(MR.material_request_type == "Purchase") - .where(MR.docstatus == 1) - .where(MR.per_ordered < 99.99) - ) - - mr_items_list = query.run(as_dict=True) - - material_requests = {} - for d in mr_items_list: - material_requests.setdefault(d.name, []).append(d.item_code) - - for mr, items in material_requests.items(): - target_doc = get_mapped_doc( - "Material Request", - mr, - { - "Material Request": { - "doctype": "Request for Quotation", - "validation": { - "docstatus": ["=", 1], - "material_request_type": ["=", "Purchase"], - }, - }, - "Material Request Item": { - "doctype": "Request for Quotation Item", - "condition": lambda row: row.item_code in items, - "field_map": [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["uom", "uom"], - ], - }, - }, - target_doc, - ) - - return target_doc - - @frappe.whitelist() def get_supplier_tag(): filters = {"document_type": "Supplier"} From 8192d70f830709d55e57a512462a58b02afb6303 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:32:57 +0530 Subject: [PATCH 41/65] refactor(delivery_note): move mapping functions to mapper.py --- .../doctype/delivery_note/delivery_note.py | 582 +---------------- erpnext/stock/doctype/delivery_note/mapper.py | 583 ++++++++++++++++++ 2 files changed, 593 insertions(+), 572 deletions(-) create mode 100644 erpnext/stock/doctype/delivery_note/mapper.py diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index f13c2d9c393..1229b1bad10 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -2,25 +2,27 @@ # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe import _ -from frappe.contacts.doctype.address.address import get_company_address -from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.model.utils import get_fetch_values from frappe.query_builder import DocType from frappe.query_builder.functions import Abs, Sum from frappe.utils import cint, flt -from erpnext.accounts.party import get_due_date -from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes from erpnext.controllers.selling_controller import SellingController from erpnext.stock.doctype.packed_item.packed_item import make_packing_list +from .mapper import ( + make_delivery_trip, + make_installation_note, + make_inter_company_purchase_receipt, + make_packing_slip, + make_sales_invoice, + make_sales_return, + make_shipment, +) + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -754,8 +756,6 @@ class DeliveryNote(SellingController): def update_billed_amount_based_on_so(so_detail, update_modified=True): - from frappe.query_builder.functions import Sum - # Billed against Sales Order directly si = frappe.qb.DocType("Sales Invoice").as_("si") si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") @@ -850,569 +850,7 @@ def get_list_context(context=None): return list_context -def get_invoiced_qty_map(delivery_note): - """returns a map: {dn_detail: invoiced_qty}""" - sii = DocType("Sales Invoice Item") - - invoiced_qty_map = frappe._dict( - ( - frappe.qb.from_(sii) - .select(sii.dn_detail, Sum(sii.qty).as_("qty")) - .where((sii.delivery_note == delivery_note) & (sii.docstatus == 1)) - .groupby(sii.dn_detail) - ).run() - ) - - return invoiced_qty_map - - -def get_returned_qty_map(delivery_note): - """returns a map: {so_detail: returned_qty}""" - dn = DocType("Delivery Note") - dni = DocType("Delivery Note Item") - - returned_qty_map = frappe._dict( - ( - frappe.qb.from_(dni) - .join(dn) - .on(dn.name == dni.parent) - .select(dni.dn_detail, Sum(Abs(dni.qty)).as_("qty")) - .where( - (dn.docstatus == 1) - & (dn.is_return == 1) - & (dn.return_against == delivery_note) - & (dni.qty <= 0) - ) - .groupby(dni.dn_detail) - ).run() - ) - - return returned_qty_map - - -@frappe.whitelist() -def make_sales_invoice( - source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - doc = frappe.get_doc("Delivery Note", source_name) - - to_make_invoice_qty_map = {} - returned_qty_map = get_returned_qty_map(source_name) - invoiced_qty_map = get_invoiced_qty_map(source_name) - - def set_missing_values(source, target): - target.run_method("set_missing_values") - target.run_method("set_po_nos") - - if len(target.get("items")) == 0: - frappe.throw(_("All these items have already been Invoiced/Returned")) - - if args and args.get("merge_taxes"): - merge_taxes(source, target) - - target.run_method("calculate_taxes_and_totals") - - # set company address - if source.company_address: - target.update({"company_address": source.company_address}) - else: - # set company address - target.update(get_company_address(target.company)) - - if target.company_address: - target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address)) - - def update_item(source_doc, target_doc, source_parent): - target_doc.qty = to_make_invoice_qty_map[source_doc.name] - target_doc._old_name = source_doc.name - - def get_pending_qty(item_row): - pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0) - - returned_qty = 0 - if returned_qty_map.get(item_row.name, 0) > 0: - returned_qty = flt(returned_qty_map.get(item_row.name, 0)) - returned_qty_map[item_row.name] -= pending_qty - - if returned_qty: - if returned_qty >= pending_qty: - pending_qty = 0 - returned_qty -= pending_qty - else: - pending_qty -= returned_qty - returned_qty = 0 - - to_make_invoice_qty_map[item_row.name] = pending_qty - - return pending_qty - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - doc = get_mapped_doc( - "Delivery Note", - source_name, - { - "Delivery Note": { - "doctype": "Sales Invoice", - "field_map": {"is_return": "is_return"}, - "validation": {"docstatus": ["=", 1]}, - }, - "Delivery Note Item": { - "doctype": "Sales Invoice Item", - "field_map": { - "name": "dn_detail", - "parent": "delivery_note", - "so_detail": "so_detail", - "against_sales_order": "sales_order", - "cost_center": "cost_center", - }, - "postprocess": update_item, - "filter": lambda d: get_pending_qty(d) <= 0 - if not doc.get("is_return") - else get_pending_qty(d) > 0, - "condition": select_item, - }, - "Sales Taxes and Charges": { - "doctype": "Sales Taxes and Charges", - "reset_value": not (args and args.get("merge_taxes")), - "ignore": args.get("merge_taxes") if args else 0, - }, - "Sales Team": { - "doctype": "Sales Team", - "field_map": {"incentives": "incentives"}, - "add_if_empty": True, - }, - }, - target_doc, - set_missing_values, - ) - - automatically_fetch_payment_terms = cint( - frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") - ) - - if not doc.is_return: - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(doc) - so, doctype, fieldname = ps.get_order_details() - if ( - ps.linked_order_has_payment_terms(so, fieldname, doctype) - and not automatically_fetch_payment_terms - ): - payment_terms_template = frappe.db.get_value(doctype, so, "payment_terms_template") - doc.payment_terms_template = payment_terms_template - doc.due_date = get_due_date( - doc.posting_date, - "Customer", - doc.customer, - doc.company, - template_name=doc.payment_terms_template, - ) - - elif automatically_fetch_payment_terms: - ps.set_payment_schedule() - - return doc - - -@frappe.whitelist() -def make_delivery_trip( - source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None -): - if not target_doc: - target_doc = frappe.new_doc("Delivery Trip") - - def update_address(source_doc, target_doc, source_parent): - target_doc.address = source_doc.shipping_address_name or source_doc.customer_address - target_doc.customer_address = source_doc.shipping_address or source_doc.address_display - - doclist = get_mapped_doc( - "Delivery Note", - source_name, - { - "Delivery Note": { - "doctype": "Delivery Stop", - "on_parent": target_doc, - "field_map": { - "name": "delivery_note", - "contact_person": "contact", - "contact_display": "customer_contact", - }, - "postprocess": update_address, - }, - }, - ignore_child_tables=True, - ) - - return doclist - - -@frappe.whitelist() -def make_installation_note( - source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None -): - def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.installed_qty) - target.serial_no = obj.serial_no - - doclist = get_mapped_doc( - "Delivery Note", - source_name, - { - "Delivery Note": {"doctype": "Installation Note", "validation": {"docstatus": ["=", 1]}}, - "Delivery Note Item": { - "doctype": "Installation Note Item", - "field_map": { - "name": "prevdoc_detail_docname", - "parent": "prevdoc_docname", - "parenttype": "prevdoc_doctype", - }, - "postprocess": update_item, - "condition": lambda doc: doc.installed_qty < doc.qty, - }, - }, - target_doc, - ) - - return doclist - - -@frappe.whitelist() -def make_packing_slip(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - target.run_method("set_missing_values") - - def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.packed_qty) - - doclist = get_mapped_doc( - "Delivery Note", - source_name, - { - "Delivery Note": { - "doctype": "Packing Slip", - "field_map": {"name": "delivery_note", "letter_head": "letter_head"}, - "validation": {"docstatus": ["=", 0]}, - }, - "Delivery Note Item": { - "doctype": "Packing Slip Item", - "field_map": { - "item_code": "item_code", - "item_name": "item_name", - "batch_no": "batch_no", - "description": "description", - "qty": "qty", - "uom": "stock_uom", - "name": "dn_detail", - }, - "postprocess": update_item, - "condition": lambda item: ( - not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0}) - and flt(item.packed_qty) < flt(item.qty) - ), - }, - "Packed Item": { - "doctype": "Packing Slip Item", - "field_map": { - "item_code": "item_code", - "item_name": "item_name", - "batch_no": "batch_no", - "description": "description", - "qty": "qty", - "name": "pi_detail", - }, - "postprocess": update_item, - "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)), - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_shipment(source_name: str, target_doc: str | Document | None = None): - def postprocess(source, target): - user = frappe.db.get_value( - "User", frappe.session.user, ["email", "full_name", "phone", "mobile_no"], as_dict=1 - ) - target.pickup_contact_email = user.email - pickup_contact_display = f"{user.full_name}" - if user: - if user.email: - pickup_contact_display += "
    " + user.email - if user.phone: - pickup_contact_display += "
    " + user.phone - if user.mobile_no and not user.phone: - pickup_contact_display += "
    " + user.mobile_no - target.pickup_contact = pickup_contact_display - - # As we are using session user details in the pickup_contact then pickup_contact_person will be session user - target.pickup_contact_person = frappe.session.user - - contact_person = source.contact_person or get_default_contact("Customer", source.customer) - if contact_person: - contact = frappe.db.get_value( - "Contact", contact_person, ["email_id", "phone", "mobile_no"], as_dict=1 - ) - - delivery_contact_display = source.contact_display or contact_person or "" - if contact and not source.contact_display: - if contact.email_id: - delivery_contact_display += "
    " + contact.email_id - if contact.phone: - delivery_contact_display += "
    " + contact.phone - if contact.mobile_no and not contact.phone: - delivery_contact_display += "
    " + contact.mobile_no - - target.delivery_contact_name = contact_person - if contact and contact.email_id and not target.delivery_contact_email: - target.delivery_contact_email = contact.email_id - target.delivery_contact = delivery_contact_display - - if source.shipping_address_name: - target.delivery_address_name = source.shipping_address_name - target.delivery_address = source.shipping_address - elif source.customer_address: - target.delivery_address_name = source.customer_address - target.delivery_address = source.address_display - - doclist = get_mapped_doc( - "Delivery Note", - source_name, - { - "Delivery Note": { - "doctype": "Shipment", - "field_map": { - "grand_total": "value_of_goods", - "company": "pickup_company", - "company_address": "pickup_address_name", - "company_address_display": "pickup_address", - "customer": "delivery_customer", - "contact_person": "delivery_contact_name", - "contact_email": "delivery_contact_email", - }, - "validation": {"docstatus": ["=", 1]}, - }, - "Delivery Note Item": { - "doctype": "Shipment Delivery Note", - "field_map": { - "name": "prevdoc_detail_docname", - "parent": "prevdoc_docname", - "parenttype": "prevdoc_doctype", - "base_amount": "grand_total", - }, - }, - }, - target_doc, - postprocess, - ) - - return doclist - - -@frappe.whitelist() -def make_sales_return(source_name: str, target_doc: str | Document | None = None): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Delivery Note", source_name, target_doc) - - @frappe.whitelist() def update_delivery_note_status(docname: str, status: str): dn = frappe.get_lazy_doc("Delivery Note", docname) dn.update_status(status) - - -@frappe.whitelist() -def make_inter_company_purchase_receipt(source_name: str, target_doc: str | Document | None = None): - return make_inter_company_transaction("Delivery Note", source_name, target_doc) - - -def make_inter_company_transaction(doctype, source_name, target_doc=None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( - get_inter_company_details, - set_purchase_references, - update_address, - update_taxes, - validate_inter_company_transaction, - ) - - if doctype == "Delivery Note": - source_doc = frappe.get_doc(doctype, source_name) - target_doctype = "Purchase Receipt" - source_document_warehouse_field = "target_warehouse" - target_document_warehouse_field = "from_warehouse" - else: - source_doc = frappe.get_doc(doctype, source_name) - target_doctype = "Delivery Note" - source_document_warehouse_field = "from_warehouse" - target_document_warehouse_field = "target_warehouse" - - validate_inter_company_transaction(source_doc, doctype) - details = get_inter_company_details(source_doc, doctype) - - def set_missing_values(source, target): - target.run_method("set_missing_values") - set_purchase_references(target) - - if target.doctype == "Purchase Receipt": - master_doctype = "Purchase Taxes and Charges Template" - else: - master_doctype = "Sales Taxes and Charges Template" - - if not target.get("taxes") and target.get("taxes_and_charges"): - for tax in get_taxes_and_charges(master_doctype, target.get("taxes_and_charges")): - target.append("taxes", tax) - - if not target.get("items"): - frappe.throw(_("All items have already been received")) - - def update_details(source_doc, target_doc, source_parent): - def _validate_address_link(address, link_doctype, link_name): - return frappe.db.get_value( - "Dynamic Link", - { - "parent": address, - "parenttype": "Address", - "link_doctype": link_doctype, - "link_name": link_name, - }, - "parent", - ) - - target_doc.inter_company_invoice_reference = source_doc.name - if target_doc.doctype == "Purchase Receipt": - target_doc.company = details.get("company") - target_doc.supplier = details.get("party") - target_doc.buying_price_list = source_doc.selling_price_list - target_doc.is_internal_supplier = 1 - target_doc.inter_company_reference = source_doc.name - - # Invert the address on target doc creation - if source_doc.company_address and _validate_address_link( - source_doc.company_address, "Supplier", details.get("party") - ): - update_address(target_doc, "supplier_address", "address_display", source_doc.company_address) - if source_doc.dispatch_address_name and _validate_address_link( - source_doc.dispatch_address_name, "Company", details.get("company") - ): - update_address( - target_doc, - "dispatch_address", - "dispatch_address_display", - source_doc.dispatch_address_name, - ) - if source_doc.shipping_address_name and _validate_address_link( - source_doc.shipping_address_name, "Company", details.get("company") - ): - update_address( - target_doc, - "shipping_address", - "shipping_address_display", - source_doc.shipping_address_name, - ) - if source_doc.customer_address and _validate_address_link( - source_doc.customer_address, "Company", details.get("company") - ): - update_address( - target_doc, "billing_address", "billing_address_display", source_doc.customer_address - ) - - update_taxes( - target_doc, - party=target_doc.supplier, - party_type="Supplier", - company=target_doc.company, - doctype=target_doc.doctype, - party_address=target_doc.supplier_address, - company_address=target_doc.shipping_address, - ) - else: - target_doc.company = details.get("company") - target_doc.customer = details.get("party") - target_doc.company_address = source_doc.supplier_address - target_doc.selling_price_list = source_doc.buying_price_list - target_doc.is_internal_customer = 1 - target_doc.inter_company_reference = source_doc.name - - # Invert the address on target doc creation - if source_doc.supplier_address and _validate_address_link( - source_doc.supplier_address, "Company", details.get("company") - ): - update_address( - target_doc, "company_address", "company_address_display", source_doc.supplier_address - ) - if source_doc.shipping_address and _validate_address_link( - source_doc.shipping_address, "Customer", details.get("party") - ): - update_address( - target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address - ) - if source_doc.shipping_address and _validate_address_link( - source_doc.shipping_address, "Customer", details.get("party") - ): - update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address) - - update_taxes( - target_doc, - party=target_doc.customer, - party_type="Customer", - company=target_doc.company, - doctype=target_doc.doctype, - party_address=target_doc.customer_address, - company_address=target_doc.company_address, - shipping_address_name=target_doc.shipping_address_name, - ) - - def update_item(source, target, source_parent): - if source_parent.doctype == "Delivery Note" and source.received_qty: - target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty) - - if source.get("use_serial_batch_fields"): - target.set("use_serial_batch_fields", 1) - - if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"): - target.set("use_serial_batch_fields", 1) - - doclist = get_mapped_doc( - doctype, - source_name, - { - doctype: { - "doctype": target_doctype, - "postprocess": update_details, - "field_no_map": ["taxes_and_charges", "set_warehouse"], - "field_map": {"shipping_address_name": "shipping_address"}, - }, - doctype + " Item": { - "doctype": target_doctype + " Item", - "field_map": { - source_document_warehouse_field: target_document_warehouse_field, - "name": "delivery_note_item", - "purchase_order": "purchase_order", - "purchase_order_item": "purchase_order_item", - "material_request": "material_request", - "Material_request_item": "material_request_item", - }, - "field_no_map": ["warehouse"], - "condition": lambda item: item.received_qty < item.qty + item.returned_qty, - "postprocess": update_item, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py new file mode 100644 index 00000000000..ad9417f4db8 --- /dev/null +++ b/erpnext/stock/doctype/delivery_note/mapper.py @@ -0,0 +1,583 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.contacts.doctype.contact.contact import get_default_contact +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.model.utils import get_fetch_values +from frappe.query_builder import DocType +from frappe.query_builder.functions import Abs, Sum +from frappe.utils import flt + +from erpnext.accounts.party import get_due_date +from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes + + +def get_invoiced_qty_map(delivery_note: str) -> dict: + """returns a map: {dn_detail: invoiced_qty}""" + sii = DocType("Sales Invoice Item") + + invoiced_qty_map = frappe._dict( + ( + frappe.qb.from_(sii) + .select(sii.dn_detail, Sum(sii.qty).as_("qty")) + .where((sii.delivery_note == delivery_note) & (sii.docstatus == 1)) + .groupby(sii.dn_detail) + ).run() + ) + + return invoiced_qty_map + + +def get_returned_qty_map(delivery_note: str) -> dict: + """returns a map: {so_detail: returned_qty}""" + dn = DocType("Delivery Note") + dni = DocType("Delivery Note Item") + + returned_qty_map = frappe._dict( + ( + frappe.qb.from_(dni) + .join(dn) + .on(dn.name == dni.parent) + .select(dni.dn_detail, Sum(Abs(dni.qty)).as_("qty")) + .where( + (dn.docstatus == 1) + & (dn.is_return == 1) + & (dn.return_against == delivery_note) + & (dni.qty <= 0) + ) + .groupby(dni.dn_detail) + ).run() + ) + + return returned_qty_map + + +@frappe.whitelist() +def make_sales_invoice( + source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None +): + from frappe.contacts.doctype.address.address import get_company_address + + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + doc = frappe.get_doc("Delivery Note", source_name) + + to_make_invoice_qty_map = {} + returned_qty_map = get_returned_qty_map(source_name) + invoiced_qty_map = get_invoiced_qty_map(source_name) + + def set_missing_values(source, target): + target.run_method("set_missing_values") + target.run_method("set_po_nos") + + if len(target.get("items")) == 0: + frappe.throw(_("All these items have already been Invoiced/Returned")) + + if args and args.get("merge_taxes"): + merge_taxes(source, target) + + target.run_method("calculate_taxes_and_totals") + + # set company address + if source.company_address: + target.update({"company_address": source.company_address}) + else: + # set company address + target.update(get_company_address(target.company)) + + if target.company_address: + target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address)) + + def update_item(source_doc, target_doc, source_parent): + target_doc.qty = to_make_invoice_qty_map[source_doc.name] + target_doc._old_name = source_doc.name + + def get_pending_qty(item_row): + pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0) + + returned_qty = 0 + if returned_qty_map.get(item_row.name, 0) > 0: + returned_qty = flt(returned_qty_map.get(item_row.name, 0)) + returned_qty_map[item_row.name] -= pending_qty + + if returned_qty: + if returned_qty >= pending_qty: + pending_qty = 0 + returned_qty -= pending_qty + else: + pending_qty -= returned_qty + returned_qty = 0 + + to_make_invoice_qty_map[item_row.name] = pending_qty + + return pending_qty + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + doc = get_mapped_doc( + "Delivery Note", + source_name, + { + "Delivery Note": { + "doctype": "Sales Invoice", + "field_map": {"is_return": "is_return"}, + "validation": {"docstatus": ["=", 1]}, + }, + "Delivery Note Item": { + "doctype": "Sales Invoice Item", + "field_map": { + "name": "dn_detail", + "parent": "delivery_note", + "so_detail": "so_detail", + "against_sales_order": "sales_order", + "cost_center": "cost_center", + }, + "postprocess": update_item, + "filter": lambda d: get_pending_qty(d) <= 0 + if not doc.get("is_return") + else get_pending_qty(d) > 0, + "condition": select_item, + }, + "Sales Taxes and Charges": { + "doctype": "Sales Taxes and Charges", + "reset_value": not (args and args.get("merge_taxes")), + "ignore": args.get("merge_taxes") if args else 0, + }, + "Sales Team": { + "doctype": "Sales Team", + "field_map": {"incentives": "incentives"}, + "add_if_empty": True, + }, + }, + target_doc, + set_missing_values, + ) + + from frappe.utils import cint + + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + + if not doc.is_return: + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + ps = PaymentScheduleService(doc) + so, doctype, fieldname = ps.get_order_details() + if ( + ps.linked_order_has_payment_terms(so, fieldname, doctype) + and not automatically_fetch_payment_terms + ): + payment_terms_template = frappe.db.get_value(doctype, so, "payment_terms_template") + doc.payment_terms_template = payment_terms_template + doc.due_date = get_due_date( + doc.posting_date, + "Customer", + doc.customer, + doc.company, + template_name=doc.payment_terms_template, + ) + + elif automatically_fetch_payment_terms: + ps.set_payment_schedule() + + return doc + + +@frappe.whitelist() +def make_delivery_trip( + source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None +): + if not target_doc: + target_doc = frappe.new_doc("Delivery Trip") + + def update_address(source_doc, target_doc, source_parent): + target_doc.address = source_doc.shipping_address_name or source_doc.customer_address + target_doc.customer_address = source_doc.shipping_address or source_doc.address_display + + doclist = get_mapped_doc( + "Delivery Note", + source_name, + { + "Delivery Note": { + "doctype": "Delivery Stop", + "on_parent": target_doc, + "field_map": { + "name": "delivery_note", + "contact_person": "contact", + "contact_display": "customer_contact", + }, + "postprocess": update_address, + }, + }, + ignore_child_tables=True, + ) + + return doclist + + +@frappe.whitelist() +def make_installation_note( + source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None +): + def update_item(obj, target, source_parent): + target.qty = flt(obj.qty) - flt(obj.installed_qty) + target.serial_no = obj.serial_no + + doclist = get_mapped_doc( + "Delivery Note", + source_name, + { + "Delivery Note": {"doctype": "Installation Note", "validation": {"docstatus": ["=", 1]}}, + "Delivery Note Item": { + "doctype": "Installation Note Item", + "field_map": { + "name": "prevdoc_detail_docname", + "parent": "prevdoc_docname", + "parenttype": "prevdoc_doctype", + }, + "postprocess": update_item, + "condition": lambda doc: doc.installed_qty < doc.qty, + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_packing_slip(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + target.run_method("set_missing_values") + + def update_item(obj, target, source_parent): + target.qty = flt(obj.qty) - flt(obj.packed_qty) + + doclist = get_mapped_doc( + "Delivery Note", + source_name, + { + "Delivery Note": { + "doctype": "Packing Slip", + "field_map": {"name": "delivery_note", "letter_head": "letter_head"}, + "validation": {"docstatus": ["=", 0]}, + }, + "Delivery Note Item": { + "doctype": "Packing Slip Item", + "field_map": { + "item_code": "item_code", + "item_name": "item_name", + "batch_no": "batch_no", + "description": "description", + "qty": "qty", + "uom": "stock_uom", + "name": "dn_detail", + }, + "postprocess": update_item, + "condition": lambda item: ( + not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0}) + and flt(item.packed_qty) < flt(item.qty) + ), + }, + "Packed Item": { + "doctype": "Packing Slip Item", + "field_map": { + "item_code": "item_code", + "item_name": "item_name", + "batch_no": "batch_no", + "description": "description", + "qty": "qty", + "name": "pi_detail", + }, + "postprocess": update_item, + "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)), + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_shipment(source_name: str, target_doc: str | Document | None = None): + def postprocess(source, target): + user = frappe.db.get_value( + "User", frappe.session.user, ["email", "full_name", "phone", "mobile_no"], as_dict=1 + ) + target.pickup_contact_email = user.email + pickup_contact_display = f"{user.full_name}" + if user: + if user.email: + pickup_contact_display += "
    " + user.email + if user.phone: + pickup_contact_display += "
    " + user.phone + if user.mobile_no and not user.phone: + pickup_contact_display += "
    " + user.mobile_no + target.pickup_contact = pickup_contact_display + + # As we are using session user details in the pickup_contact then pickup_contact_person will be session user + target.pickup_contact_person = frappe.session.user + + contact_person = source.contact_person or get_default_contact("Customer", source.customer) + if contact_person: + contact = frappe.db.get_value( + "Contact", contact_person, ["email_id", "phone", "mobile_no"], as_dict=1 + ) + + delivery_contact_display = source.contact_display or contact_person or "" + if contact and not source.contact_display: + if contact.email_id: + delivery_contact_display += "
    " + contact.email_id + if contact.phone: + delivery_contact_display += "
    " + contact.phone + if contact.mobile_no and not contact.phone: + delivery_contact_display += "
    " + contact.mobile_no + + target.delivery_contact_name = contact_person + if contact and contact.email_id and not target.delivery_contact_email: + target.delivery_contact_email = contact.email_id + target.delivery_contact = delivery_contact_display + + if source.shipping_address_name: + target.delivery_address_name = source.shipping_address_name + target.delivery_address = source.shipping_address + elif source.customer_address: + target.delivery_address_name = source.customer_address + target.delivery_address = source.address_display + + doclist = get_mapped_doc( + "Delivery Note", + source_name, + { + "Delivery Note": { + "doctype": "Shipment", + "field_map": { + "grand_total": "value_of_goods", + "company": "pickup_company", + "company_address": "pickup_address_name", + "company_address_display": "pickup_address", + "customer": "delivery_customer", + "contact_person": "delivery_contact_name", + "contact_email": "delivery_contact_email", + }, + "validation": {"docstatus": ["=", 1]}, + }, + "Delivery Note Item": { + "doctype": "Shipment Delivery Note", + "field_map": { + "name": "prevdoc_detail_docname", + "parent": "prevdoc_docname", + "parenttype": "prevdoc_doctype", + "base_amount": "grand_total", + }, + }, + }, + target_doc, + postprocess, + ) + + return doclist + + +@frappe.whitelist() +def make_sales_return(source_name: str, target_doc: str | Document | None = None): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Delivery Note", source_name, target_doc) + + +@frappe.whitelist() +def make_inter_company_purchase_receipt(source_name: str, target_doc: str | Document | None = None): + return make_inter_company_transaction("Delivery Note", source_name, target_doc) + + +def make_inter_company_transaction(doctype: str, source_name: str, target_doc=None): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + get_inter_company_details, + set_purchase_references, + update_address, + update_taxes, + validate_inter_company_transaction, + ) + + if doctype == "Delivery Note": + source_doc = frappe.get_doc(doctype, source_name) + target_doctype = "Purchase Receipt" + source_document_warehouse_field = "target_warehouse" + target_document_warehouse_field = "from_warehouse" + else: + source_doc = frappe.get_doc(doctype, source_name) + target_doctype = "Delivery Note" + source_document_warehouse_field = "from_warehouse" + target_document_warehouse_field = "target_warehouse" + + validate_inter_company_transaction(source_doc, doctype) + details = get_inter_company_details(source_doc, doctype) + + def set_missing_values(source, target): + target.run_method("set_missing_values") + set_purchase_references(target) + + if target.doctype == "Purchase Receipt": + master_doctype = "Purchase Taxes and Charges Template" + else: + master_doctype = "Sales Taxes and Charges Template" + + if not target.get("taxes") and target.get("taxes_and_charges"): + for tax in get_taxes_and_charges(master_doctype, target.get("taxes_and_charges")): + target.append("taxes", tax) + + if not target.get("items"): + frappe.throw(_("All items have already been received")) + + def update_details(source_doc, target_doc, source_parent): + def _validate_address_link(address, link_doctype, link_name): + return frappe.db.get_value( + "Dynamic Link", + { + "parent": address, + "parenttype": "Address", + "link_doctype": link_doctype, + "link_name": link_name, + }, + "parent", + ) + + target_doc.inter_company_invoice_reference = source_doc.name + if target_doc.doctype == "Purchase Receipt": + target_doc.company = details.get("company") + target_doc.supplier = details.get("party") + target_doc.buying_price_list = source_doc.selling_price_list + target_doc.is_internal_supplier = 1 + target_doc.inter_company_reference = source_doc.name + + # Invert the address on target doc creation + if source_doc.company_address and _validate_address_link( + source_doc.company_address, "Supplier", details.get("party") + ): + update_address(target_doc, "supplier_address", "address_display", source_doc.company_address) + if source_doc.dispatch_address_name and _validate_address_link( + source_doc.dispatch_address_name, "Company", details.get("company") + ): + update_address( + target_doc, + "dispatch_address", + "dispatch_address_display", + source_doc.dispatch_address_name, + ) + if source_doc.shipping_address_name and _validate_address_link( + source_doc.shipping_address_name, "Company", details.get("company") + ): + update_address( + target_doc, + "shipping_address", + "shipping_address_display", + source_doc.shipping_address_name, + ) + if source_doc.customer_address and _validate_address_link( + source_doc.customer_address, "Company", details.get("company") + ): + update_address( + target_doc, "billing_address", "billing_address_display", source_doc.customer_address + ) + + update_taxes( + target_doc, + party=target_doc.supplier, + party_type="Supplier", + company=target_doc.company, + doctype=target_doc.doctype, + party_address=target_doc.supplier_address, + company_address=target_doc.shipping_address, + ) + else: + target_doc.company = details.get("company") + target_doc.customer = details.get("party") + target_doc.company_address = source_doc.supplier_address + target_doc.selling_price_list = source_doc.buying_price_list + target_doc.is_internal_customer = 1 + target_doc.inter_company_reference = source_doc.name + + # Invert the address on target doc creation + if source_doc.supplier_address and _validate_address_link( + source_doc.supplier_address, "Company", details.get("company") + ): + update_address( + target_doc, "company_address", "company_address_display", source_doc.supplier_address + ) + if source_doc.shipping_address and _validate_address_link( + source_doc.shipping_address, "Customer", details.get("party") + ): + update_address( + target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address + ) + if source_doc.shipping_address and _validate_address_link( + source_doc.shipping_address, "Customer", details.get("party") + ): + update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address) + + update_taxes( + target_doc, + party=target_doc.customer, + party_type="Customer", + company=target_doc.company, + doctype=target_doc.doctype, + party_address=target_doc.customer_address, + company_address=target_doc.company_address, + shipping_address_name=target_doc.shipping_address_name, + ) + + def update_item(source, target, source_parent): + if source_parent.doctype == "Delivery Note" and source.received_qty: + target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty) + + if source.get("use_serial_batch_fields"): + target.set("use_serial_batch_fields", 1) + + if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"): + target.set("use_serial_batch_fields", 1) + + doclist = get_mapped_doc( + doctype, + source_name, + { + doctype: { + "doctype": target_doctype, + "postprocess": update_details, + "field_no_map": ["taxes_and_charges", "set_warehouse"], + "field_map": {"shipping_address_name": "shipping_address"}, + }, + doctype + " Item": { + "doctype": target_doctype + " Item", + "field_map": { + source_document_warehouse_field: target_document_warehouse_field, + "name": "delivery_note_item", + "purchase_order": "purchase_order", + "purchase_order_item": "purchase_order_item", + "material_request": "material_request", + "Material_request_item": "material_request_item", + }, + "field_no_map": ["warehouse"], + "condition": lambda item: item.received_qty < item.qty + item.returned_qty, + "postprocess": update_item, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist From 220b6fe572971d30caef2239a4f3760a0dc23e79 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:33:42 +0530 Subject: [PATCH 42/65] refactor(delivery_note): re-export make_inter_company_transaction --- erpnext/stock/doctype/delivery_note/delivery_note.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 1229b1bad10..b4a89673c8f 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -17,6 +17,7 @@ from .mapper import ( make_delivery_trip, make_installation_note, make_inter_company_purchase_receipt, + make_inter_company_transaction, make_packing_slip, make_sales_invoice, make_sales_return, From 0968adafc8cca38bd7fd58479794495e40a0ab1a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:36:08 +0530 Subject: [PATCH 43/65] refactor(purchase_receipt): move mapping functions to mapper.py --- .../stock/doctype/purchase_receipt/mapper.py | 254 ++++++++++++++++++ .../purchase_receipt/purchase_receipt.py | 254 +----------------- 2 files changed, 262 insertions(+), 246 deletions(-) create mode 100644 erpnext/stock/doctype/purchase_receipt/mapper.py diff --git a/erpnext/stock/doctype/purchase_receipt/mapper.py b/erpnext/stock/doctype/purchase_receipt/mapper.py new file mode 100644 index 00000000000..efbe5e73d88 --- /dev/null +++ b/erpnext/stock/doctype/purchase_receipt/mapper.py @@ -0,0 +1,254 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Abs, Sum +from frappe.utils import flt + +from erpnext.controllers.accounts_controller import merge_taxes +from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction +from erpnext.stock.serial_batch_bundle import ( + SerialBatchCreation, + get_batches_from_bundle, + get_serial_nos_from_bundle, +) + + +def get_invoiced_qty_map(purchase_receipt: str) -> dict: + """returns a map: {pr_detail: invoiced_qty}""" + invoiced_qty_map = {} + + for pr_detail, qty in frappe.db.sql( + """select pr_detail, qty from `tabPurchase Invoice Item` + where purchase_receipt=%s and docstatus=1""", + purchase_receipt, + ): + if not invoiced_qty_map.get(pr_detail): + invoiced_qty_map[pr_detail] = 0 + invoiced_qty_map[pr_detail] += qty + + return invoiced_qty_map + + +def get_returned_qty_map(purchase_receipt: str) -> dict: + """returns a map: {pr_detail: returned_qty}""" + pr = frappe.qb.DocType("Purchase Receipt") + pr_item = frappe.qb.DocType("Purchase Receipt Item") + + query = ( + frappe.qb.from_(pr) + .inner_join(pr_item) + .on(pr.name == pr_item.parent) + .select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty")) + .where( + (pr.docstatus == 1) + & (pr.is_return == 1) + & (pr.return_against == purchase_receipt) + & (pr_item.purchase_receipt_item.isnotnull()) + ) + .groupby(pr_item.purchase_receipt_item) + ).run(as_list=1) + + return frappe._dict(query) if query else frappe._dict() + + +@frappe.whitelist() +def make_purchase_invoice( + source_name: str | None, target_doc: str | Document | None = None, args: dict | str | None = None +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + from erpnext.accounts.party import get_payment_terms_template + + doc = frappe.get_doc("Purchase Receipt", source_name) + returned_qty_map = get_returned_qty_map(source_name) + invoiced_qty_map = get_invoiced_qty_map(source_name) + + def set_missing_values(source, target): + if len(target.get("items")) == 0: + frappe.throw(_("All items have already been Invoiced/Returned")) + + doc = frappe.get_doc(target) + doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company) + doc.run_method("onload") + doc.run_method("set_missing_values") + + if args and args.get("merge_taxes"): + merge_taxes(source, doc) + + doc.run_method("calculate_taxes_and_totals") + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doc).set_payment_schedule() + + def update_item(source_doc, target_doc, source_parent): + target_doc.qty, returned_qty = get_pending_qty(source_doc) + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + target_doc.rejected_qty = 0 + target_doc.stock_qty = flt(target_doc.qty) * flt( + target_doc.conversion_factor, target_doc.precision("conversion_factor") + ) + returned_qty_map[source_doc.name] = returned_qty + target_doc._old_name = source_doc.name + + def get_pending_qty(item_row): + qty = item_row.qty + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + qty = item_row.received_qty + + pending_qty = qty - invoiced_qty_map.get(item_row.name, 0) + + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + return pending_qty, 0 + + returned_qty = flt(returned_qty_map.get(item_row.name, 0)) + if item_row.rejected_qty and returned_qty: + returned_qty -= item_row.rejected_qty + + if returned_qty: + if returned_qty >= pending_qty: + pending_qty = 0 + returned_qty -= pending_qty + else: + pending_qty -= returned_qty + returned_qty = 0 + + return pending_qty, returned_qty + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + doclist = get_mapped_doc( + "Purchase Receipt", + source_name, + { + "Purchase Receipt": { + "doctype": "Purchase Invoice", + "field_map": { + "supplier_warehouse": "supplier_warehouse", + "is_return": "is_return", + "bill_date": "bill_date", + }, + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Receipt Item": { + "doctype": "Purchase Invoice Item", + "field_map": { + "name": "pr_detail", + "parent": "purchase_receipt", + "qty": "received_qty", + "purchase_order_item": "po_detail", + "purchase_order": "purchase_order", + "is_fixed_asset": "is_fixed_asset", + "asset_location": "asset_location", + "asset_category": "asset_category", + "wip_composite_asset": "wip_composite_asset", + }, + "postprocess": update_item, + "filter": lambda d: ( + get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0 + ), + "condition": select_item, + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": not (args and args.get("merge_taxes")), + "ignore": args.get("merge_taxes") if args else 0, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_purchase_return_against_rejected_warehouse(source_name: str): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Purchase Receipt", source_name, return_against_rejected_qty=True) + + +@frappe.whitelist() +def make_purchase_return(source_name: str, target_doc: str | Document | None = None): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Purchase Receipt", source_name, target_doc) + + +@frappe.whitelist() +def make_stock_entry(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + target.stock_entry_type = "Material Transfer" + target.purpose = "Material Transfer" + target.set_missing_values() + + def update_item(source_doc, target_doc, source_parent): + if source_doc.serial_and_batch_bundle: + serial_nos = get_serial_nos_from_bundle(source_doc.serial_and_batch_bundle) + if serial_nos: + serial_nos = "\n".join(serial_nos) + + batches = get_batches_from_bundle(source_doc.serial_and_batch_bundle) + if batches: + if len(batches) == 1: + target_doc.use_serial_batch_fields = 1 + target_doc.batch_no = next(iter(batches)) + elif not serial_nos: + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": source_doc.serial_and_batch_bundle, + "item_code": source_doc.item_code, + "warehouse": source_doc.warehouse, + } + ) + + cls_obj.duplicate_package() + + target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle + + if serial_nos: + target_doc.use_serial_batch_fields = 1 + target_doc.serial_no = serial_nos + + doclist = get_mapped_doc( + "Purchase Receipt", + source_name, + { + "Purchase Receipt": { + "doctype": "Stock Entry", + }, + "Purchase Receipt Item": { + "doctype": "Stock Entry Detail", + "field_map": { + "warehouse": "s_warehouse", + "parent": "reference_purchase_receipt", + "batch_no": "batch_no", + }, + "postprocess": update_item, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_inter_company_delivery_note(source_name: str, target_doc: str | Document | None = None): + return make_inter_company_transaction("Purchase Receipt", source_name, target_doc) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 0a48b00776c..710eb63c6ab 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -2,14 +2,11 @@ # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe import _, throw from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.query_builder.functions import Abs, CombineDatetime, Sum +from frappe.query_builder.functions import CombineDatetime from frappe.utils import cint, flt, get_datetime, getdate, nowdate from pypika import functions as fn @@ -17,14 +14,15 @@ import erpnext from erpnext.accounts.utils import get_account_currency from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled from erpnext.buying.utils import check_on_hold_or_closed_status -from erpnext.controllers.accounts_controller import merge_taxes from erpnext.controllers.buying_controller import BuyingController -from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation -from erpnext.stock.serial_batch_bundle import ( - SerialBatchCreation, - get_batches_from_bundle, - get_serial_nos_from_bundle, + +from .mapper import ( + make_inter_company_delivery_note, + make_purchase_invoice, + make_purchase_return, + make_purchase_return_against_rejected_warehouse, + make_stock_entry, ) form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -1084,248 +1082,12 @@ def get_item_wise_returned_qty(pr_doc): ) -@frappe.whitelist() -def make_purchase_invoice( - source_name: str | None, target_doc: str | Document | None = None, args: dict | str | None = None -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - from erpnext.accounts.party import get_payment_terms_template - - doc = frappe.get_doc("Purchase Receipt", source_name) - returned_qty_map = get_returned_qty_map(source_name) - invoiced_qty_map = get_invoiced_qty_map(source_name) - - def set_missing_values(source, target): - if len(target.get("items")) == 0: - frappe.throw(_("All items have already been Invoiced/Returned")) - - doc = frappe.get_doc(target) - doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company) - doc.run_method("onload") - doc.run_method("set_missing_values") - - if args and args.get("merge_taxes"): - merge_taxes(source, doc) - - doc.run_method("calculate_taxes_and_totals") - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(doc).set_payment_schedule() - - def update_item(source_doc, target_doc, source_parent): - target_doc.qty, returned_qty = get_pending_qty(source_doc) - if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): - target_doc.rejected_qty = 0 - target_doc.stock_qty = flt(target_doc.qty) * flt( - target_doc.conversion_factor, target_doc.precision("conversion_factor") - ) - returned_qty_map[source_doc.name] = returned_qty - target_doc._old_name = source_doc.name - - def get_pending_qty(item_row): - qty = item_row.qty - if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): - qty = item_row.received_qty - - pending_qty = qty - invoiced_qty_map.get(item_row.name, 0) - - if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): - return pending_qty, 0 - - returned_qty = flt(returned_qty_map.get(item_row.name, 0)) - if item_row.rejected_qty and returned_qty: - returned_qty -= item_row.rejected_qty - - if returned_qty: - if returned_qty >= pending_qty: - pending_qty = 0 - returned_qty -= pending_qty - else: - pending_qty -= returned_qty - returned_qty = 0 - - return pending_qty, returned_qty - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - doclist = get_mapped_doc( - "Purchase Receipt", - source_name, - { - "Purchase Receipt": { - "doctype": "Purchase Invoice", - "field_map": { - "supplier_warehouse": "supplier_warehouse", - "is_return": "is_return", - "bill_date": "bill_date", - }, - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Receipt Item": { - "doctype": "Purchase Invoice Item", - "field_map": { - "name": "pr_detail", - "parent": "purchase_receipt", - "qty": "received_qty", - "purchase_order_item": "po_detail", - "purchase_order": "purchase_order", - "is_fixed_asset": "is_fixed_asset", - "asset_location": "asset_location", - "asset_category": "asset_category", - "wip_composite_asset": "wip_composite_asset", - }, - "postprocess": update_item, - "filter": lambda d: ( - get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0 - ), - "condition": select_item, - }, - "Purchase Taxes and Charges": { - "doctype": "Purchase Taxes and Charges", - "reset_value": not (args and args.get("merge_taxes")), - "ignore": args.get("merge_taxes") if args else 0, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -def get_invoiced_qty_map(purchase_receipt): - """returns a map: {pr_detail: invoiced_qty}""" - invoiced_qty_map = {} - - for pr_detail, qty in frappe.db.sql( - """select pr_detail, qty from `tabPurchase Invoice Item` - where purchase_receipt=%s and docstatus=1""", - purchase_receipt, - ): - if not invoiced_qty_map.get(pr_detail): - invoiced_qty_map[pr_detail] = 0 - invoiced_qty_map[pr_detail] += qty - - return invoiced_qty_map - - -def get_returned_qty_map(purchase_receipt): - """returns a map: {pr_detail: returned_qty}""" - - pr = frappe.qb.DocType("Purchase Receipt") - pr_item = frappe.qb.DocType("Purchase Receipt Item") - - query = ( - frappe.qb.from_(pr) - .inner_join(pr_item) - .on(pr.name == pr_item.parent) - .select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty")) - .where( - (pr.docstatus == 1) - & (pr.is_return == 1) - & (pr.return_against == purchase_receipt) - & (pr_item.purchase_receipt_item.isnotnull()) - ) - .groupby(pr_item.purchase_receipt_item) - ).run(as_list=1) - - return frappe._dict(query) if query else frappe._dict() - - -@frappe.whitelist() -def make_purchase_return_against_rejected_warehouse(source_name: str): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Purchase Receipt", source_name, return_against_rejected_qty=True) - - -@frappe.whitelist() -def make_purchase_return(source_name: str, target_doc: str | Document | None = None): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Purchase Receipt", source_name, target_doc) - - @frappe.whitelist() def update_purchase_receipt_status(docname: str, status: str): pr = frappe.get_lazy_doc("Purchase Receipt", docname, check_permission="submit") pr.update_status(status) -@frappe.whitelist() -def make_stock_entry(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - target.stock_entry_type = "Material Transfer" - target.purpose = "Material Transfer" - target.set_missing_values() - - def update_item(source_doc, target_doc, source_parent): - if source_doc.serial_and_batch_bundle: - serial_nos = get_serial_nos_from_bundle(source_doc.serial_and_batch_bundle) - if serial_nos: - serial_nos = "\n".join(serial_nos) - - batches = get_batches_from_bundle(source_doc.serial_and_batch_bundle) - if batches: - if len(batches) == 1: - target_doc.use_serial_batch_fields = 1 - target_doc.batch_no = next(iter(batches)) - elif not serial_nos: - cls_obj = SerialBatchCreation( - { - "type_of_transaction": "Outward", - "serial_and_batch_bundle": source_doc.serial_and_batch_bundle, - "item_code": source_doc.item_code, - "warehouse": source_doc.warehouse, - } - ) - - cls_obj.duplicate_package() - - target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle - - if serial_nos: - target_doc.use_serial_batch_fields = 1 - target_doc.serial_no = serial_nos - - doclist = get_mapped_doc( - "Purchase Receipt", - source_name, - { - "Purchase Receipt": { - "doctype": "Stock Entry", - }, - "Purchase Receipt Item": { - "doctype": "Stock Entry Detail", - "field_map": { - "warehouse": "s_warehouse", - "parent": "reference_purchase_receipt", - "batch_no": "batch_no", - }, - "postprocess": update_item, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_inter_company_delivery_note(source_name: str, target_doc: str | Document | None = None): - return make_inter_company_transaction("Purchase Receipt", source_name, target_doc) - - @erpnext.allow_regional def update_regional_gl_entries(gl_list, doc): return From 7b9f61e058abb6941b065192dde54e41e6b94a27 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:41:44 +0530 Subject: [PATCH 44/65] refactor(material_request): move mapping functions to mapper.py --- .../stock/doctype/material_request/mapper.py | 370 +++++++++++++++++ .../material_request/material_request.py | 371 +----------------- 2 files changed, 381 insertions(+), 360 deletions(-) create mode 100644 erpnext/stock/doctype/material_request/mapper.py diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py new file mode 100644 index 00000000000..1ecc842a7b4 --- /dev/null +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -0,0 +1,370 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import cint, flt, getdate, nowdate + +from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( + get_subcontracting_boms_for_finished_goods, +) + + +def set_missing_values(source, target_doc): + if target_doc.doctype == "Purchase Order" and getdate(target_doc.schedule_date) < getdate(nowdate()): + target_doc.schedule_date = None + target_doc.run_method("set_missing_values") + target_doc.run_method("calculate_taxes_and_totals") + + +def update_item(obj, target, source_parent): + target.conversion_factor = obj.conversion_factor + + qty = obj.ordered_qty or obj.received_qty + target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor + target.stock_qty = target.qty * target.conversion_factor + if getdate(target.schedule_date) < getdate(nowdate()): + target.schedule_date = None + + if target.fg_item: + target.fg_item_qty = obj.stock_qty + if sc_bom := get_subcontracting_boms_for_finished_goods(target.fg_item): + target.item_code = sc_bom.service_item + target.uom = sc_bom.service_item_uom + target.conversion_factor = ( + frappe.db.get_value( + "UOM Conversion Detail", + {"parent": sc_bom.service_item, "uom": sc_bom.service_item_uom}, + "conversion_factor", + ) + or 1 + ) + target.qty = target.fg_item_qty * sc_bom.conversion_factor + target.stock_qty = target.qty * target.conversion_factor + + +@frappe.whitelist() +def make_purchase_order( + source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + is_subcontracted = ( + frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting" + ) + + def postprocess(source, target_doc): + target_doc.is_subcontracted = is_subcontracted + set_missing_values(source, target_doc) + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + + qty = d.ordered_qty or d.received_qty + + return qty < d.stock_qty and child_filter + + def generate_field_map(): + field_map = [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["sales_order", "sales_order"], + ["sales_order_item", "sales_order_item"], + ["wip_composite_asset", "wip_composite_asset"], + ] + + if is_subcontracted: + field_map.extend([["item_code", "fg_item"], ["qty", "fg_item_qty"]]) + else: + field_map.extend([["uom", "stock_uom"], ["uom", "uom"]]) + + return field_map + + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Purchase Order", + "validation": { + "docstatus": ["=", 1], + "material_request_type": ["in", ["Purchase", "Subcontracting"]], + }, + }, + "Material Request Item": { + "doctype": "Purchase Order Item", + "field_map": generate_field_map(), + "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [], + "postprocess": update_item, + "condition": select_item, + }, + }, + target_doc, + postprocess, + ) + + doclist.set_onload("load_after_mapping", False) + return doclist + + +@frappe.whitelist() +def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None): + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Request for Quotation", + "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, + }, + "Material Request Item": { + "doctype": "Request for Quotation Item", + "field_map": [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["project", "project_name"], + ], + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def get_items_based_on_default_supplier(supplier: str): + supplier_items = [ + d.parent + for d in frappe.db.get_all( + "Item Default", {"default_supplier": supplier, "parenttype": "Item"}, "parent" + ) + ] + + return supplier_items + + +@frappe.whitelist() +def make_purchase_order_based_on_supplier( + source_name: str, target_doc: str | Document | None = None, args: dict | None = None +): + mr = source_name + + supplier_items = get_items_based_on_default_supplier(args.get("supplier")) + + def postprocess(source, target_doc): + target_doc.supplier = args.get("supplier") + if getdate(target_doc.schedule_date) < getdate(nowdate()): + target_doc.schedule_date = None + target_doc.set( + "items", + [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0], + ) + + set_missing_values(source, target_doc) + + target_doc = get_mapped_doc( + "Material Request", + mr, + { + "Material Request": { + "doctype": "Purchase Order", + }, + "Material Request Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["uom", "stock_uom"], + ["uom", "uom"], + ], + "postprocess": update_item, + "condition": lambda doc: doc.ordered_qty < doc.qty, + }, + }, + target_doc, + postprocess, + ) + + return target_doc + + +@frappe.whitelist() +def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None): + def postprocess(source, target_doc): + set_missing_values(source, target_doc) + + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Supplier Quotation", + "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, + }, + "Material Request Item": { + "doctype": "Supplier Quotation Item", + "field_map": { + "name": "material_request_item", + "parent": "material_request", + "sales_order": "sales_order", + }, + }, + }, + target_doc, + postprocess, + ) + + doclist.set_onload("load_after_mapping", False) + return doclist + + +@frappe.whitelist() +def make_stock_entry(source_name: str, target_doc: str | Document | None = None): + def update_item(obj, target, source_parent): + qty = ( + flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor + if flt(obj.stock_qty) > flt(obj.ordered_qty) + else 0 + ) + target.qty = qty + target.transfer_qty = qty * obj.conversion_factor + target.conversion_factor = obj.conversion_factor + + if ( + source_parent.material_request_type == "Material Transfer" + or source_parent.material_request_type == "Customer Provided" + ): + target.t_warehouse = obj.warehouse + else: + target.s_warehouse = obj.warehouse + + if source_parent.material_request_type == "Customer Provided": + target.allow_zero_valuation_rate = 1 + + if source_parent.material_request_type == "Material Transfer": + target.s_warehouse = obj.from_warehouse + + def set_missing_values(source, target): + target.purpose = source.material_request_type + target.from_warehouse = source.set_from_warehouse + target.to_warehouse = source.set_warehouse + if source.material_request_type == "Material Issue": + target.from_warehouse = source.set_warehouse + target.to_warehouse = None + + if source.job_card: + target.purpose = "Material Transfer for Manufacture" + + if source.material_request_type == "Customer Provided": + target.purpose = "Material Receipt" + + target.set_transfer_qty() + target.set_actual_qty() + target.calculate_rate_and_amount(raise_error_if_no_rate=False) + target.stock_entry_type = target.purpose + + if source.job_card: + job_card_details = frappe.get_all( + "Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"] + ) + + if job_card_details and job_card_details[0]: + target.bom_no = job_card_details[0].bom_no + target.fg_completed_qty = job_card_details[0].for_quantity + target.from_bom = 1 + + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + "material_request_type": [ + "in", + ["Material Transfer", "Material Issue", "Customer Provided"], + ], + }, + }, + "Material Request Item": { + "doctype": "Stock Entry Detail", + "field_map": { + "name": "material_request_item", + "parent": "material_request", + "uom": "stock_uom", + "job_card_item": "job_card_item", + }, + "field_no_map": ["expense_account"], + "postprocess": update_item, + "condition": lambda doc: ( + flt(doc.ordered_qty, doc.precision("ordered_qty")) + < flt(doc.stock_qty, doc.precision("ordered_qty")) + ), + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def create_pick_list(source_name: str, target_doc: str | Document | None = None): + def update_item(obj, target, source_parent): + qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty")) + target.qty = qty + target.stock_qty = qty * obj.conversion_factor + target.conversion_factor = obj.conversion_factor + + doc = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Pick List", + "field_map": {"material_request_type": "purpose"}, + "validation": {"docstatus": ["=", 1]}, + }, + "Material Request Item": { + "doctype": "Pick List Item", + "field_map": { + "name": "material_request_item", + "stock_qty": "stock_qty", + "from_warehouse": "warehouse", + }, + "postprocess": update_item, + "condition": lambda doc: ( + flt(doc.picked_qty, doc.precision("picked_qty")) + < flt(doc.stock_qty, doc.precision("stock_qty")) + ), + }, + }, + target_doc, + ) + + doc.set_item_locations() + + return doc + + +@frappe.whitelist() +def make_in_transit_stock_entry(source_name: str, in_transit_warehouse: str): + ste_doc = make_stock_entry(source_name) + ste_doc.add_to_transit = 1 + ste_doc.to_warehouse = in_transit_warehouse + + for row in ste_doc.items: + row.t_warehouse = in_transit_warehouse + + return ste_doc diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 8d8239626a1..0a99e19662d 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -5,14 +5,12 @@ # For license information, please see license.txt -import json from typing import Any import frappe import frappe.defaults from frappe import _, msgprint from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Order from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_sep, nowdate @@ -21,8 +19,17 @@ from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_it from erpnext.controllers.buying_controller import BuyingController from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty -from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( - get_subcontracting_boms_for_finished_goods, + +from .mapper import ( + create_pick_list, + get_items_based_on_default_supplier, + make_in_transit_stock_entry, + make_purchase_order, + make_purchase_order_based_on_supplier, + make_request_for_quotation, + make_stock_entry, + make_supplier_quotation, + set_missing_values, ) form_grid_templates = {"items": "templates/form_grid/material_request_grid.html"} @@ -441,39 +448,6 @@ def update_completed_and_requested_qty(stock_entry, method): mr_obj.update_requested_qty(mr_item_rows) -def set_missing_values(source, target_doc): - if target_doc.doctype == "Purchase Order" and getdate(target_doc.schedule_date) < getdate(nowdate()): - target_doc.schedule_date = None - target_doc.run_method("set_missing_values") - target_doc.run_method("calculate_taxes_and_totals") - - -def update_item(obj, target, source_parent): - target.conversion_factor = obj.conversion_factor - - qty = obj.ordered_qty or obj.received_qty - target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor - target.stock_qty = target.qty * target.conversion_factor - if getdate(target.schedule_date) < getdate(nowdate()): - target.schedule_date = None - - if target.fg_item: - target.fg_item_qty = obj.stock_qty - if sc_bom := get_subcontracting_boms_for_finished_goods(target.fg_item): - target.item_code = sc_bom.service_item - target.uom = sc_bom.service_item_uom - target.conversion_factor = ( - frappe.db.get_value( - "UOM Conversion Detail", - {"parent": sc_bom.service_item, "uom": sc_bom.service_item_uom}, - "conversion_factor", - ) - or 1 - ) - target.qty = target.fg_item_qty * sc_bom.conversion_factor - target.stock_qty = target.qty * target.conversion_factor - - def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -498,156 +472,6 @@ def update_status(name: str, status: str): material_request.update_status(status) -@frappe.whitelist() -def make_purchase_order( - source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - is_subcontracted = ( - frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting" - ) - - def postprocess(source, target_doc): - target_doc.is_subcontracted = is_subcontracted - set_missing_values(source, target_doc) - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - - qty = d.ordered_qty or d.received_qty - - return qty < d.stock_qty and child_filter - - def generate_field_map(): - field_map = [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["sales_order", "sales_order"], - ["sales_order_item", "sales_order_item"], - ["wip_composite_asset", "wip_composite_asset"], - ] - - if is_subcontracted: - field_map.extend([["item_code", "fg_item"], ["qty", "fg_item_qty"]]) - else: - field_map.extend([["uom", "stock_uom"], ["uom", "uom"]]) - - return field_map - - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Purchase Order", - "validation": { - "docstatus": ["=", 1], - "material_request_type": ["in", ["Purchase", "Subcontracting"]], - }, - }, - "Material Request Item": { - "doctype": "Purchase Order Item", - "field_map": generate_field_map(), - "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [], - "postprocess": update_item, - "condition": select_item, - }, - }, - target_doc, - postprocess, - ) - - doclist.set_onload("load_after_mapping", False) - return doclist - - -@frappe.whitelist() -def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None): - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Request for Quotation", - "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, - }, - "Material Request Item": { - "doctype": "Request for Quotation Item", - "field_map": [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["project", "project_name"], - ], - }, - }, - target_doc, - ) - - return doclist - - -@frappe.whitelist() -def make_purchase_order_based_on_supplier( - source_name: str, target_doc: str | Document | None = None, args: dict | None = None -): - mr = source_name - - supplier_items = get_items_based_on_default_supplier(args.get("supplier")) - - def postprocess(source, target_doc): - target_doc.supplier = args.get("supplier") - if getdate(target_doc.schedule_date) < getdate(nowdate()): - target_doc.schedule_date = None - target_doc.set( - "items", - [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0], - ) - - set_missing_values(source, target_doc) - - target_doc = get_mapped_doc( - "Material Request", - mr, - { - "Material Request": { - "doctype": "Purchase Order", - }, - "Material Request Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["uom", "stock_uom"], - ["uom", "uom"], - ], - "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.qty, - }, - }, - target_doc, - postprocess, - ) - - return target_doc - - -@frappe.whitelist() -def get_items_based_on_default_supplier(supplier: str): - supplier_items = [ - d.parent - for d in frappe.db.get_all( - "Item Default", {"default_supplier": supplier, "parenttype": "Item"}, "parent" - ) - ] - - return supplier_items - - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_material_requests_based_on_supplier( @@ -694,128 +518,6 @@ def get_material_requests_based_on_supplier( return material_requests -@frappe.whitelist() -def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None): - def postprocess(source, target_doc): - set_missing_values(source, target_doc) - - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Supplier Quotation", - "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, - }, - "Material Request Item": { - "doctype": "Supplier Quotation Item", - "field_map": { - "name": "material_request_item", - "parent": "material_request", - "sales_order": "sales_order", - }, - }, - }, - target_doc, - postprocess, - ) - - doclist.set_onload("load_after_mapping", False) - return doclist - - -@frappe.whitelist() -def make_stock_entry(source_name: str, target_doc: str | Document | None = None): - def update_item(obj, target, source_parent): - qty = ( - flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor - if flt(obj.stock_qty) > flt(obj.ordered_qty) - else 0 - ) - target.qty = qty - target.transfer_qty = qty * obj.conversion_factor - target.conversion_factor = obj.conversion_factor - - if ( - source_parent.material_request_type == "Material Transfer" - or source_parent.material_request_type == "Customer Provided" - ): - target.t_warehouse = obj.warehouse - else: - target.s_warehouse = obj.warehouse - - if source_parent.material_request_type == "Customer Provided": - target.allow_zero_valuation_rate = 1 - - if source_parent.material_request_type == "Material Transfer": - target.s_warehouse = obj.from_warehouse - - def set_missing_values(source, target): - target.purpose = source.material_request_type - target.from_warehouse = source.set_from_warehouse - target.to_warehouse = source.set_warehouse - if source.material_request_type == "Material Issue": - target.from_warehouse = source.set_warehouse - target.to_warehouse = None - - if source.job_card: - target.purpose = "Material Transfer for Manufacture" - - if source.material_request_type == "Customer Provided": - target.purpose = "Material Receipt" - - target.set_transfer_qty() - target.set_actual_qty() - target.calculate_rate_and_amount(raise_error_if_no_rate=False) - target.stock_entry_type = target.purpose - - if source.job_card: - job_card_details = frappe.get_all( - "Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"] - ) - - if job_card_details and job_card_details[0]: - target.bom_no = job_card_details[0].bom_no - target.fg_completed_qty = job_card_details[0].for_quantity - target.from_bom = 1 - - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Stock Entry", - "validation": { - "docstatus": ["=", 1], - "material_request_type": [ - "in", - ["Material Transfer", "Material Issue", "Customer Provided"], - ], - }, - }, - "Material Request Item": { - "doctype": "Stock Entry Detail", - "field_map": { - "name": "material_request_item", - "parent": "material_request", - "uom": "stock_uom", - "job_card_item": "job_card_item", - }, - "field_no_map": ["expense_account"], - "postprocess": update_item, - "condition": lambda doc: ( - flt(doc.ordered_qty, doc.precision("ordered_qty")) - < flt(doc.stock_qty, doc.precision("ordered_qty")) - ), - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - @frappe.whitelist() def raise_work_orders(material_request: str, company: str): mr = frappe.get_doc("Material Request", material_request) @@ -885,54 +587,3 @@ def raise_work_orders(material_request: str, company: str): ) return work_orders - - -@frappe.whitelist() -def create_pick_list(source_name: str, target_doc: str | Document | None = None): - def update_item(obj, target, source_parent): - qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty")) - target.qty = qty - target.stock_qty = qty * obj.conversion_factor - target.conversion_factor = obj.conversion_factor - - doc = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Pick List", - "field_map": {"material_request_type": "purpose"}, - "validation": {"docstatus": ["=", 1]}, - }, - "Material Request Item": { - "doctype": "Pick List Item", - "field_map": { - "name": "material_request_item", - "stock_qty": "stock_qty", - "from_warehouse": "warehouse", - }, - "postprocess": update_item, - "condition": lambda doc: ( - flt(doc.picked_qty, doc.precision("picked_qty")) - < flt(doc.stock_qty, doc.precision("stock_qty")) - ), - }, - }, - target_doc, - ) - - doc.set_item_locations() - - return doc - - -@frappe.whitelist() -def make_in_transit_stock_entry(source_name: str, in_transit_warehouse: str): - ste_doc = make_stock_entry(source_name) - ste_doc.add_to_transit = 1 - ste_doc.to_warehouse = in_transit_warehouse - - for row in ste_doc.items: - row.t_warehouse = in_transit_warehouse - - return ste_doc From 92983255b3d6165dd019d1343f9e6e275b3f9086 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 12:45:32 +0530 Subject: [PATCH 45/65] refactor(pick_list): move mapping functions to mapper.py --- erpnext/stock/doctype/pick_list/mapper.py | 365 +++++++++++++++++++ erpnext/stock/doctype/pick_list/pick_list.py | 365 +------------------ 2 files changed, 373 insertions(+), 357 deletions(-) create mode 100644 erpnext/stock/doctype/pick_list/mapper.py diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py new file mode 100644 index 00000000000..bf22310271a --- /dev/null +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -0,0 +1,365 @@ +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json +from itertools import groupby + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import map_child_doc +from frappe.utils import flt, get_link_to_form + +from erpnext.selling.doctype.sales_order.sales_order import ( + make_delivery_note as create_delivery_note_from_sales_order, +) + + +def validate_item_locations(pick_list): + if not pick_list.locations: + frappe.throw(_("Add items in the Item Locations table")) + + +@frappe.whitelist() +def create_delivery_note(source_name: str, target_doc: str | Document | None = None): + pick_list = frappe.get_doc("Pick List", source_name) + validate_item_locations(pick_list) + sales_dict = dict() + sales_orders = [] + delivery_notes = [] + for location in pick_list.locations: + if location.sales_order: + sales_orders.append( + frappe.db.get_value( + "Sales Order", + location.sales_order, + [ + "customer", + "name as sales_order", + "company_address", + "dispatch_address_name", + "shipping_address_name", + "customer_address", + ], + as_dict=True, + ) + ) + + group_key = lambda so: ( # noqa + so["customer"], + so["company_address"] or "", + so["dispatch_address_name"] or "", + so["shipping_address_name"] or "", + so["customer_address"] or "", + ) + for key, rows in groupby(sorted(sales_orders, key=group_key), key=group_key): + sales_dict[key] = {row.sales_order for row in rows} + + if sales_dict: + delivery_notes.extend(create_dn_with_so(sales_dict, pick_list)) + + if not all(item.sales_order for item in pick_list.locations): + delivery_notes.append(create_dn_wo_so(pick_list)) + + if len(delivery_notes) == 1: + return delivery_notes[0] + else: + from frappe.utils import comma_and + + doc_list = [get_link_to_form("Delivery Note", p.name) for p in delivery_notes] + frappe.msgprint(_("{0} created").format(comma_and(doc_list))) + + +def create_dn_wo_so(pick_list, delivery_note=None): + if not delivery_note: + delivery_note = frappe.new_doc("Delivery Note") + + delivery_note.company = pick_list.company + + item_table_mapper_without_so = { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "name", + "parent": "", + }, + } + map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note) + delivery_note.flags.ignore_mandatory = True + delivery_note.save() + + return delivery_note + + +@frappe.whitelist() +def create_dn_for_pick_lists( + source_name: str, target_doc: str | Document | None = None, kwargs: dict | str | None = None +): + """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer""" + if kwargs is None: + kwargs = {} + if isinstance(kwargs, str): + kwargs = json.loads(kwargs) + + pick_list = frappe.get_doc("Pick List", source_name) + validate_item_locations(pick_list) + + sales_order_arg = kwargs.get("sales_order") + customer_arg = kwargs.get("customer") + + if sales_order_arg: + sales_orders = {sales_order_arg} + else: + sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order} + + if customer_arg: + sales_orders = frappe.get_all( + "Sales Order", + filters={"customer": customer_arg, "name": ["in", list(sales_orders)]}, + pluck="name", + ) + + delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc, kwargs=kwargs) + + if not sales_order_arg and not all(item.sales_order for item in pick_list.locations): + if isinstance(delivery_note, str): + delivery_note = frappe.get_doc(frappe.parse_json(delivery_note)) + + delivery_note = create_dn_wo_so(pick_list, delivery_note) + + return delivery_note + + +def create_dn_with_so(sales_dict, pick_list): + """Create Delivery Note for each customer (based on SO) in a Pick List.""" + delivery_notes = [] + + for key in sales_dict: + delivery_note = create_dn_from_so(pick_list, sales_dict[key], None) + if delivery_note: + delivery_note.flags.ignore_mandatory = True + # updates packed_items on save + # save as multiple customers are possible + delivery_note.save() + delivery_notes.append(delivery_note) + + return delivery_notes + + +def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None): + if not sales_order_list: + return delivery_note + + def select_item(d): + filtered_items = kwargs.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + item_table_mapper = { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) + and doc.delivered_by_supplier != 1 + and select_item(doc), + } + + kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} + + delivery_note = create_delivery_note_from_sales_order( + next(iter(sales_order_list)), delivery_note, kwargs=kwargs + ) + + if not delivery_note: + return + + for so in sales_order_list: + map_pl_locations(pick_list, item_table_mapper, delivery_note, so) + + return delivery_note + + +def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): + for location in pick_list.locations: + if location.sales_order != sales_order or location.product_bundle_item: + continue + + if location.sales_order_item: + sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) + else: + sales_order_item = None + + source_doc = sales_order_item or location + + dn_item = map_child_doc(source_doc, delivery_note, item_mapper) + + if dn_item: + dn_item.against_pick_list = pick_list.name + dn_item.pick_list_item = location.name + dn_item.warehouse = location.warehouse + dn_item.qty = flt(location.picked_qty - location.delivered_qty) / ( + flt(dn_item.conversion_factor) or 1 + ) + dn_item.batch_no = location.batch_no + dn_item.serial_no = location.serial_no + dn_item.use_serial_batch_fields = location.use_serial_batch_fields + + update_delivery_note_item(source_doc, dn_item, delivery_note) + + add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order) + set_delivery_note_missing_values(delivery_note) + + delivery_note.company = pick_list.company + if sales_order: + delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") + + +def add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order=None) -> None: + """Add product bundles found in pick list to delivery note. + + When mapping pick list items, the bundle item itself isn't part of the + locations. Dynamically fetch and add parent bundle item into DN.""" + product_bundles = pick_list._get_product_bundles() + product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) + + for so_row, value in product_bundles.items(): + sales_order_item = frappe.get_doc("Sales Order Item", so_row) + if sales_order and sales_order_item.parent != sales_order: + continue + + dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) + dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( + so_row, product_bundle_qty_map[value.item_code] + ) + dn_bundle_item.pick_list_item = value.pick_list_item + dn_bundle_item.against_pick_list = pick_list.name + update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) + + +@frappe.whitelist() +def create_stock_entry(pick_list: str): + pick_list = frappe.get_doc(json.loads(pick_list)) + validate_item_locations(pick_list) + + if stock_entry_exists(pick_list.get("name")): + return frappe.msgprint(_("Stock Entry has been already created against this Pick List")) + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.pick_list = pick_list.get("name") + stock_entry.purpose = pick_list.get("purpose") + stock_entry.company = pick_list.get("company") + stock_entry.set_stock_entry_type() + + if pick_list.get("work_order"): + stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry) + elif pick_list.get("material_request"): + stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry) + else: + stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) + + stock_entry.set_missing_values() + + return stock_entry.as_dict() + + +def update_delivery_note_item(source, target, delivery_note): + cost_center = frappe.db.get_value("Project", delivery_note.project, "cost_center") + if not cost_center: + cost_center = get_cost_center(source.item_code, "Item", delivery_note.company) + + if not cost_center: + cost_center = get_cost_center(source.item_group, "Item Group", delivery_note.company) + + target.cost_center = cost_center + + +def get_cost_center(for_item, from_doctype, company): + """Returns Cost Center for Item or Item Group""" + return frappe.db.get_value( + "Item Default", + fieldname=["buying_cost_center"], + filters={"parent": for_item, "parenttype": from_doctype, "company": company}, + ) + + +def set_delivery_note_missing_values(target): + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + + +def stock_entry_exists(pick_list_name): + return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name}) + + +def update_stock_entry_based_on_work_order(pick_list, stock_entry): + work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) + + stock_entry.work_order = work_order.name + stock_entry.company = work_order.company + stock_entry.from_bom = 1 + stock_entry.bom_no = work_order.bom_no + stock_entry.use_multi_level_bom = work_order.use_multi_level_bom + stock_entry.fg_completed_qty = pick_list.for_qty + if work_order.bom_no: + stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required") + + is_wip_warehouse_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group") + if not (is_wip_warehouse_group and work_order.skip_transfer): + wip_warehouse = work_order.wip_warehouse + else: + wip_warehouse = None + stock_entry.to_warehouse = wip_warehouse + + stock_entry.project = work_order.project + + for location in pick_list.locations: + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = wip_warehouse + + stock_entry.append("items", item) + + return stock_entry + + +def update_stock_entry_based_on_material_request(pick_list, stock_entry): + for location in pick_list.locations: + target_warehouse = None + if location.material_request_item: + target_warehouse = frappe.get_value( + "Material Request Item", location.material_request_item, "warehouse" + ) + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = target_warehouse + stock_entry.append("items", item) + + return stock_entry + + +def update_stock_entry_items_with_no_reference(pick_list, stock_entry): + for location in pick_list.locations: + item = frappe._dict() + update_common_item_properties(item, location) + + stock_entry.append("items", item) + + return stock_entry + + +def update_common_item_properties(item, location): + item.item_code = location.item_code + item.s_warehouse = location.warehouse + item.transfer_qty = location.picked_qty + item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty")) + item.uom = location.uom + item.conversion_factor = location.conversion_factor + item.stock_uom = location.stock_uom + item.material_request = location.material_request + item.serial_no = location.serial_no + item.batch_no = location.batch_no + item.material_request_item = location.material_request_item diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 7f6a6421c69..910e0211867 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -3,22 +3,17 @@ import json from collections import OrderedDict, defaultdict -from itertools import groupby from typing import Any import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.model.mapper import map_child_doc from frappe.query_builder import Case from frappe.query_builder.custom import GROUP_CONCAT from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum from frappe.utils import cint, floor, flt, get_link_to_form from frappe.utils.nestedset import get_descendants_of -from erpnext.selling.doctype.sales_order.sales_order import ( - make_delivery_note as create_delivery_note_from_sales_order, -) from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_auto_batch_nos, ) @@ -30,6 +25,14 @@ from erpnext.stock.serial_batch_bundle import ( ) from erpnext.utilities.transaction_base import TransactionBase +from .mapper import ( + create_delivery_note, + create_dn_for_pick_lists, + create_stock_entry, + stock_entry_exists, + validate_item_locations, +) + class MissingWarehouseValidationError(frappe.ValidationError): pass @@ -931,11 +934,6 @@ def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]: return query.run(as_dict=True) -def validate_item_locations(pick_list): - if not pick_list.locations: - frappe.throw(_("Add items in the Item Locations table")) - - def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus): available_locations = item_location_map.get(item_doc.item_code) locations = [] @@ -1284,253 +1282,6 @@ def get_available_item_locations_for_other_item( return item_locations -@frappe.whitelist() -def create_delivery_note(source_name: str, target_doc: str | Document | None = None): - pick_list = frappe.get_doc("Pick List", source_name) - validate_item_locations(pick_list) - sales_dict = dict() - sales_orders = [] - delivery_notes = [] - for location in pick_list.locations: - if location.sales_order: - sales_orders.append( - frappe.db.get_value( - "Sales Order", - location.sales_order, - [ - "customer", - "name as sales_order", - "company_address", - "dispatch_address_name", - "shipping_address_name", - "customer_address", - ], - as_dict=True, - ) - ) - - group_key = lambda so: ( # noqa - so["customer"], - so["company_address"] or "", - so["dispatch_address_name"] or "", - so["shipping_address_name"] or "", - so["customer_address"] or "", - ) - for key, rows in groupby(sorted(sales_orders, key=group_key), key=group_key): - sales_dict[key] = {row.sales_order for row in rows} - - if sales_dict: - delivery_notes.extend(create_dn_with_so(sales_dict, pick_list)) - - if not all(item.sales_order for item in pick_list.locations): - delivery_notes.append(create_dn_wo_so(pick_list)) - - if len(delivery_notes) == 1: - return delivery_notes[0] - else: - from frappe.utils import comma_and - - doc_list = [get_link_to_form("Delivery Note", p.name) for p in delivery_notes] - frappe.msgprint(_("{0} created").format(comma_and(doc_list))) - - -def create_dn_wo_so(pick_list, delivery_note=None): - if not delivery_note: - delivery_note = frappe.new_doc("Delivery Note") - - delivery_note.company = pick_list.company - - item_table_mapper_without_so = { - "doctype": "Delivery Note Item", - "field_map": { - "rate": "rate", - "name": "name", - "parent": "", - }, - } - map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note) - delivery_note.flags.ignore_mandatory = True - delivery_note.save() - - return delivery_note - - -@frappe.whitelist() -def create_dn_for_pick_lists( - source_name: str, target_doc: str | Document | None = None, kwargs: dict | str | None = None -): - """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer""" - if kwargs is None: - kwargs = {} - if isinstance(kwargs, str): - kwargs = json.loads(kwargs) - - pick_list = frappe.get_doc("Pick List", source_name) - validate_item_locations(pick_list) - - sales_order_arg = kwargs.get("sales_order") - customer_arg = kwargs.get("customer") - - if sales_order_arg: - sales_orders = {sales_order_arg} - else: - sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order} - - if customer_arg: - sales_orders = frappe.get_all( - "Sales Order", - filters={"customer": customer_arg, "name": ["in", list(sales_orders)]}, - pluck="name", - ) - - delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc, kwargs=kwargs) - - if not sales_order_arg and not all(item.sales_order for item in pick_list.locations): - if isinstance(delivery_note, str): - delivery_note = frappe.get_doc(frappe.parse_json(delivery_note)) - - delivery_note = create_dn_wo_so(pick_list, delivery_note) - - return delivery_note - - -def create_dn_with_so(sales_dict, pick_list): - """Create Delivery Note for each customer (based on SO) in a Pick List.""" - delivery_notes = [] - - for key in sales_dict: - delivery_note = create_dn_from_so(pick_list, sales_dict[key], None) - if delivery_note: - delivery_note.flags.ignore_mandatory = True - # updates packed_items on save - # save as multiple customers are possible - delivery_note.save() - delivery_notes.append(delivery_note) - - return delivery_notes - - -def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None): - if not sales_order_list: - return delivery_note - - def select_item(d): - filtered_items = kwargs.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - item_table_mapper = { - "doctype": "Delivery Note Item", - "field_map": { - "rate": "rate", - "name": "so_detail", - "parent": "against_sales_order", - }, - "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) - and doc.delivered_by_supplier != 1 - and select_item(doc), - } - - kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} - - delivery_note = create_delivery_note_from_sales_order( - next(iter(sales_order_list)), delivery_note, kwargs=kwargs - ) - - if not delivery_note: - return - - for so in sales_order_list: - map_pl_locations(pick_list, item_table_mapper, delivery_note, so) - - return delivery_note - - -def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): - for location in pick_list.locations: - if location.sales_order != sales_order or location.product_bundle_item: - continue - - if location.sales_order_item: - sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) - else: - sales_order_item = None - - source_doc = sales_order_item or location - - dn_item = map_child_doc(source_doc, delivery_note, item_mapper) - - if dn_item: - dn_item.against_pick_list = pick_list.name - dn_item.pick_list_item = location.name - dn_item.warehouse = location.warehouse - dn_item.qty = flt(location.picked_qty - location.delivered_qty) / ( - flt(dn_item.conversion_factor) or 1 - ) - dn_item.batch_no = location.batch_no - dn_item.serial_no = location.serial_no - dn_item.use_serial_batch_fields = location.use_serial_batch_fields - - update_delivery_note_item(source_doc, dn_item, delivery_note) - - add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order) - set_delivery_note_missing_values(delivery_note) - - delivery_note.company = pick_list.company - if sales_order: - delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") - - -def add_product_bundles_to_delivery_note( - pick_list: "PickList", delivery_note, item_mapper, sales_order=None -) -> None: - """Add product bundles found in pick list to delivery note. - - When mapping pick list items, the bundle item itself isn't part of the - locations. Dynamically fetch and add parent bundle item into DN.""" - product_bundles = pick_list._get_product_bundles() - product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) - - for so_row, value in product_bundles.items(): - sales_order_item = frappe.get_doc("Sales Order Item", so_row) - if sales_order and sales_order_item.parent != sales_order: - continue - - dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) - dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( - so_row, product_bundle_qty_map[value.item_code] - ) - dn_bundle_item.pick_list_item = value.pick_list_item - dn_bundle_item.against_pick_list = pick_list.name - update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) - - -@frappe.whitelist() -def create_stock_entry(pick_list: str): - pick_list = frappe.get_doc(json.loads(pick_list)) - validate_item_locations(pick_list) - - if stock_entry_exists(pick_list.get("name")): - return frappe.msgprint(_("Stock Entry has been already created against this Pick List")) - - stock_entry = frappe.new_doc("Stock Entry") - stock_entry.pick_list = pick_list.get("name") - stock_entry.purpose = pick_list.get("purpose") - stock_entry.company = pick_list.get("company") - stock_entry.set_stock_entry_type() - - if pick_list.get("work_order"): - stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry) - elif pick_list.get("material_request"): - stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry) - else: - stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) - - stock_entry.set_missing_values() - - return stock_entry.as_dict() - - @frappe.whitelist() def get_pending_work_orders( doctype: Any, @@ -1585,106 +1336,6 @@ def get_actual_qty(item_code, warehouse): ) -def update_delivery_note_item(source, target, delivery_note): - cost_center = frappe.db.get_value("Project", delivery_note.project, "cost_center") - if not cost_center: - cost_center = get_cost_center(source.item_code, "Item", delivery_note.company) - - if not cost_center: - cost_center = get_cost_center(source.item_group, "Item Group", delivery_note.company) - - target.cost_center = cost_center - - -def get_cost_center(for_item, from_doctype, company): - """Returns Cost Center for Item or Item Group""" - return frappe.db.get_value( - "Item Default", - fieldname=["buying_cost_center"], - filters={"parent": for_item, "parenttype": from_doctype, "company": company}, - ) - - -def set_delivery_note_missing_values(target): - target.run_method("set_missing_values") - target.run_method("set_po_nos") - target.run_method("calculate_taxes_and_totals") - - -def stock_entry_exists(pick_list_name): - return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name}) - - -def update_stock_entry_based_on_work_order(pick_list, stock_entry): - work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) - - stock_entry.work_order = work_order.name - stock_entry.company = work_order.company - stock_entry.from_bom = 1 - stock_entry.bom_no = work_order.bom_no - stock_entry.use_multi_level_bom = work_order.use_multi_level_bom - stock_entry.fg_completed_qty = pick_list.for_qty - if work_order.bom_no: - stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required") - - is_wip_warehouse_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group") - if not (is_wip_warehouse_group and work_order.skip_transfer): - wip_warehouse = work_order.wip_warehouse - else: - wip_warehouse = None - stock_entry.to_warehouse = wip_warehouse - - stock_entry.project = work_order.project - - for location in pick_list.locations: - item = frappe._dict() - update_common_item_properties(item, location) - item.t_warehouse = wip_warehouse - - stock_entry.append("items", item) - - return stock_entry - - -def update_stock_entry_based_on_material_request(pick_list, stock_entry): - for location in pick_list.locations: - target_warehouse = None - if location.material_request_item: - target_warehouse = frappe.get_value( - "Material Request Item", location.material_request_item, "warehouse" - ) - item = frappe._dict() - update_common_item_properties(item, location) - item.t_warehouse = target_warehouse - stock_entry.append("items", item) - - return stock_entry - - -def update_stock_entry_items_with_no_reference(pick_list, stock_entry): - for location in pick_list.locations: - item = frappe._dict() - update_common_item_properties(item, location) - - stock_entry.append("items", item) - - return stock_entry - - -def update_common_item_properties(item, location): - item.item_code = location.item_code - item.s_warehouse = location.warehouse - item.transfer_qty = location.picked_qty - item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty")) - item.uom = location.uom - item.conversion_factor = location.conversion_factor - item.stock_uom = location.stock_uom - item.material_request = location.material_request - item.serial_no = location.serial_no - item.batch_no = location.batch_no - item.material_request_item = location.material_request_item - - def get_rejected_warehouses(): if not hasattr(frappe.local, "rejected_warehouses"): frappe.local.rejected_warehouses = [] From 7b456c6405d8a7b55dc76abce8944b56690087bd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:01:38 +0530 Subject: [PATCH 46/65] refactor(sales_invoice): move mapping functions to mapper.py --- .../accounts/doctype/sales_invoice/mapper.py | 615 +++++++++++++++++ .../doctype/sales_invoice/sales_invoice.py | 625 +----------------- 2 files changed, 631 insertions(+), 609 deletions(-) create mode 100644 erpnext/accounts/doctype/sales_invoice/mapper.py diff --git a/erpnext/accounts/doctype/sales_invoice/mapper.py b/erpnext/accounts/doctype/sales_invoice/mapper.py new file mode 100644 index 00000000000..cebae93fd1a --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/mapper.py @@ -0,0 +1,615 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.contacts.doctype.address.address import get_address_display +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.model.utils import get_fetch_values +from frappe.utils import flt, get_link_to_form, getdate + +from erpnext.accounts.party import get_party_details + + +@frappe.whitelist() +def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None): + doclist = get_mapped_doc( + "Sales Invoice", + source_name, + { + "Sales Invoice": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}}, + "Sales Invoice Item": { + "doctype": "Maintenance Schedule Item", + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_delivery_note(source_name: str, target_doc: Document | None = None): + def set_missing_values(source, target): + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + + def update_item(source_doc, target_doc, source_parent): + target_doc.qty = flt(source_doc.qty) - flt(source_doc.delivered_qty) + target_doc.stock_qty = target_doc.qty * flt(source_doc.conversion_factor) + + target_doc.base_amount = target_doc.qty * flt(source_doc.base_rate) + target_doc.amount = target_doc.qty * flt(source_doc.rate) + + doclist = get_mapped_doc( + "Sales Invoice", + source_name, + { + "Sales Invoice": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, + "Sales Invoice Item": { + "doctype": "Delivery Note Item", + "field_map": { + "name": "si_detail", + "parent": "against_sales_invoice", + "serial_no": "serial_no", + "sales_order": "against_sales_order", + "so_detail": "so_detail", + "cost_center": "cost_center", + }, + "postprocess": update_item, + "condition": lambda doc: doc.delivered_by_supplier != 1 + and not doc.scio_detail + and not doc.dn_detail + and doc.qty - doc.delivered_qty > 0, + }, + "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, + "Sales Team": { + "doctype": "Sales Team", + "field_map": {"incentives": "incentives"}, + "add_if_empty": True, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_sales_return(source_name: str, target_doc: Document | None = None): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Sales Invoice", source_name, target_doc) + + +def get_inter_company_details(doc, doctype): + if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]: + parties = frappe.db.get_all( + "Supplier", + fields=["name"], + filters={"disabled": 0, "is_internal_supplier": 1, "represents_company": doc.company}, + ) + company = frappe.get_cached_value("Customer", doc.customer, "represents_company") + + if not parties: + frappe.throw( + _("No Supplier found for Inter Company Transactions which represents company {0}").format( + frappe.bold(doc.company) + ) + ) + + party = get_internal_party(parties, "Supplier", doc) + else: + parties = frappe.db.get_all( + "Customer", + fields=["name"], + filters={"disabled": 0, "is_internal_customer": 1, "represents_company": doc.company}, + ) + company = frappe.get_cached_value("Supplier", doc.supplier, "represents_company") + + if not parties: + frappe.throw( + _("No Customer found for Inter Company Transactions which represents company {0}").format( + frappe.bold(doc.company) + ) + ) + + party = get_internal_party(parties, "Customer", doc) + + return {"party": party, "company": company} + + +def get_internal_party(parties, link_doctype, doc): + if len(parties) == 1: + party = parties[0].name + else: + # If more than one Internal Supplier/Customer, get supplier/customer on basis of address + if doc.get("company_address") or doc.get("shipping_address"): + party = frappe.db.get_value( + "Dynamic Link", + { + "parent": doc.get("company_address") or doc.get("shipping_address"), + "parenttype": "Address", + "link_doctype": link_doctype, + }, + "link_name", + ) + + if not party: + party = parties[0].name + else: + party = parties[0].name + + return party + + +def validate_inter_company_transaction(doc, doctype): + details = get_inter_company_details(doc, doctype) + price_list = ( + doc.selling_price_list + if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"] + else doc.buying_price_list + ) + valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1}) + if not valid_price_list and not doc.is_internal_transfer(): + frappe.throw(_("Selected Price List should have buying and selling fields checked.")) + + party = details.get("party") + if not party: + partytype = "Supplier" if doctype in ["Sales Invoice", "Sales Order"] else "Customer" + frappe.throw(_("No {0} found for Inter Company Transactions.").format(partytype)) + + company = details.get("company") + default_currency = frappe.get_cached_value("Company", company, "default_currency") + if default_currency != doc.currency: + frappe.throw( + _("Company currencies of both the companies should match for Inter Company Transactions.") + ) + + return + + +@frappe.whitelist() +def make_inter_company_purchase_invoice(source_name: str, target_doc: Document | None = None): + return make_inter_company_transaction("Sales Invoice", source_name, target_doc) + + +def make_inter_company_transaction(doctype, source_name, target_doc=None): + if doctype in ["Sales Invoice", "Sales Order"]: + source_doc = frappe.get_doc(doctype, source_name) + target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order" + target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item" + source_document_warehouse_field = "target_warehouse" + target_document_warehouse_field = "from_warehouse" + received_items = get_received_items(source_name, target_doctype, target_detail_field) + else: + source_doc = frappe.get_doc(doctype, source_name) + target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order" + source_document_warehouse_field = "from_warehouse" + target_document_warehouse_field = "target_warehouse" + received_items = {} + + validate_inter_company_transaction(source_doc, doctype) + details = get_inter_company_details(source_doc, doctype) + + def set_missing_values(source, target): + target.run_method("set_missing_values") + set_purchase_references(target) + + def update_details(source_doc, target_doc, source_parent): + def _validate_address_link(address, link_doctype, link_name): + return frappe.db.get_value( + "Dynamic Link", + { + "parent": address, + "parenttype": "Address", + "link_doctype": link_doctype, + "link_name": link_name, + }, + "parent", + ) + + target_doc.inter_company_invoice_reference = source_doc.name + if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]: + currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency") + target_doc.company = details.get("company") + target_doc.supplier = details.get("party") + target_doc.is_internal_supplier = 1 + target_doc.ignore_pricing_rule = 1 + target_doc.buying_price_list = source_doc.selling_price_list + + # Invert Addresses + if source_doc.company_address and _validate_address_link( + source_doc.company_address, "Supplier", details.get("party") + ): + update_address(target_doc, "supplier_address", "address_display", source_doc.company_address) + if source_doc.dispatch_address_name and _validate_address_link( + source_doc.dispatch_address_name, "Company", details.get("company") + ): + update_address( + target_doc, + "dispatch_address", + "dispatch_address_display", + source_doc.dispatch_address_name, + ) + if source_doc.shipping_address_name and _validate_address_link( + source_doc.shipping_address_name, "Company", details.get("company") + ): + update_address( + target_doc, + "shipping_address", + "shipping_address_display", + source_doc.shipping_address_name, + ) + if source_doc.customer_address and _validate_address_link( + source_doc.customer_address, "Company", details.get("company") + ): + update_address( + target_doc, "billing_address", "billing_address_display", source_doc.customer_address + ) + + if currency: + target_doc.currency = currency + + update_taxes( + target_doc, + party=target_doc.supplier, + party_type="Supplier", + company=target_doc.company, + doctype=target_doc.doctype, + party_address=target_doc.supplier_address, + company_address=target_doc.shipping_address, + ) + + else: + currency = frappe.db.get_value("Customer", details.get("party"), "default_currency") + target_doc.company = details.get("company") + target_doc.customer = details.get("party") + target_doc.selling_price_list = source_doc.buying_price_list + + if source_doc.supplier_address and _validate_address_link( + source_doc.supplier_address, "Company", details.get("company") + ): + update_address( + target_doc, "company_address", "company_address_display", source_doc.supplier_address + ) + if source_doc.shipping_address and _validate_address_link( + source_doc.shipping_address, "Customer", details.get("party") + ): + update_address( + target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address + ) + if source_doc.shipping_address and _validate_address_link( + source_doc.shipping_address, "Customer", details.get("party") + ): + update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address) + + if currency: + target_doc.currency = currency + + update_taxes( + target_doc, + party=target_doc.customer, + party_type="Customer", + company=target_doc.company, + doctype=target_doc.doctype, + party_address=target_doc.customer_address, + company_address=target_doc.company_address, + shipping_address_name=target_doc.shipping_address_name, + ) + + def update_item(source, target, source_parent): + target.qty = flt(source.qty) - received_items.get(source.name, 0.0) + if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item": + target.purchase_order = source.parent + target.purchase_order_item = source.name + target.material_request = source.material_request + target.material_request_item = source.material_request_item + + if ( + source.get("purchase_order") + and source.get("purchase_order_item") + and target.doctype == "Purchase Invoice Item" + ): + target.purchase_order = source.purchase_order + target.po_detail = source.purchase_order_item + + if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"): + target.use_serial_batch_fields = 1 + + item_field_map = { + "doctype": target_doctype + " Item", + "field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"], + "field_map": { + "rate": "rate", + }, + "postprocess": update_item, + "condition": lambda doc: doc.qty > 0, + } + + if doctype in ["Sales Invoice", "Sales Order"]: + item_field_map["field_map"].update( + { + "name": target_detail_field, + } + ) + + if source_doc.get("update_stock"): + item_field_map["field_map"].update( + { + source_document_warehouse_field: target_document_warehouse_field, + "batch_no": "batch_no", + "serial_no": "serial_no", + } + ) + elif target_doctype == "Sales Order": + item_field_map["field_map"].update( + { + source_document_warehouse_field: "warehouse", + } + ) + + doclist = get_mapped_doc( + doctype, + source_name, + { + doctype: { + "doctype": target_doctype, + "postprocess": update_details, + "set_target_warehouse": "set_from_warehouse", + "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], + }, + doctype + " Item": item_field_map, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +def get_received_items(reference_name, doctype, reference_fieldname): + reference_field = "inter_company_invoice_reference" + if doctype == "Purchase Order": + reference_field = "inter_company_order_reference" + + filters = { + reference_field: reference_name, + "docstatus": 1, + } + + target_doctypes = frappe.get_all( + doctype, + filters=filters, + as_list=True, + ) + + if target_doctypes: + target_doctypes = list(target_doctypes[0]) + + received_items_map = frappe._dict( + frappe.get_all( + doctype + " Item", + filters={"parent": ("in", target_doctypes)}, + fields=[reference_fieldname, "qty"], + as_list=1, + ) + ) + + return received_items_map + + +def set_purchase_references(doc): + # add internal PO or PR links if any + + if doc.is_internal_transfer(): + if doc.doctype == "Purchase Receipt": + so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference) + + if so_item_map: + pd_item_map, parent_child_map, warehouse_map = get_pd_details( + "Purchase Order Item", so_item_map, "sales_order_item" + ) + + update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map) + + elif doc.doctype == "Purchase Invoice": + dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference) + # First check for Purchase receipt + if list(dn_item_map.values()): + pd_item_map, parent_child_map, warehouse_map = get_pd_details( + "Purchase Receipt Item", dn_item_map, "delivery_note_item" + ) + + update_pi_items( + doc, + "pr_detail", + "purchase_receipt", + dn_item_map, + pd_item_map, + parent_child_map, + warehouse_map, + ) + + +def update_pi_items( + doc, + detail_field, + parent_field, + sales_item_map, + purchase_item_map, + parent_child_map, + warehouse_map, +): + for item in doc.get("items"): + item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item))) + item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item))) + if doc.update_stock: + item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item)) + if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"): + item.warehouse = frappe.db.get_value( + "Purchase Order Item", item.purchase_order_item, "warehouse" + ) + + +def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map): + for item in doc.get("items"): + item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item)) + if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"): + item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse") + + +def get_delivery_note_details(internal_reference): + si_item_details = frappe.get_all( + "Delivery Note Item", fields=["name", "so_detail"], filters={"parent": internal_reference} + ) + + return {d.name: d.so_detail for d in si_item_details if d.so_detail} + + +def get_sales_invoice_details(internal_reference): + dn_item_map = {} + so_item_map = {} + + si_item_details = frappe.get_all( + "Sales Invoice Item", + fields=["name", "so_detail", "dn_detail"], + filters={"parent": internal_reference}, + ) + + for d in si_item_details: + if d.dn_detail: + dn_item_map.setdefault(d.name, d.dn_detail) + if d.so_detail: + so_item_map.setdefault(d.name, d.so_detail) + + return dn_item_map, so_item_map + + +def get_pd_details(doctype, sd_detail_map, sd_detail_field): + pd_item_map = {} + accepted_warehouse_map = {} + parent_child_map = {} + + pd_item_details = frappe.get_all( + doctype, + fields=[sd_detail_field, "name", "warehouse", "parent"], + filters={sd_detail_field: ("in", list(sd_detail_map.values()))}, + ) + + for d in pd_item_details: + pd_item_map.setdefault(d.get(sd_detail_field), d.name) + parent_child_map.setdefault(d.get(sd_detail_field), d.parent) + accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse) + + return pd_item_map, parent_child_map, accepted_warehouse_map + + +def update_taxes( + doc, + party=None, + party_type=None, + company=None, + doctype=None, + party_address=None, + company_address=None, + shipping_address_name=None, + master_doctype=None, +): + # Update Party Details + party_details = get_party_details( + party=party, + party_type=party_type, + company=company, + doctype=doctype, + party_address=party_address, + company_address=company_address, + shipping_address=shipping_address_name, + ) + + # Update taxes and charges if any + doc.taxes_and_charges = party_details.get("taxes_and_charges") + doc.set("taxes", party_details.get("taxes")) + + +def update_address(doc, address_field, address_display_field, address_name): + doc.set(address_field, address_name) + fetch_values = get_fetch_values(doc.doctype, address_field, address_name) + + for key, value in fetch_values.items(): + doc.set(key, value) + + doc.set(address_display_field, get_address_display(doc.get(address_field))) + + +@frappe.whitelist() +def create_invoice_discounting(source_name: str, target_doc: str | Document | None = None): + invoice = frappe.get_doc("Sales Invoice", source_name) + invoice_discounting = frappe.new_doc("Invoice Discounting") + invoice_discounting.company = invoice.company + invoice_discounting.append( + "invoices", + { + "sales_invoice": source_name, + "customer": invoice.customer, + "posting_date": invoice.posting_date, + "outstanding_amount": invoice.outstanding_amount, + }, + ) + + return invoice_discounting + + +@frappe.whitelist() +def create_dunning( + source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False +): + def postprocess_dunning(source, target): + from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text + + dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company}) + if dunning_type: + dunning_type = frappe.get_doc("Dunning Type", dunning_type) + target.dunning_type = dunning_type.name + target.rate_of_interest = dunning_type.rate_of_interest + target.dunning_fee = dunning_type.dunning_fee + target.income_account = dunning_type.income_account + target.cost_center = dunning_type.cost_center + letter_text = get_dunning_letter_text( + dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language + ) + + if letter_text: + target.body_text = letter_text.get("body_text") + target.closing_text = letter_text.get("closing_text") + target.language = letter_text.get("language") + + # update outstanding from doc + if source.payment_schedule and len(source.payment_schedule) == 1: + for row in target.overdue_payments: + if row.payment_schedule == source.payment_schedule[0].name: + row.outstanding = source.get("outstanding_amount") + + target.validate() + + return get_mapped_doc( + from_doctype="Sales Invoice", + from_docname=source_name, + target_doc=target_doc, + table_maps={ + "Sales Invoice": { + "doctype": "Dunning", + "field_map": {"customer_address": "customer_address", "parent": "sales_invoice"}, + }, + "Payment Schedule": { + "doctype": "Overdue Payment", + "field_map": {"name": "payment_schedule", "parent": "sales_invoice"}, + "condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(), + }, + }, + postprocess=postprocess_dunning, + ignore_permissions=ignore_permissions, + ) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 2950a182e1d..89851d8c16e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -5,10 +5,7 @@ import frappe import frappe.utils from frappe import _, msgprint, throw -from frappe.contacts.doctype.address.address import get_address_display from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.model.utils import get_fetch_values from frappe.query_builder import Case from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate from frappe.utils.data import comma_and @@ -29,7 +26,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger ) from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center -from erpnext.accounts.party import get_due_date, get_party_account, get_party_details +from erpnext.accounts.party import get_due_date, get_party_account from erpnext.accounts.utils import ( get_account_currency, update_voucher_outstanding, @@ -49,6 +46,21 @@ from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timeshe from erpnext.setup.doctype.company.company import update_company_current_month_sales from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so +from .mapper import ( + create_dunning, + create_invoice_discounting, + get_inter_company_details, + make_delivery_note, + make_inter_company_purchase_invoice, + make_inter_company_transaction, + make_maintenance_schedule, + make_sales_return, + set_purchase_references, + update_address, + update_taxes, + validate_inter_company_transaction, +) + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -1995,544 +2007,11 @@ def get_bank_cash_account(mode_of_payment: str, company: str): return {"account": account} -@frappe.whitelist() -def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None): - doclist = get_mapped_doc( - "Sales Invoice", - source_name, - { - "Sales Invoice": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}}, - "Sales Invoice Item": { - "doctype": "Maintenance Schedule Item", - }, - }, - target_doc, - ) - - return doclist - - -@frappe.whitelist() -def make_delivery_note(source_name: str, target_doc: Document | None = None): - def set_missing_values(source, target): - target.run_method("set_missing_values") - target.run_method("set_po_nos") - target.run_method("calculate_taxes_and_totals") - - def update_item(source_doc, target_doc, source_parent): - target_doc.qty = flt(source_doc.qty) - flt(source_doc.delivered_qty) - target_doc.stock_qty = target_doc.qty * flt(source_doc.conversion_factor) - - target_doc.base_amount = target_doc.qty * flt(source_doc.base_rate) - target_doc.amount = target_doc.qty * flt(source_doc.rate) - - doclist = get_mapped_doc( - "Sales Invoice", - source_name, - { - "Sales Invoice": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, - "Sales Invoice Item": { - "doctype": "Delivery Note Item", - "field_map": { - "name": "si_detail", - "parent": "against_sales_invoice", - "serial_no": "serial_no", - "sales_order": "against_sales_order", - "so_detail": "so_detail", - "cost_center": "cost_center", - }, - "postprocess": update_item, - "condition": lambda doc: doc.delivered_by_supplier != 1 - and not doc.scio_detail - and not doc.dn_detail - and doc.qty - doc.delivered_qty > 0, - }, - "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, - "Sales Team": { - "doctype": "Sales Team", - "field_map": {"incentives": "incentives"}, - "add_if_empty": True, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_sales_return(source_name: str, target_doc: Document | None = None): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Sales Invoice", source_name, target_doc) - - -def get_inter_company_details(doc, doctype): - if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]: - parties = frappe.db.get_all( - "Supplier", - fields=["name"], - filters={"disabled": 0, "is_internal_supplier": 1, "represents_company": doc.company}, - ) - company = frappe.get_cached_value("Customer", doc.customer, "represents_company") - - if not parties: - frappe.throw( - _("No Supplier found for Inter Company Transactions which represents company {0}").format( - frappe.bold(doc.company) - ) - ) - - party = get_internal_party(parties, "Supplier", doc) - else: - parties = frappe.db.get_all( - "Customer", - fields=["name"], - filters={"disabled": 0, "is_internal_customer": 1, "represents_company": doc.company}, - ) - company = frappe.get_cached_value("Supplier", doc.supplier, "represents_company") - - if not parties: - frappe.throw( - _("No Customer found for Inter Company Transactions which represents company {0}").format( - frappe.bold(doc.company) - ) - ) - - party = get_internal_party(parties, "Customer", doc) - - return {"party": party, "company": company} - - -def get_internal_party(parties, link_doctype, doc): - if len(parties) == 1: - party = parties[0].name - else: - # If more than one Internal Supplier/Customer, get supplier/customer on basis of address - if doc.get("company_address") or doc.get("shipping_address"): - party = frappe.db.get_value( - "Dynamic Link", - { - "parent": doc.get("company_address") or doc.get("shipping_address"), - "parenttype": "Address", - "link_doctype": link_doctype, - }, - "link_name", - ) - - if not party: - party = parties[0].name - else: - party = parties[0].name - - return party - - -def validate_inter_company_transaction(doc, doctype): - details = get_inter_company_details(doc, doctype) - price_list = ( - doc.selling_price_list - if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"] - else doc.buying_price_list - ) - valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1}) - if not valid_price_list and not doc.is_internal_transfer(): - frappe.throw(_("Selected Price List should have buying and selling fields checked.")) - - party = details.get("party") - if not party: - partytype = "Supplier" if doctype in ["Sales Invoice", "Sales Order"] else "Customer" - frappe.throw(_("No {0} found for Inter Company Transactions.").format(partytype)) - - company = details.get("company") - default_currency = frappe.get_cached_value("Company", company, "default_currency") - if default_currency != doc.currency: - frappe.throw( - _("Company currencies of both the companies should match for Inter Company Transactions.") - ) - - return - - -@frappe.whitelist() -def make_inter_company_purchase_invoice(source_name: str, target_doc: Document | None = None): - return make_inter_company_transaction("Sales Invoice", source_name, target_doc) - - @erpnext.allow_regional def make_regional_gl_entries(gl_entries, doc): return gl_entries -def make_inter_company_transaction(doctype, source_name, target_doc=None): - if doctype in ["Sales Invoice", "Sales Order"]: - source_doc = frappe.get_doc(doctype, source_name) - target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order" - target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item" - source_document_warehouse_field = "target_warehouse" - target_document_warehouse_field = "from_warehouse" - received_items = get_received_items(source_name, target_doctype, target_detail_field) - else: - source_doc = frappe.get_doc(doctype, source_name) - target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order" - source_document_warehouse_field = "from_warehouse" - target_document_warehouse_field = "target_warehouse" - received_items = {} - - validate_inter_company_transaction(source_doc, doctype) - details = get_inter_company_details(source_doc, doctype) - - def set_missing_values(source, target): - target.run_method("set_missing_values") - set_purchase_references(target) - - def update_details(source_doc, target_doc, source_parent): - def _validate_address_link(address, link_doctype, link_name): - return frappe.db.get_value( - "Dynamic Link", - { - "parent": address, - "parenttype": "Address", - "link_doctype": link_doctype, - "link_name": link_name, - }, - "parent", - ) - - target_doc.inter_company_invoice_reference = source_doc.name - if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]: - currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency") - target_doc.company = details.get("company") - target_doc.supplier = details.get("party") - target_doc.is_internal_supplier = 1 - target_doc.ignore_pricing_rule = 1 - target_doc.buying_price_list = source_doc.selling_price_list - - # Invert Addresses - if source_doc.company_address and _validate_address_link( - source_doc.company_address, "Supplier", details.get("party") - ): - update_address(target_doc, "supplier_address", "address_display", source_doc.company_address) - if source_doc.dispatch_address_name and _validate_address_link( - source_doc.dispatch_address_name, "Company", details.get("company") - ): - update_address( - target_doc, - "dispatch_address", - "dispatch_address_display", - source_doc.dispatch_address_name, - ) - if source_doc.shipping_address_name and _validate_address_link( - source_doc.shipping_address_name, "Company", details.get("company") - ): - update_address( - target_doc, - "shipping_address", - "shipping_address_display", - source_doc.shipping_address_name, - ) - if source_doc.customer_address and _validate_address_link( - source_doc.customer_address, "Company", details.get("company") - ): - update_address( - target_doc, "billing_address", "billing_address_display", source_doc.customer_address - ) - - if currency: - target_doc.currency = currency - - update_taxes( - target_doc, - party=target_doc.supplier, - party_type="Supplier", - company=target_doc.company, - doctype=target_doc.doctype, - party_address=target_doc.supplier_address, - company_address=target_doc.shipping_address, - ) - - else: - currency = frappe.db.get_value("Customer", details.get("party"), "default_currency") - target_doc.company = details.get("company") - target_doc.customer = details.get("party") - target_doc.selling_price_list = source_doc.buying_price_list - - if source_doc.supplier_address and _validate_address_link( - source_doc.supplier_address, "Company", details.get("company") - ): - update_address( - target_doc, "company_address", "company_address_display", source_doc.supplier_address - ) - if source_doc.shipping_address and _validate_address_link( - source_doc.shipping_address, "Customer", details.get("party") - ): - update_address( - target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address - ) - if source_doc.shipping_address and _validate_address_link( - source_doc.shipping_address, "Customer", details.get("party") - ): - update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address) - - if currency: - target_doc.currency = currency - - update_taxes( - target_doc, - party=target_doc.customer, - party_type="Customer", - company=target_doc.company, - doctype=target_doc.doctype, - party_address=target_doc.customer_address, - company_address=target_doc.company_address, - shipping_address_name=target_doc.shipping_address_name, - ) - - def update_item(source, target, source_parent): - target.qty = flt(source.qty) - received_items.get(source.name, 0.0) - if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item": - target.purchase_order = source.parent - target.purchase_order_item = source.name - target.material_request = source.material_request - target.material_request_item = source.material_request_item - - if ( - source.get("purchase_order") - and source.get("purchase_order_item") - and target.doctype == "Purchase Invoice Item" - ): - target.purchase_order = source.purchase_order - target.po_detail = source.purchase_order_item - - if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"): - target.use_serial_batch_fields = 1 - - item_field_map = { - "doctype": target_doctype + " Item", - "field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"], - "field_map": { - "rate": "rate", - }, - "postprocess": update_item, - "condition": lambda doc: doc.qty > 0, - } - - if doctype in ["Sales Invoice", "Sales Order"]: - item_field_map["field_map"].update( - { - "name": target_detail_field, - } - ) - - if source_doc.get("update_stock"): - item_field_map["field_map"].update( - { - source_document_warehouse_field: target_document_warehouse_field, - "batch_no": "batch_no", - "serial_no": "serial_no", - } - ) - elif target_doctype == "Sales Order": - item_field_map["field_map"].update( - { - source_document_warehouse_field: "warehouse", - } - ) - - doclist = get_mapped_doc( - doctype, - source_name, - { - doctype: { - "doctype": target_doctype, - "postprocess": update_details, - "set_target_warehouse": "set_from_warehouse", - "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], - }, - doctype + " Item": item_field_map, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -def get_received_items(reference_name, doctype, reference_fieldname): - reference_field = "inter_company_invoice_reference" - if doctype == "Purchase Order": - reference_field = "inter_company_order_reference" - - filters = { - reference_field: reference_name, - "docstatus": 1, - } - - target_doctypes = frappe.get_all( - doctype, - filters=filters, - as_list=True, - ) - - if target_doctypes: - target_doctypes = list(target_doctypes[0]) - - received_items_map = frappe._dict( - frappe.get_all( - doctype + " Item", - filters={"parent": ("in", target_doctypes)}, - fields=[reference_fieldname, "qty"], - as_list=1, - ) - ) - - return received_items_map - - -def set_purchase_references(doc): - # add internal PO or PR links if any - - if doc.is_internal_transfer(): - if doc.doctype == "Purchase Receipt": - so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference) - - if so_item_map: - pd_item_map, parent_child_map, warehouse_map = get_pd_details( - "Purchase Order Item", so_item_map, "sales_order_item" - ) - - update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map) - - elif doc.doctype == "Purchase Invoice": - dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference) - # First check for Purchase receipt - if list(dn_item_map.values()): - pd_item_map, parent_child_map, warehouse_map = get_pd_details( - "Purchase Receipt Item", dn_item_map, "delivery_note_item" - ) - - update_pi_items( - doc, - "pr_detail", - "purchase_receipt", - dn_item_map, - pd_item_map, - parent_child_map, - warehouse_map, - ) - - -def update_pi_items( - doc, - detail_field, - parent_field, - sales_item_map, - purchase_item_map, - parent_child_map, - warehouse_map, -): - for item in doc.get("items"): - item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item))) - item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item))) - if doc.update_stock: - item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item)) - if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"): - item.warehouse = frappe.db.get_value( - "Purchase Order Item", item.purchase_order_item, "warehouse" - ) - - -def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map): - for item in doc.get("items"): - item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item)) - if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"): - item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse") - - -def get_delivery_note_details(internal_reference): - si_item_details = frappe.get_all( - "Delivery Note Item", fields=["name", "so_detail"], filters={"parent": internal_reference} - ) - - return {d.name: d.so_detail for d in si_item_details if d.so_detail} - - -def get_sales_invoice_details(internal_reference): - dn_item_map = {} - so_item_map = {} - - si_item_details = frappe.get_all( - "Sales Invoice Item", - fields=["name", "so_detail", "dn_detail"], - filters={"parent": internal_reference}, - ) - - for d in si_item_details: - if d.dn_detail: - dn_item_map.setdefault(d.name, d.dn_detail) - if d.so_detail: - so_item_map.setdefault(d.name, d.so_detail) - - return dn_item_map, so_item_map - - -def get_pd_details(doctype, sd_detail_map, sd_detail_field): - pd_item_map = {} - accepted_warehouse_map = {} - parent_child_map = {} - - pd_item_details = frappe.get_all( - doctype, - fields=[sd_detail_field, "name", "warehouse", "parent"], - filters={sd_detail_field: ("in", list(sd_detail_map.values()))}, - ) - - for d in pd_item_details: - pd_item_map.setdefault(d.get(sd_detail_field), d.name) - parent_child_map.setdefault(d.get(sd_detail_field), d.parent) - accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse) - - return pd_item_map, parent_child_map, accepted_warehouse_map - - -def update_taxes( - doc, - party=None, - party_type=None, - company=None, - doctype=None, - party_address=None, - company_address=None, - shipping_address_name=None, - master_doctype=None, -): - # Update Party Details - party_details = get_party_details( - party=party, - party_type=party_type, - company=company, - doctype=doctype, - party_address=party_address, - company_address=company_address, - shipping_address=shipping_address_name, - ) - - # Update taxes and charges if any - doc.taxes_and_charges = party_details.get("taxes_and_charges") - doc.set("taxes", party_details.get("taxes")) - - -def update_address(doc, address_field, address_display_field, address_name): - doc.set(address_field, address_name) - fetch_values = get_fetch_values(doc.doctype, address_field, address_name) - - for key, value in fetch_values.items(): - doc.set(key, value) - - doc.set(address_display_field, get_address_display(doc.get(address_field))) - - @frappe.whitelist() def get_loyalty_programs(customer: str): """sets applicable loyalty program to the customer or returns a list of applicable programs""" @@ -2551,24 +2030,6 @@ def get_loyalty_programs(customer: str): return lp_details -@frappe.whitelist() -def create_invoice_discounting(source_name: str, target_doc: str | Document | None = None): - invoice = frappe.get_doc("Sales Invoice", source_name) - invoice_discounting = frappe.new_doc("Invoice Discounting") - invoice_discounting.company = invoice.company - invoice_discounting.append( - "invoices", - { - "sales_invoice": source_name, - "customer": invoice.customer, - "posting_date": invoice.posting_date, - "outstanding_amount": invoice.outstanding_amount, - }, - ) - - return invoice_discounting - - def update_multi_mode_option(doc, pos_profile): def append_payment(payment_mode): payment = doc.append("payments", {}) @@ -2651,60 +2112,6 @@ def get_mode_of_payment_info(mode_of_payment, company): ) -@frappe.whitelist() -def create_dunning( - source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False -): - from frappe.model.mapper import get_mapped_doc - - def postprocess_dunning(source, target): - from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text - - dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company}) - if dunning_type: - dunning_type = frappe.get_doc("Dunning Type", dunning_type) - target.dunning_type = dunning_type.name - target.rate_of_interest = dunning_type.rate_of_interest - target.dunning_fee = dunning_type.dunning_fee - target.income_account = dunning_type.income_account - target.cost_center = dunning_type.cost_center - letter_text = get_dunning_letter_text( - dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language - ) - - if letter_text: - target.body_text = letter_text.get("body_text") - target.closing_text = letter_text.get("closing_text") - target.language = letter_text.get("language") - - # update outstanding from doc - if source.payment_schedule and len(source.payment_schedule) == 1: - for row in target.overdue_payments: - if row.payment_schedule == source.payment_schedule[0].name: - row.outstanding = source.get("outstanding_amount") - - target.validate() - - return get_mapped_doc( - from_doctype="Sales Invoice", - from_docname=source_name, - target_doc=target_doc, - table_maps={ - "Sales Invoice": { - "doctype": "Dunning", - "field_map": {"customer_address": "customer_address", "parent": "sales_invoice"}, - }, - "Payment Schedule": { - "doctype": "Overdue Payment", - "field_map": {"name": "payment_schedule", "parent": "sales_invoice"}, - "condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(), - }, - }, - postprocess=postprocess_dunning, - ignore_permissions=ignore_permissions, - ) - - def check_if_return_invoice_linked_with_payment_entry(self): # If a Return invoice is linked with payment entry along with other invoices, # the cancellation of the Return causes allocated amount to be greater than paid From f9d67ebb1e7cd88f9d523d30c7576c8aa356948f Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:04:08 +0530 Subject: [PATCH 47/65] refactor(purchase_invoice): move mapping functions to mapper.py --- .../doctype/purchase_invoice/mapper.py | 129 ++++++++++++++++++ .../purchase_invoice/purchase_invoice.py | 124 +---------------- 2 files changed, 132 insertions(+), 121 deletions(-) create mode 100644 erpnext/accounts/doctype/purchase_invoice/mapper.py diff --git a/erpnext/accounts/doctype/purchase_invoice/mapper.py b/erpnext/accounts/doctype/purchase_invoice/mapper.py new file mode 100644 index 00000000000..d1c8df11df0 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/mapper.py @@ -0,0 +1,129 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt + +from erpnext.controllers.accounts_controller import merge_taxes + + +@frappe.whitelist() +def make_debit_note(source_name: str, target_doc: str | Document | None = None): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Purchase Invoice", source_name, target_doc) + + +@frappe.whitelist() +def make_stock_entry(source_name: str, target_doc: str | Document | None = None): + doc = get_mapped_doc( + "Purchase Invoice", + source_name, + { + "Purchase Invoice": {"doctype": "Stock Entry", "validation": {"docstatus": ["=", 1]}}, + "Purchase Invoice Item": { + "doctype": "Stock Entry Detail", + "field_map": {"stock_qty": "transfer_qty", "batch_no": "batch_no"}, + }, + }, + target_doc, + ) + + return doc + + +@frappe.whitelist() +def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction + + return make_inter_company_transaction("Purchase Invoice", source_name, target_doc) + + +@frappe.whitelist() +def make_purchase_receipt( + source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + def post_parent_process(source_parent, target_parent): + remove_items_with_zero_qty(target_parent) + set_missing_values(source_parent, target_parent) + + def remove_items_with_zero_qty(target_parent): + target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0] + + def set_missing_values(source_parent, target_parent): + target_parent.run_method("set_missing_values") + if args and args.get("merge_taxes"): + merge_taxes(source_parent, target_parent) + target_parent.run_method("calculate_taxes_and_totals") + + def update_item(obj, target, source_parent): + from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row + + returned_qty_map = ( + get_returned_qty_map_for_row( + source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice" + ) + or {} + ) + + target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty")) + target.received_qty = flt(obj.qty) - flt(obj.received_qty) + target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt( + obj.conversion_factor + ) + target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) + target.base_amount = ( + (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) + ) + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + doc = get_mapped_doc( + "Purchase Invoice", + source_name, + { + "Purchase Invoice": { + "doctype": "Purchase Receipt", + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Invoice Item": { + "doctype": "Purchase Receipt Item", + "field_map": { + "name": "purchase_invoice_item", + "parent": "purchase_invoice", + "bom": "bom", + "purchase_order": "purchase_order", + "po_detail": "purchase_order_item", + "material_request": "material_request", + "material_request_item": "material_request_item", + "wip_composite_asset": "wip_composite_asset", + }, + "postprocess": update_item, + "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": not (args and args.get("merge_taxes")), + "ignore": args.get("merge_taxes") if args else 0, + }, + }, + target_doc, + post_parent_process, + ) + + return doc diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 40a8ea7dba5..6138d02568f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -2,12 +2,9 @@ # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe import _, qb, throw from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate @@ -37,12 +34,14 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status -from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head +from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( update_billed_amount_based_on_po, ) +from .mapper import make_debit_note, make_inter_company_sales_invoice, make_purchase_receipt, make_stock_entry + class WarehouseMissingError(frappe.ValidationError): pass @@ -1140,31 +1139,6 @@ def make_regional_gl_entries(gl_entries, doc): return gl_entries -@frappe.whitelist() -def make_debit_note(source_name: str, target_doc: str | Document | None = None): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Purchase Invoice", source_name, target_doc) - - -@frappe.whitelist() -def make_stock_entry(source_name: str, target_doc: str | Document | None = None): - doc = get_mapped_doc( - "Purchase Invoice", - source_name, - { - "Purchase Invoice": {"doctype": "Stock Entry", "validation": {"docstatus": ["=", 1]}}, - "Purchase Invoice Item": { - "doctype": "Stock Entry Detail", - "field_map": {"stock_qty": "transfer_qty", "batch_no": "batch_no"}, - }, - }, - target_doc, - ) - - return doc - - @frappe.whitelist() def change_release_date(name: str, release_date: str | None = None): if frappe.db.exists("Purchase Invoice", name): @@ -1184,95 +1158,3 @@ def block_invoice(name: str, release_date: str, hold_comment: str | None = None) if frappe.db.exists("Purchase Invoice", name): pi = frappe.get_lazy_doc("Purchase Invoice", name) pi.block_invoice(hold_comment, release_date) - - -@frappe.whitelist() -def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction - - return make_inter_company_transaction("Purchase Invoice", source_name, target_doc) - - -@frappe.whitelist() -def make_purchase_receipt( - source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None -): - if args is None: - args = {} - if isinstance(args, str): - args = json.loads(args) - - def post_parent_process(source_parent, target_parent): - remove_items_with_zero_qty(target_parent) - set_missing_values(source_parent, target_parent) - - def remove_items_with_zero_qty(target_parent): - target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0] - - def set_missing_values(source_parent, target_parent): - target_parent.run_method("set_missing_values") - if args and args.get("merge_taxes"): - merge_taxes(source_parent, target_parent) - target_parent.run_method("calculate_taxes_and_totals") - - def update_item(obj, target, source_parent): - from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row - - returned_qty_map = ( - get_returned_qty_map_for_row( - source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice" - ) - or {} - ) - - target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty")) - target.received_qty = flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt( - obj.conversion_factor - ) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) - - def select_item(d): - filtered_items = args.get("filtered_children", []) - child_filter = d.name in filtered_items if filtered_items else True - return child_filter - - doc = get_mapped_doc( - "Purchase Invoice", - source_name, - { - "Purchase Invoice": { - "doctype": "Purchase Receipt", - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Invoice Item": { - "doctype": "Purchase Receipt Item", - "field_map": { - "name": "purchase_invoice_item", - "parent": "purchase_invoice", - "bom": "bom", - "purchase_order": "purchase_order", - "po_detail": "purchase_order_item", - "material_request": "material_request", - "material_request_item": "material_request_item", - "wip_composite_asset": "wip_composite_asset", - }, - "postprocess": update_item, - "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), - }, - "Purchase Taxes and Charges": { - "doctype": "Purchase Taxes and Charges", - "reset_value": not (args and args.get("merge_taxes")), - "ignore": args.get("merge_taxes") if args else 0, - }, - }, - target_doc, - post_parent_process, - ) - - return doc From 0a4fa5e35e4bf86da46f12686417cbf258de1bdb Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:11:38 +0530 Subject: [PATCH 48/65] refactor(work_order): move mapping functions to mapper.py --- .../doctype/work_order/mapper.py | 134 ++++++++++++++++++ .../doctype/work_order/work_order.py | 129 +---------------- 2 files changed, 136 insertions(+), 127 deletions(-) create mode 100644 erpnext/manufacturing/doctype/work_order/mapper.py diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py new file mode 100644 index 00000000000..e2230a1367f --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/mapper.py @@ -0,0 +1,134 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt + + +@frappe.whitelist() +def make_stock_entry( + work_order_id: str, + purpose: str, + qty: float | None = None, + target_warehouse: str | None = None, + is_additional_transfer_entry: bool = False, + source_stock_entry: str | None = None, +): + work_order = frappe.get_doc("Work Order", work_order_id) + if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): + wip_warehouse = work_order.wip_warehouse + else: + wip_warehouse = None + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.purpose = purpose + stock_entry.work_order = work_order_id + stock_entry.company = work_order.company + stock_entry.from_bom = 1 + stock_entry.bom_no = work_order.bom_no + stock_entry.use_multi_level_bom = work_order.use_multi_level_bom + if purpose in ["Material Transfer for Manufacture", "Manufacture"]: + stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order + # accept 0 qty as well + stock_entry.fg_completed_qty = ( + qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty)) + ) + + if purpose == "Material Transfer for Manufacture": + stock_entry.to_warehouse = wip_warehouse + stock_entry.project = work_order.project + else: + stock_entry.from_warehouse = ( + work_order.source_warehouse + if work_order.skip_transfer and not work_order.from_wip_warehouse + else wip_warehouse + ) + stock_entry.to_warehouse = work_order.fg_warehouse + stock_entry.project = work_order.project + if work_order.bom_no: + stock_entry.inspection_required = frappe.db.get_value( + "BOM", work_order.bom_no, "inspection_required" + ) + + if purpose == "Disassemble": + stock_entry.from_warehouse = work_order.fg_warehouse + stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse + if source_stock_entry: + stock_entry.source_stock_entry = source_stock_entry + + stock_entry.set_stock_entry_type() + stock_entry.is_additional_transfer_entry = is_additional_transfer_entry + stock_entry.get_items() + + return stock_entry.as_dict() + + +@frappe.whitelist() +def create_pick_list(source_name: str, target_doc: str | None = None, for_qty: float | None = None): + for_qty = for_qty or json.loads(target_doc).get("for_qty") + max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty") + + def update_item_quantity(source, target, source_parent): + pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty) + desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty) + + qty = 0 + if desire_to_transfer <= pending_to_issue: + qty = desire_to_transfer + elif pending_to_issue > 0: + qty = pending_to_issue + + if qty: + target.qty = qty + target.stock_qty = qty + target.uom = frappe.get_value("Item", source.item_code, "stock_uom") + target.stock_uom = target.uom + target.conversion_factor = 1 + else: + target.delete() + + doc = get_mapped_doc( + "Work Order", + source_name, + { + "Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}}, + "Work Order Item": { + "doctype": "Pick List Item", + "postprocess": update_item_quantity, + "condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty), + }, + }, + target_doc, + ) + + doc.purpose = "Material Transfer for Manufacture" + doc.for_qty = for_qty + + doc.set_item_locations() + + return doc + + +@frappe.whitelist() +def make_stock_return_entry(work_order: str): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + ManufactureStockEntry, + ) + + wo_doc = frappe.get_cached_doc("Work Order", work_order) + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.from_bom = 1 + stock_entry.is_return = 1 + stock_entry.work_order = work_order + stock_entry.purpose = "Material Transfer for Manufacture" + stock_entry.bom_no = wo_doc.bom_no + stock_entry.set_stock_entry_type() + + ste_cls = ManufactureStockEntry(stock_entry) + ste_cls.add_raw_materials_based_on_transfer() + ste_cls.return_available_materials_in_source_wh() + return stock_entry diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index e2c221487c0..f46c1b4d83e 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -8,7 +8,6 @@ import frappe from dateutil.relativedelta import relativedelta from frappe import _ from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Case from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( @@ -41,6 +40,8 @@ from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company from erpnext.utilities.transaction_base import validate_uom_is_integer +from .mapper import create_pick_list, make_stock_entry, make_stock_return_entry + class OverProductionError(frappe.ValidationError): pass @@ -2401,64 +2402,6 @@ def set_work_order_ops(name: str): po.save() -@frappe.whitelist() -def make_stock_entry( - work_order_id: str, - purpose: str, - qty: float | None = None, - target_warehouse: str | None = None, - is_additional_transfer_entry: bool = False, - source_stock_entry: str | None = None, -): - work_order = frappe.get_doc("Work Order", work_order_id) - if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): - wip_warehouse = work_order.wip_warehouse - else: - wip_warehouse = None - - stock_entry = frappe.new_doc("Stock Entry") - stock_entry.purpose = purpose - stock_entry.work_order = work_order_id - stock_entry.company = work_order.company - stock_entry.from_bom = 1 - stock_entry.bom_no = work_order.bom_no - stock_entry.use_multi_level_bom = work_order.use_multi_level_bom - if purpose in ["Material Transfer for Manufacture", "Manufacture"]: - stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order - # accept 0 qty as well - stock_entry.fg_completed_qty = ( - qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty)) - ) - - if purpose == "Material Transfer for Manufacture": - stock_entry.to_warehouse = wip_warehouse - stock_entry.project = work_order.project - else: - stock_entry.from_warehouse = ( - work_order.source_warehouse - if work_order.skip_transfer and not work_order.from_wip_warehouse - else wip_warehouse - ) - stock_entry.to_warehouse = work_order.fg_warehouse - stock_entry.project = work_order.project - if work_order.bom_no: - stock_entry.inspection_required = frappe.db.get_value( - "BOM", work_order.bom_no, "inspection_required" - ) - - if purpose == "Disassemble": - stock_entry.from_warehouse = work_order.fg_warehouse - stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse - if source_stock_entry: - stock_entry.source_stock_entry = source_stock_entry - - stock_entry.set_stock_entry_type() - stock_entry.is_additional_transfer_entry = is_additional_transfer_entry - stock_entry.get_items() - - return stock_entry.as_dict() - - @frappe.whitelist() def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float: se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True) @@ -2718,52 +2661,6 @@ def get_work_order_operation_data(work_order, operation, workstation): return d -@frappe.whitelist() -def create_pick_list(source_name: str, target_doc: str | None = None, for_qty: float | None = None): - for_qty = for_qty or json.loads(target_doc).get("for_qty") - max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty") - - def update_item_quantity(source, target, source_parent): - pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty) - desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty) - - qty = 0 - if desire_to_transfer <= pending_to_issue: - qty = desire_to_transfer - elif pending_to_issue > 0: - qty = pending_to_issue - - if qty: - target.qty = qty - target.stock_qty = qty - target.uom = frappe.get_value("Item", source.item_code, "stock_uom") - target.stock_uom = target.uom - target.conversion_factor = 1 - else: - target.delete() - - doc = get_mapped_doc( - "Work Order", - source_name, - { - "Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}}, - "Work Order Item": { - "doctype": "Pick List Item", - "postprocess": update_item_quantity, - "condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty), - }, - }, - target_doc, - ) - - doc.purpose = "Material Transfer for Manufacture" - doc.for_qty = for_qty - - doc.set_item_locations() - - return doc - - def get_reserved_qty_for_production( item_code: str, warehouse: str, @@ -2813,28 +2710,6 @@ def get_reserved_qty_for_production( return query.run()[0][0] or 0.0 -@frappe.whitelist() -def make_stock_return_entry(work_order: str): - from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( - ManufactureStockEntry, - ) - - wo_doc = frappe.get_cached_doc("Work Order", work_order) - - stock_entry = frappe.new_doc("Stock Entry") - stock_entry.from_bom = 1 - stock_entry.is_return = 1 - stock_entry.work_order = work_order - stock_entry.purpose = "Material Transfer for Manufacture" - stock_entry.bom_no = wo_doc.bom_no - stock_entry.set_stock_entry_type() - - ste_cls = ManufactureStockEntry(stock_entry) - ste_cls.add_raw_materials_based_on_transfer() - ste_cls.return_available_materials_in_source_wh() - return stock_entry - - def get_row_wise_serial_batch(work_order, purpose=None): if not purpose: purpose = "Material Transfer for Manufacture" From 341fad04c9d4ca8854707f7f31037ee5c3778d52 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:20:50 +0530 Subject: [PATCH 49/65] refactor(job_card): move mapping functions to mapper.py --- .../doctype/job_card/job_card.py | 182 +---------------- .../manufacturing/doctype/job_card/mapper.py | 191 ++++++++++++++++++ 2 files changed, 193 insertions(+), 180 deletions(-) create mode 100644 erpnext/manufacturing/doctype/job_card/mapper.py diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 12dad352dc2..45be24b8ad7 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -8,7 +8,6 @@ from typing import Any import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Criterion from frappe.query_builder.functions import IfNull, Max, Min, Sum from frappe.utils import ( @@ -37,6 +36,8 @@ from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import get_subcontracting_boms_for_finished_goods, ) +from .mapper import make_corrective_job_card, make_material_request, make_stock_entry, make_subcontracting_po + class OverlapError(frappe.ValidationError): pass @@ -1547,49 +1548,6 @@ class JobCard(Document): return ste.stock_entry.as_dict() -@frappe.whitelist() -def make_subcontracting_po(source_name: str, target_doc: Document | str | None = None): - def set_missing_values(source, target): - _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good) - - pending_qty = source.for_quantity - source.manufactured_qty - service_item_qty = flt(_item_details.service_item_qty) or 1.0 - fg_item_qty = flt(_item_details.finished_good_qty) or 1.0 - - target.is_subcontracted = 1 - target.supplier_warehouse = source.wip_warehouse - target.append( - "items", - { - "item_code": _item_details.service_item, - "fg_item": source.finished_good, - "uom": _item_details.service_item_uom, - "stock_uom": _item_details.service_item_uom, - "conversion_factor": _item_details.conversion_factor or 1, - "item_name": _item_details.service_item, - "qty": pending_qty * service_item_qty / fg_item_qty, - "fg_item_qty": pending_qty, - "job_card": source.name, - "bom": source.semi_fg_bom, - "warehouse": source.target_warehouse, - }, - ) - - doclist = get_mapped_doc( - "Job Card", - source_name, - { - "Job Card": { - "doctype": "Purchase Order", - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - @frappe.whitelist() def make_time_log(kwargs: str | dict): if isinstance(kwargs, str): @@ -1633,105 +1591,6 @@ def get_operations(doctype: str, txt: str, searchfield: str, start: int, page_le ) -@frappe.whitelist() -def make_material_request(source_name: str, target_doc: Document | str | None = None): - def update_item(obj, target, source_parent): - target.warehouse = source_parent.wip_warehouse - - def set_missing_values(source, target): - target.material_request_type = "Material Transfer" - - doclist = get_mapped_doc( - "Job Card", - source_name, - { - "Job Card": { - "doctype": "Material Request", - "field_map": { - "name": "job_card", - }, - }, - "Job Card Item": { - "doctype": "Material Request Item", - "field_map": {"required_qty": "qty", "uom": "stock_uom", "name": "job_card_item"}, - "postprocess": update_item, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_stock_entry(source_name: str, target_doc: Document | str | None = None): - def update_item(source, target, source_parent): - target.t_warehouse = source_parent.wip_warehouse - - if not target.conversion_factor: - target.conversion_factor = 1 - - pending_rm_qty = flt(source.required_qty) - flt(source.transferred_qty) - if pending_rm_qty > 0: - target.qty = pending_rm_qty - - def set_missing_values(source, target): - if source.finished_good and not source.target_warehouse: - frappe.throw(_("Please set the Target Warehouse in the Job Card")) - - if not source.skip_material_transfer or source.backflush_from_wip_warehouse: - if not source.wip_warehouse: - frappe.throw(_("Please set the WIP Warehouse in the Job Card")) - - target.purpose = "Material Transfer for Manufacture" - target.from_bom = 1 - - if source.semi_fg_bom: - target.bom_no = source.semi_fg_bom - - # avoid negative 'For Quantity' - pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0)) - target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0 - - target.set_missing_values() - target.set_stock_entry_type() - - wo_allows_alternate_item = frappe.db.get_value( - "Work Order", target.work_order, "allow_alternative_item" - ) - for item in target.items: - item.allow_alternative_item = int( - wo_allows_alternate_item - and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item") - ) - - doclist = get_mapped_doc( - "Job Card", - source_name, - { - "Job Card": { - "doctype": "Stock Entry", - "field_map": {"name": "job_card", "for_quantity": "fg_completed_qty"}, - }, - "Job Card Item": { - "doctype": "Stock Entry Detail", - "field_map": { - "source_warehouse": "s_warehouse", - "required_qty": "qty", - "name": "job_card_item", - }, - "postprocess": update_item, - "condition": lambda doc: doc.required_qty > 0, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - def time_diff_in_minutes(string_ed_date, string_st_date): return time_diff(string_ed_date, string_st_date).total_seconds() / 60 @@ -1782,40 +1641,3 @@ def get_job_details(start: Any, end: Any, filters: str | dict | None = None): events.append(job_card_data) return events - - -@frappe.whitelist() -def make_corrective_job_card( - source_name: str, - operation: str | None = None, - for_operation: str | None = None, - target_doc: Document | str | None = None, -): - def set_missing_values(source, target): - target.is_corrective_job_card = 1 - target.operation = operation - target.for_operation = for_operation - - target.set("time_logs", []) - target.set("employee", []) - target.set("items", []) - target.set("sub_operations", []) - target.set_sub_operations() - target.get_required_items() - - doclist = get_mapped_doc( - "Job Card", - source_name, - { - "Job Card": { - "doctype": "Job Card", - "field_map": { - "name": "for_job_card", - }, - } - }, - target_doc, - set_missing_values, - ) - - return doclist diff --git a/erpnext/manufacturing/doctype/job_card/mapper.py b/erpnext/manufacturing/doctype/job_card/mapper.py new file mode 100644 index 00000000000..0dce2daa157 --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card/mapper.py @@ -0,0 +1,191 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt + +from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( + get_subcontracting_boms_for_finished_goods, +) + + +@frappe.whitelist() +def make_subcontracting_po(source_name: str, target_doc: Document | str | None = None): + def set_missing_values(source, target): + _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good) + + pending_qty = source.for_quantity - source.manufactured_qty + service_item_qty = flt(_item_details.service_item_qty) or 1.0 + fg_item_qty = flt(_item_details.finished_good_qty) or 1.0 + + target.is_subcontracted = 1 + target.supplier_warehouse = source.wip_warehouse + target.append( + "items", + { + "item_code": _item_details.service_item, + "fg_item": source.finished_good, + "uom": _item_details.service_item_uom, + "stock_uom": _item_details.service_item_uom, + "conversion_factor": _item_details.conversion_factor or 1, + "item_name": _item_details.service_item, + "qty": pending_qty * service_item_qty / fg_item_qty, + "fg_item_qty": pending_qty, + "job_card": source.name, + "bom": source.semi_fg_bom, + "warehouse": source.target_warehouse, + }, + ) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Purchase Order", + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_material_request(source_name: str, target_doc: Document | str | None = None): + def update_item(obj, target, source_parent): + target.warehouse = source_parent.wip_warehouse + + def set_missing_values(source, target): + target.material_request_type = "Material Transfer" + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Material Request", + "field_map": { + "name": "job_card", + }, + }, + "Job Card Item": { + "doctype": "Material Request Item", + "field_map": {"required_qty": "qty", "uom": "stock_uom", "name": "job_card_item"}, + "postprocess": update_item, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_stock_entry(source_name: str, target_doc: Document | str | None = None): + def update_item(source, target, source_parent): + target.t_warehouse = source_parent.wip_warehouse + + if not target.conversion_factor: + target.conversion_factor = 1 + + pending_rm_qty = flt(source.required_qty) - flt(source.transferred_qty) + if pending_rm_qty > 0: + target.qty = pending_rm_qty + + def set_missing_values(source, target): + if source.finished_good and not source.target_warehouse: + frappe.throw(_("Please set the Target Warehouse in the Job Card")) + + if not source.skip_material_transfer or source.backflush_from_wip_warehouse: + if not source.wip_warehouse: + frappe.throw(_("Please set the WIP Warehouse in the Job Card")) + + target.purpose = "Material Transfer for Manufacture" + target.from_bom = 1 + + if source.semi_fg_bom: + target.bom_no = source.semi_fg_bom + + # avoid negative 'For Quantity' + pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0)) + target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0 + + target.set_missing_values() + target.set_stock_entry_type() + + wo_allows_alternate_item = frappe.db.get_value( + "Work Order", target.work_order, "allow_alternative_item" + ) + for item in target.items: + item.allow_alternative_item = int( + wo_allows_alternate_item + and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item") + ) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Stock Entry", + "field_map": {"name": "job_card", "for_quantity": "fg_completed_qty"}, + }, + "Job Card Item": { + "doctype": "Stock Entry Detail", + "field_map": { + "source_warehouse": "s_warehouse", + "required_qty": "qty", + "name": "job_card_item", + }, + "postprocess": update_item, + "condition": lambda doc: doc.required_qty > 0, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_corrective_job_card( + source_name: str, + operation: str | None = None, + for_operation: str | None = None, + target_doc: Document | str | None = None, +): + def set_missing_values(source, target): + target.is_corrective_job_card = 1 + target.operation = operation + target.for_operation = for_operation + + target.set("time_logs", []) + target.set("employee", []) + target.set("items", []) + target.set("sub_operations", []) + target.set_sub_operations() + target.get_required_items() + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Job Card", + "field_map": { + "name": "for_job_card", + }, + } + }, + target_doc, + set_missing_values, + ) + + return doclist From 9b857737570cf88b7e1f22b24eed1d334a652410 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:23:56 +0530 Subject: [PATCH 50/65] refactor(opportunity): move mapping functions to mapper.py --- erpnext/crm/doctype/opportunity/mapper.py | 152 ++++++++++++++++++ .../crm/doctype/opportunity/opportunity.py | 152 +----------------- 2 files changed, 160 insertions(+), 144 deletions(-) create mode 100644 erpnext/crm/doctype/opportunity/mapper.py diff --git a/erpnext/crm/doctype/opportunity/mapper.py b/erpnext/crm/doctype/opportunity/mapper.py new file mode 100644 index 00000000000..b3a66614613 --- /dev/null +++ b/erpnext/crm/doctype/opportunity/mapper.py @@ -0,0 +1,152 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.email.inbox import link_communication_to_document +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc + +from erpnext.setup.utils import get_exchange_rate + + +@frappe.whitelist() +def make_quotation(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + from erpnext.controllers.accounts_controller import get_default_taxes_and_charges + + quotation = frappe.get_doc(target) + + company_currency = frappe.get_cached_value("Company", quotation.company, "default_currency") + + if company_currency == quotation.currency: + exchange_rate = 1 + else: + exchange_rate = get_exchange_rate( + quotation.currency, company_currency, quotation.transaction_date, args="for_selling" + ) + + quotation.conversion_rate = exchange_rate + + # get default taxes + taxes = get_default_taxes_and_charges("Sales Taxes and Charges Template", company=quotation.company) + if taxes.get("taxes"): + quotation.update(taxes) + + quotation.run_method("set_missing_values") + quotation.run_method("calculate_taxes_and_totals") + if not source.get("items", []): + quotation.opportunity = source.name + + doclist = get_mapped_doc( + "Opportunity", + source_name, + { + "Opportunity": { + "doctype": "Quotation", + "field_map": {"opportunity_from": "quotation_to", "name": "enq_no"}, + }, + "Opportunity Item": { + "doctype": "Quotation Item", + "field_map": { + "parent": "prevdoc_docname", + "parenttype": "prevdoc_doctype", + "uom": "stock_uom", + }, + "add_if_empty": True, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None): + def update_item(obj, target, source_parent): + target.conversion_factor = 1.0 + + doclist = get_mapped_doc( + "Opportunity", + source_name, + { + "Opportunity": {"doctype": "Request for Quotation"}, + "Opportunity Item": { + "doctype": "Request for Quotation Item", + "field_map": [["name", "opportunity_item"], ["parent", "opportunity"], ["uom", "uom"]], + "postprocess": update_item, + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_customer(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + target.opportunity_name = source.name + + if source.opportunity_from == "Lead": + target.lead_name = source.party_name + + doclist = get_mapped_doc( + "Opportunity", + source_name, + { + "Opportunity": { + "doctype": "Customer", + "field_map": {"currency": "default_currency", "customer_name": "customer_name"}, + } + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None): + doclist = get_mapped_doc( + "Opportunity", + source_name, + { + "Opportunity": {"doctype": "Supplier Quotation", "field_map": {"name": "opportunity"}}, + "Opportunity Item": {"doctype": "Supplier Quotation Item", "field_map": {"uom": "stock_uom"}}, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_opportunity_from_communication( + communication: str, company: str, ignore_communication_links: bool = False +): + from erpnext.crm.doctype.lead.lead import make_lead_from_communication + + doc = frappe.get_doc("Communication", communication) + + lead = doc.reference_name if doc.reference_doctype == "Lead" else None + if not lead: + lead = make_lead_from_communication(communication, ignore_communication_links=True) + + opportunity_from = "Lead" + + opportunity = frappe.get_doc( + { + "doctype": "Opportunity", + "company": company, + "opportunity_from": opportunity_from, + "party_name": lead, + } + ).insert(ignore_permissions=True) + + link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links) + + return opportunity.name diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 179f27bdede..17d321a88d6 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -7,9 +7,7 @@ import json import frappe from frappe import _ from frappe.contacts.address_and_contact import load_address_and_contact -from frappe.email.inbox import link_communication_to_document from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder import DocType, Interval from frappe.query_builder.functions import Now from frappe.utils import flt, get_fullname @@ -24,6 +22,14 @@ from erpnext.crm.utils import ( from erpnext.setup.utils import get_exchange_rate from erpnext.utilities.transaction_base import TransactionBase +from .mapper import ( + make_customer, + make_opportunity_from_communication, + make_quotation, + make_request_for_quotation, + make_supplier_quotation, +) + class Opportunity(TransactionBase, CRMNote): # begin: auto-generated types @@ -389,120 +395,6 @@ def get_item_details(item_code: str): } -@frappe.whitelist() -def make_quotation(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - from erpnext.controllers.accounts_controller import get_default_taxes_and_charges - - quotation = frappe.get_doc(target) - - company_currency = frappe.get_cached_value("Company", quotation.company, "default_currency") - - if company_currency == quotation.currency: - exchange_rate = 1 - else: - exchange_rate = get_exchange_rate( - quotation.currency, company_currency, quotation.transaction_date, args="for_selling" - ) - - quotation.conversion_rate = exchange_rate - - # get default taxes - taxes = get_default_taxes_and_charges("Sales Taxes and Charges Template", company=quotation.company) - if taxes.get("taxes"): - quotation.update(taxes) - - quotation.run_method("set_missing_values") - quotation.run_method("calculate_taxes_and_totals") - if not source.get("items", []): - quotation.opportunity = source.name - - doclist = get_mapped_doc( - "Opportunity", - source_name, - { - "Opportunity": { - "doctype": "Quotation", - "field_map": {"opportunity_from": "quotation_to", "name": "enq_no"}, - }, - "Opportunity Item": { - "doctype": "Quotation Item", - "field_map": { - "parent": "prevdoc_docname", - "parenttype": "prevdoc_doctype", - "uom": "stock_uom", - }, - "add_if_empty": True, - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None): - def update_item(obj, target, source_parent): - target.conversion_factor = 1.0 - - doclist = get_mapped_doc( - "Opportunity", - source_name, - { - "Opportunity": {"doctype": "Request for Quotation"}, - "Opportunity Item": { - "doctype": "Request for Quotation Item", - "field_map": [["name", "opportunity_item"], ["parent", "opportunity"], ["uom", "uom"]], - "postprocess": update_item, - }, - }, - target_doc, - ) - - return doclist - - -@frappe.whitelist() -def make_customer(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - target.opportunity_name = source.name - - if source.opportunity_from == "Lead": - target.lead_name = source.party_name - - doclist = get_mapped_doc( - "Opportunity", - source_name, - { - "Opportunity": { - "doctype": "Customer", - "field_map": {"currency": "default_currency", "customer_name": "customer_name"}, - } - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None): - doclist = get_mapped_doc( - "Opportunity", - source_name, - { - "Opportunity": {"doctype": "Supplier Quotation", "field_map": {"name": "opportunity"}}, - "Opportunity Item": {"doctype": "Supplier Quotation Item", "field_map": {"uom": "stock_uom"}}, - }, - target_doc, - ) - - return doclist - - @frappe.whitelist() def set_multiple_status(names: str | list[str], status: str): names = json.loads(names) @@ -531,31 +423,3 @@ def auto_close_opportunity(): doc.flags.ignore_permissions = True doc.flags.ignore_mandatory = True doc.save() - - -@frappe.whitelist() -def make_opportunity_from_communication( - communication: str, company: str, ignore_communication_links: bool = False -): - from erpnext.crm.doctype.lead.lead import make_lead_from_communication - - doc = frappe.get_doc("Communication", communication) - - lead = doc.reference_name if doc.reference_doctype == "Lead" else None - if not lead: - lead = make_lead_from_communication(communication, ignore_communication_links=True) - - opportunity_from = "Lead" - - opportunity = frappe.get_doc( - { - "doctype": "Opportunity", - "company": company, - "opportunity_from": opportunity_from, - "party_name": lead, - } - ).insert(ignore_permissions=True) - - link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links) - - return opportunity.name From 28c3d24b862d90b1122c4357ee643fb271a7d321 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:26:31 +0530 Subject: [PATCH 51/65] refactor(lead): move mapping functions to mapper.py --- erpnext/crm/doctype/lead/lead.py | 163 +--------------------------- erpnext/crm/doctype/lead/mapper.py | 169 +++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 161 deletions(-) create mode 100644 erpnext/crm/doctype/lead/mapper.py diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index b4dbf719a12..e5a6ce7632d 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -7,11 +7,7 @@ from frappe.contacts.address_and_contact import ( delete_contact_and_address, load_address_and_contact, ) -from frappe.contacts.doctype.address.address import get_default_address -from frappe.contacts.doctype.contact.contact import get_default_contact -from frappe.email.inbox import link_communication_to_document from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address from frappe.utils.data import DateTimeLikeObject @@ -20,6 +16,8 @@ from erpnext.controllers.selling_controller import SellingController from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events from erpnext.selling.doctype.customer.customer import parse_full_name +from .mapper import make_customer, make_lead_from_communication, make_opportunity, make_quotation + class Lead(SellingController, CRMNote): # begin: auto-generated types @@ -322,134 +320,6 @@ class Lead(SellingController, CRMNote): return None -@frappe.whitelist() -def make_customer(source_name: str, target_doc: str | Document | None = None): - return _make_customer(source_name, target_doc) - - -def _make_customer(source_name, target_doc=None, ignore_permissions=False): - def set_missing_values(source, target): - if source.company_name: - target.customer_type = "Company" - target.customer_name = source.company_name - else: - target.customer_type = "Individual" - target.customer_name = source.lead_name - - if not target.customer_group: - target.customer_group = frappe.db.get_default("Customer Group") - - address = get_default_address("Lead", source.name) - contact = get_default_contact("Lead", source.name) - if address: - target.customer_primary_address = address - if contact: - target.customer_primary_contact = contact - - doclist = get_mapped_doc( - "Lead", - source_name, - { - "Lead": { - "doctype": "Customer", - "field_map": { - "name": "lead_name", - "company_name": "customer_name", - "contact_no": "phone_1", - "fax": "fax_1", - }, - "field_no_map": ["disabled"], - } - }, - target_doc, - set_missing_values, - ignore_permissions=ignore_permissions, - ) - - return doclist - - -@frappe.whitelist() -def make_opportunity(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - _set_missing_values(source, target) - - target_doc = get_mapped_doc( - "Lead", - source_name, - { - "Lead": { - "doctype": "Opportunity", - "field_map": { - "doctype": "opportunity_from", - "name": "party_name", - "lead_name": "contact_display", - "company_name": "customer_name", - "email_id": "contact_email", - "mobile_no": "contact_mobile", - "lead_owner": "opportunity_owner", - "notes": "notes", - }, - } - }, - target_doc, - set_missing_values, - ) - - return target_doc - - -@frappe.whitelist() -def make_quotation(source_name: str, target_doc: str | Document | None = None): - def set_missing_values(source, target): - _set_missing_values(source, target) - - target_doc = get_mapped_doc( - "Lead", - source_name, - {"Lead": {"doctype": "Quotation", "field_map": {"name": "party_name"}}}, - target_doc, - set_missing_values, - ) - - target_doc.quotation_to = "Lead" - target_doc.run_method("set_missing_values") - target_doc.run_method("set_other_charges") - target_doc.run_method("calculate_taxes_and_totals") - - return target_doc - - -def _set_missing_values(source, target): - address = frappe.get_all( - "Dynamic Link", - { - "link_doctype": source.doctype, - "link_name": source.name, - "parenttype": "Address", - }, - ["parent"], - limit=1, - ) - - contact = frappe.get_all( - "Dynamic Link", - { - "link_doctype": source.doctype, - "link_name": source.name, - "parenttype": "Contact", - }, - ["parent"], - limit=1, - ) - - if address: - target.customer_address = address[0].parent - - if contact: - target.contact_person = contact[0].parent - - @frappe.whitelist() def get_lead_details( lead: str, @@ -494,35 +364,6 @@ def get_lead_details( return out -@frappe.whitelist() -def make_lead_from_communication(communication: str, ignore_communication_links: bool = False): - """raise a issue from email""" - - doc = frappe.get_doc("Communication", communication) - lead_name = None - if doc.sender: - lead_name = frappe.db.get_value("Lead", {"email_id": doc.sender}) - if not lead_name and doc.phone_no: - lead_name = frappe.db.get_value("Lead", {"mobile_no": doc.phone_no}) - if not lead_name: - lead = frappe.get_doc( - { - "doctype": "Lead", - "lead_name": doc.sender_full_name, - "email_id": doc.sender, - "mobile_no": doc.phone_no, - } - ) - lead.flags.ignore_mandatory = True - lead.flags.ignore_permissions = True - lead.insert() - - lead_name = lead.name - - link_communication_to_document(doc, "Lead", lead_name, ignore_communication_links) - return lead_name - - def get_lead_with_phone_number(number): if not number: return diff --git a/erpnext/crm/doctype/lead/mapper.py b/erpnext/crm/doctype/lead/mapper.py new file mode 100644 index 00000000000..b90fba19a79 --- /dev/null +++ b/erpnext/crm/doctype/lead/mapper.py @@ -0,0 +1,169 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.contacts.doctype.address.address import get_default_address +from frappe.contacts.doctype.contact.contact import get_default_contact +from frappe.email.inbox import link_communication_to_document +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc + + +@frappe.whitelist() +def make_customer(source_name: str, target_doc: str | Document | None = None): + return _make_customer(source_name, target_doc) + + +def _make_customer( + source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False +): + def set_missing_values(source, target): + if source.company_name: + target.customer_type = "Company" + target.customer_name = source.company_name + else: + target.customer_type = "Individual" + target.customer_name = source.lead_name + + if not target.customer_group: + target.customer_group = frappe.db.get_default("Customer Group") + + address = get_default_address("Lead", source.name) + contact = get_default_contact("Lead", source.name) + if address: + target.customer_primary_address = address + if contact: + target.customer_primary_contact = contact + + doclist = get_mapped_doc( + "Lead", + source_name, + { + "Lead": { + "doctype": "Customer", + "field_map": { + "name": "lead_name", + "company_name": "customer_name", + "contact_no": "phone_1", + "fax": "fax_1", + }, + "field_no_map": ["disabled"], + } + }, + target_doc, + set_missing_values, + ignore_permissions=ignore_permissions, + ) + + return doclist + + +@frappe.whitelist() +def make_opportunity(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + _set_missing_values(source, target) + + target_doc = get_mapped_doc( + "Lead", + source_name, + { + "Lead": { + "doctype": "Opportunity", + "field_map": { + "doctype": "opportunity_from", + "name": "party_name", + "lead_name": "contact_display", + "company_name": "customer_name", + "email_id": "contact_email", + "mobile_no": "contact_mobile", + "lead_owner": "opportunity_owner", + "notes": "notes", + }, + } + }, + target_doc, + set_missing_values, + ) + + return target_doc + + +@frappe.whitelist() +def make_quotation(source_name: str, target_doc: str | Document | None = None): + def set_missing_values(source, target): + _set_missing_values(source, target) + + target_doc = get_mapped_doc( + "Lead", + source_name, + {"Lead": {"doctype": "Quotation", "field_map": {"name": "party_name"}}}, + target_doc, + set_missing_values, + ) + + target_doc.quotation_to = "Lead" + target_doc.run_method("set_missing_values") + target_doc.run_method("set_other_charges") + target_doc.run_method("calculate_taxes_and_totals") + + return target_doc + + +@frappe.whitelist() +def make_lead_from_communication(communication: str, ignore_communication_links: bool = False): + """raise a issue from email""" + + doc = frappe.get_doc("Communication", communication) + lead_name = None + if doc.sender: + lead_name = frappe.db.get_value("Lead", {"email_id": doc.sender}) + if not lead_name and doc.phone_no: + lead_name = frappe.db.get_value("Lead", {"mobile_no": doc.phone_no}) + if not lead_name: + lead = frappe.get_doc( + { + "doctype": "Lead", + "lead_name": doc.sender_full_name, + "email_id": doc.sender, + "mobile_no": doc.phone_no, + } + ) + lead.flags.ignore_mandatory = True + lead.flags.ignore_permissions = True + lead.insert() + + lead_name = lead.name + + link_communication_to_document(doc, "Lead", lead_name, ignore_communication_links) + return lead_name + + +def _set_missing_values(source, target): + address = frappe.get_all( + "Dynamic Link", + { + "link_doctype": source.doctype, + "link_name": source.name, + "parenttype": "Address", + }, + ["parent"], + limit=1, + ) + + contact = frappe.get_all( + "Dynamic Link", + { + "link_doctype": source.doctype, + "link_name": source.name, + "parenttype": "Contact", + }, + ["parent"], + limit=1, + ) + + if address: + target.customer_address = address[0].parent + + if contact: + target.contact_person = contact[0].parent From 35ac7155e834ab5bddbf447e44d581b3ce28dfdd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:28:37 +0530 Subject: [PATCH 52/65] refactor(subcontracting_receipt): move mapping functions to mapper.py --- .../doctype/subcontracting_receipt/mapper.py | 168 ++++++++++++++++++ .../subcontracting_receipt.py | 167 +---------------- 2 files changed, 174 insertions(+), 161 deletions(-) create mode 100644 erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py new file mode 100644 index 00000000000..4927d6723c0 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py @@ -0,0 +1,168 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt, get_link_to_form + + +@frappe.whitelist() +def make_subcontract_return_against_rejected_warehouse(source_name: str): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True) + + +@frappe.whitelist() +def make_subcontract_return(source_name: str, target_doc: Document | str | None = None): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Subcontracting Receipt", source_name, target_doc) + + +@frappe.whitelist() +def make_purchase_receipt( + source_name: Document | str, + target_doc: Document | str | None = None, + save: bool = False, + submit: bool = False, + notify: bool = False, +): + if isinstance(source_name, str): + source_doc = frappe.get_doc("Subcontracting Receipt", source_name) + else: + source_doc = source_name + + if source_doc.is_return: + return + + po_sr_item_dict = {} + po_name = None + for item in source_doc.items: + if not item.purchase_order: + continue + + if not po_name: + po_name = item.purchase_order + + po_sr_item_dict[item.purchase_order_item] = { + "qty": flt(item.qty), + "rejected_qty": flt(item.rejected_qty), + "warehouse": item.warehouse, + "rejected_warehouse": item.rejected_warehouse, + "subcontracting_receipt_item": item.name, + } + + if not po_name: + frappe.throw( + _("Purchase Order Item reference is missing in Subcontracting Receipt {0}").format( + source_doc.name + ) + ) + + def update_item(obj, target, source_parent): + sr_item_details = po_sr_item_dict.get(obj.name) + ratio = flt(obj.qty) / flt(obj.fg_item_qty) + + target.update( + { + "qty": ratio * sr_item_details["qty"], + "rejected_qty": ratio * sr_item_details["rejected_qty"], + "warehouse": sr_item_details["warehouse"], + "rejected_warehouse": sr_item_details["rejected_warehouse"], + "subcontracting_receipt_item": sr_item_details["subcontracting_receipt_item"], + } + ) + + def post_process(source, target): + target.set_missing_values() + target.update( + { + "posting_date": source_doc.posting_date, + "posting_time": source_doc.posting_time, + "subcontracting_receipt": source_doc.name, + "supplier_warehouse": source_doc.supplier_warehouse, + "is_subcontracted": 1, + "currency": frappe.get_cached_value("Company", target.company, "default_currency"), + } + ) + + target_doc = get_mapped_doc( + "Purchase Order", + po_name, + { + "Purchase Order": { + "doctype": "Purchase Receipt", + "field_map": {"supplier_warehouse": "supplier_warehouse"}, + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Order Item": { + "doctype": "Purchase Receipt Item", + "field_map": { + "name": "purchase_order_item", + "parent": "purchase_order", + "bom": "bom", + }, + "postprocess": update_item, + "condition": lambda doc: doc.name in po_sr_item_dict, + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": True, + }, + }, + postprocess=post_process, + ) + + if not target_doc.get("items"): + add_po_items_to_pr(source_doc, target_doc) + + if (save or submit) and frappe.has_permission(target_doc.doctype, "create"): + target_doc.save() + + if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc): + try: + target_doc.submit() + except Exception as e: + target_doc.add_comment("Comment", _("Submit Action Failed") + "

    " + str(e)) + + if notify: + frappe.msgprint( + _("Purchase Receipt {0} created.").format( + get_link_to_form(target_doc.doctype, target_doc.name) + ), + indicator="green", + alert=True, + ) + + return target_doc + + +def add_po_items_to_pr(scr_doc, target_doc): + fg_items = {(item.item_code, item.purchase_order): item.qty for item in scr_doc.items} + + for (item_code, po_name), fg_qty in fg_items.items(): + po_doc = frappe.get_doc("Purchase Order", po_name) + for item in po_doc.items: + if item.fg_item != item_code: + continue + + qty = (item.stock_qty - item.received_qty) * fg_qty / item.fg_item_qty + if qty: + target_doc.append( + "items", + { + "item_code": item.item_code, + "item_name": item.item_name, + "description": item.description, + "qty": qty, + "rate": item.rate, + "warehouse": item.warehouse, + "purchase_order": item.parent, + "purchase_order_item": item.name, + }, + ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 1ae55f47017..b5948dff305 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -6,7 +6,6 @@ from collections import defaultdict import frappe from frappe import _ from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate @@ -20,6 +19,12 @@ from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.get_item_details import get_default_cost_center, get_default_expense_account from erpnext.stock.stock_ledger import get_valuation_rate +from .mapper import ( + make_purchase_receipt, + make_subcontract_return, + make_subcontract_return_against_rejected_warehouse, +) + class BOMQuantityError(frappe.ValidationError): pass @@ -750,163 +755,3 @@ class SubcontractingReceipt(SubcontractingController): return True return False - - -@frappe.whitelist() -def make_subcontract_return_against_rejected_warehouse(source_name: str): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True) - - -@frappe.whitelist() -def make_subcontract_return(source_name: str, target_doc: Document | str | None = None): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Subcontracting Receipt", source_name, target_doc) - - -@frappe.whitelist() -def make_purchase_receipt( - source_name: Document | str, - target_doc: Document | str | None = None, - save: bool = False, - submit: bool = False, - notify: bool = False, -): - if isinstance(source_name, str): - source_doc = frappe.get_doc("Subcontracting Receipt", source_name) - else: - source_doc = source_name - - if source_doc.is_return: - return - - po_sr_item_dict = {} - po_name = None - for item in source_doc.items: - if not item.purchase_order: - continue - - if not po_name: - po_name = item.purchase_order - - po_sr_item_dict[item.purchase_order_item] = { - "qty": flt(item.qty), - "rejected_qty": flt(item.rejected_qty), - "warehouse": item.warehouse, - "rejected_warehouse": item.rejected_warehouse, - "subcontracting_receipt_item": item.name, - } - - if not po_name: - frappe.throw( - _("Purchase Order Item reference is missing in Subcontracting Receipt {0}").format( - source_doc.name - ) - ) - - def update_item(obj, target, source_parent): - sr_item_details = po_sr_item_dict.get(obj.name) - ratio = flt(obj.qty) / flt(obj.fg_item_qty) - - target.update( - { - "qty": ratio * sr_item_details["qty"], - "rejected_qty": ratio * sr_item_details["rejected_qty"], - "warehouse": sr_item_details["warehouse"], - "rejected_warehouse": sr_item_details["rejected_warehouse"], - "subcontracting_receipt_item": sr_item_details["subcontracting_receipt_item"], - } - ) - - def post_process(source, target): - target.set_missing_values() - target.update( - { - "posting_date": source_doc.posting_date, - "posting_time": source_doc.posting_time, - "subcontracting_receipt": source_doc.name, - "supplier_warehouse": source_doc.supplier_warehouse, - "is_subcontracted": 1, - "currency": frappe.get_cached_value("Company", target.company, "default_currency"), - } - ) - - target_doc = get_mapped_doc( - "Purchase Order", - po_name, - { - "Purchase Order": { - "doctype": "Purchase Receipt", - "field_map": {"supplier_warehouse": "supplier_warehouse"}, - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Order Item": { - "doctype": "Purchase Receipt Item", - "field_map": { - "name": "purchase_order_item", - "parent": "purchase_order", - "bom": "bom", - }, - "postprocess": update_item, - "condition": lambda doc: doc.name in po_sr_item_dict, - }, - "Purchase Taxes and Charges": { - "doctype": "Purchase Taxes and Charges", - "reset_value": True, - }, - }, - postprocess=post_process, - ) - - if not target_doc.get("items"): - add_po_items_to_pr(source_doc, target_doc) - - if (save or submit) and frappe.has_permission(target_doc.doctype, "create"): - target_doc.save() - - if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc): - try: - target_doc.submit() - except Exception as e: - target_doc.add_comment("Comment", _("Submit Action Failed") + "

    " + str(e)) - - if notify: - frappe.msgprint( - _("Purchase Receipt {0} created.").format( - get_link_to_form(target_doc.doctype, target_doc.name) - ), - indicator="green", - alert=True, - ) - - return target_doc - - -def add_po_items_to_pr(scr_doc, target_doc): - fg_items = {(item.item_code, item.purchase_order): item.qty for item in scr_doc.items} - - for (item_code, po_name), fg_qty in fg_items.items(): - po_doc = frappe.get_doc("Purchase Order", po_name) - for item in po_doc.items: - if item.fg_item != item_code: - continue - - qty = (item.stock_qty - item.received_qty) * fg_qty / item.fg_item_qty - if qty: - target_doc.append( - "items", - { - "item_code": item.item_code, - "item_name": item.item_name, - "description": item.description, - "qty": qty, - "rate": item.rate, - "warehouse": item.warehouse, - "purchase_order": item.parent, - "purchase_order_item": item.name, - }, - ) From 61da2302ba2c7c6c9a280635c497a5a74443cc18 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:38:00 +0530 Subject: [PATCH 53/65] refactor(asset): move mapping functions to mapper.py --- erpnext/assets/doctype/asset/asset.py | 392 +----------------------- erpnext/assets/doctype/asset/mapper.py | 394 +++++++++++++++++++++++++ 2 files changed, 404 insertions(+), 382 deletions(-) create mode 100644 erpnext/assets/doctype/asset/mapper.py diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index a9b45a79135..cf749a4c6b8 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -21,7 +21,6 @@ from frappe.utils import ( ) import erpnext -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.accounts.general_ledger import make_reverse_gl_entries from erpnext.assets.doctype.asset.depreciation import ( get_comma_separated_links, @@ -38,6 +37,16 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched ) from erpnext.controllers.accounts_controller import AccountsController +from .mapper import ( + create_asset_capitalization, + create_asset_maintenance, + create_asset_repair, + create_asset_value_adjustment, + make_journal_entry, + make_sales_invoice, + split_asset, +) + class Asset(AccountsController): # begin: auto-generated types @@ -1092,101 +1101,6 @@ def get_asset_naming_series(): return meta.get_field("naming_series").options -@frappe.whitelist() -def make_sales_invoice(asset: str, item_code: str, company: str, sell_qty: int, serial_no: str | None = None): - asset_doc = frappe.get_doc("Asset", asset) - si = frappe.new_doc("Sales Invoice") - si.company = company - si.currency = frappe.get_cached_value("Company", company, "default_currency") - disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(company) - si.append( - "items", - { - "item_code": item_code, - "is_fixed_asset": 1, - "asset": asset, - "income_account": disposal_account, - "serial_no": serial_no, - "cost_center": depreciation_cost_center, - "qty": sell_qty, - }, - ) - - accounting_dimensions = get_dimensions(with_cost_center_and_project=True) - for dimension in accounting_dimensions[0]: - si.update( - { - dimension["fieldname"]: asset_doc.get(dimension["fieldname"]) - or dimension.get("default_dimension") - } - ) - - si.set_missing_values() - return si - - -@frappe.whitelist() -def create_asset_maintenance( - asset: str, - item_code: str, - item_name: str, - asset_category: str, - company: str, -): - asset_maintenance = frappe.new_doc("Asset Maintenance") - asset_maintenance.update( - { - "asset_name": asset, - "company": company, - "item_code": item_code, - "item_name": item_name, - "asset_category": asset_category, - } - ) - return asset_maintenance - - -@frappe.whitelist() -def create_asset_repair( - company: str, - asset: str, - asset_name: str, -): - asset_repair = frappe.new_doc("Asset Repair") - asset_repair.update({"company": company, "asset": asset, "asset_name": asset_name}) - return asset_repair - - -@frappe.whitelist() -def create_asset_capitalization( - company: str, - asset: str, - asset_name: str, - item_code: str, -): - asset_capitalization = frappe.new_doc("Asset Capitalization") - asset_capitalization.update( - { - "target_asset": asset, - "company": company, - "target_asset_name": asset_name, - "target_item_code": item_code, - } - ) - return asset_capitalization - - -@frappe.whitelist() -def create_asset_value_adjustment( - asset: str, - asset_category: str, - company: str, -): - asset_value_adjustment = frappe.new_doc("Asset Value Adjustment") - asset_value_adjustment.update({"asset": asset, "company": company, "asset_category": asset_category}) - return asset_value_adjustment - - @frappe.whitelist() def get_item_details( item_code: str, @@ -1241,79 +1155,6 @@ def get_asset_account(account_name, asset=None, asset_category=None, company=Non return account -@frappe.whitelist() -def make_journal_entry(asset_name: str): - asset = frappe.get_doc("Asset", asset_name) - ( - fixed_asset_account, - accumulated_depreciation_account, - depreciation_expense_account, - ) = get_depreciation_accounts(asset.asset_category, asset.company) - - depreciation_cost_center, depreciation_series = frappe.get_cached_value( - "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] - ) - depreciation_cost_center = asset.cost_center or depreciation_cost_center - - je = frappe.new_doc("Journal Entry") - je.voucher_type = "Depreciation Entry" - je.naming_series = depreciation_series - je.company = asset.company - je.remark = _("Depreciation Entry against asset {0}").format(asset_name) - - je.append( - "accounts", - { - "account": depreciation_expense_account, - "reference_type": "Asset", - "reference_name": asset.name, - "cost_center": depreciation_cost_center, - }, - ) - - je.append( - "accounts", - { - "account": accumulated_depreciation_account, - "reference_type": "Asset", - "reference_name": asset.name, - }, - ) - - return je - - -@frappe.whitelist() -def make_asset_movement( - assets: list[dict] | str, - purpose: str = "Transfer", -): - import json - - if isinstance(assets, str): - assets = json.loads(assets) - - if len(assets) == 0: - frappe.throw(_("At least one asset has to be selected.")) - - asset_movement = frappe.new_doc("Asset Movement") - asset_movement.purpose = purpose - for asset in assets: - asset = frappe.get_doc("Asset", asset.get("name")) - asset_movement.company = asset.get("company") - asset_movement.append( - "assets", - { - "asset": asset.get("name"), - "source_location": asset.get("location"), - "from_employee": asset.get("custodian"), - }, - ) - - if asset_movement.get("assets"): - return asset_movement.as_dict() - - def is_cwip_accounting_enabled(asset_category): return cint(frappe.db.get_value("Asset Category", asset_category, "enable_cwip_accounting")) @@ -1362,216 +1203,3 @@ def get_values_from_purchase_doc( "purchase_receipt_item": first_item.name if doctype == "Purchase Receipt" else None, "purchase_invoice_item": first_item.name if doctype == "Purchase Invoice" else None, } - - -@frappe.whitelist() -def split_asset(asset_name: str, split_qty: int): - """Split an asset into two based on the given quantity.""" - existing_asset = frappe.get_doc("Asset", asset_name) - split_qty = cint(split_qty) - - validate_split_quantity(existing_asset, split_qty) - remaining_qty = existing_asset.asset_quantity - split_qty - - # Create new asset and update existing one - splitted_asset = create_new_asset_from_split(existing_asset, split_qty) - update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset) - - return splitted_asset - - -def validate_split_quantity(existing_asset, split_qty): - if split_qty >= existing_asset.asset_quantity: - frappe.throw(_("Split Quantity must be less than Asset Quantity")) - - -def create_new_asset_from_split(existing_asset, split_qty): - """Create a new asset from the split quantity.""" - return process_asset_split(existing_asset, split_qty, is_new_asset=True) - - -def update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset): - """Update the existing asset with the remaining quantity.""" - process_asset_split(existing_asset, remaining_qty, splitted_asset=splitted_asset) - - -def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_asset=False): - """Handle asset creation or update during the split.""" - scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity) - new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset - asset_doc = new_asset if is_new_asset else existing_asset - asset_doc.flags.is_split_asset = True - - set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset) - log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset) - - # Update finance books and depreciation schedules - update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset) - return new_asset - - -def set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset): - asset_doc.net_purchase_amount = existing_asset.net_purchase_amount * scaling_factor - asset_doc.purchase_amount = existing_asset.net_purchase_amount * scaling_factor - asset_doc.additional_asset_cost = existing_asset.additional_asset_cost * scaling_factor - asset_doc.total_asset_cost = asset_doc.net_purchase_amount + asset_doc.additional_asset_cost - asset_doc.opening_accumulated_depreciation = ( - existing_asset.opening_accumulated_depreciation * scaling_factor - ) - asset_doc.value_after_depreciation = existing_asset.value_after_depreciation * scaling_factor - asset_doc.asset_quantity = split_qty - asset_doc.split_from = existing_asset.name if is_new_asset else None - - for row in asset_doc.get("finance_books"): - row.value_after_depreciation = row.value_after_depreciation * scaling_factor - row.expected_value_after_useful_life = row.expected_value_after_useful_life * scaling_factor - - if not is_new_asset: - asset_doc.flags.ignore_validate_update_after_submit = True - asset_doc.save() - - -def log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset): - if is_new_asset: - asset_doc.insert() - add_asset_activity( - asset_doc.name, - _("Asset created after being split from Asset {0}").format( - get_link_to_form("Asset", existing_asset.name) - ), - ) - asset_doc.submit() - asset_doc.set_status() - else: - add_asset_activity( - existing_asset.name, - _("Asset updated after being split into Asset {0}").format( - get_link_to_form("Asset", splitted_asset.name) - ), - ) - - -def update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset): - """Update finance books and depreciation schedules for the asset.""" - for fb_row in asset_doc.get("finance_books"): - reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset) - - # Add references in journal entries for new asset - if is_new_asset: - for row in new_asset.get("finance_books"): - depr_schedule_doc = get_depr_schedule(new_asset.name, "Active", row.finance_book) - for schedule in depr_schedule_doc: - if schedule.journal_entry: - add_reference_in_jv_on_split( - schedule.journal_entry, - new_asset.name, - existing_asset.name, - schedule.depreciation_amount, - ) - - -def reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset): - """Reschedule depreciation for an asset after a split.""" - current_depr_schedule_doc = get_asset_depr_schedule_doc( - existing_asset.name, "Active", fb_row.finance_book - ) - if not current_depr_schedule_doc: - return - - # Create a new depreciation schedule based on the current one - new_depr_schedule_doc = create_new_depr_schedule( - current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row - ) - - update_depreciation_terms(new_depr_schedule_doc, scaling_factor) - add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset) - - if not is_new_asset: - current_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True - current_depr_schedule_doc.cancel() - - new_depr_schedule_doc.submit() - - -def create_new_depr_schedule(current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row): - """Create a new depreciation schedule based on the current one.""" - new_depr_schedule_doc = frappe.copy_doc(current_depr_schedule_doc) - new_depr_schedule_doc.asset_doc = new_asset if is_new_asset else existing_asset - new_depr_schedule_doc.fb_row = fb_row - new_depr_schedule_doc.fetch_asset_details() - return new_depr_schedule_doc - - -def update_depreciation_terms(new_depr_schedule_doc, scaling_factor): - """Update depreciation terms with scaled amounts.""" - accumulated_depreciation = 0 - for term in new_depr_schedule_doc.get("depreciation_schedule"): - depreciation_amount = flt( - term.depreciation_amount * scaling_factor, term.precision("depreciation_amount") - ) - term.depreciation_amount = depreciation_amount - accumulated_depreciation = flt( - accumulated_depreciation + depreciation_amount, term.precision("depreciation_amount") - ) - term.accumulated_depreciation_amount = accumulated_depreciation - - -def add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset): - notes = _("This schedule was created when Asset {0} was {1} into new Asset {2}.").format( - get_link_to_form(existing_asset.doctype, existing_asset.name), - "split" if is_new_asset else "updated after being split", - get_link_to_form(new_asset.doctype, new_asset.name), - ) - new_depr_schedule_doc.notes = notes - - -def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount): - """Add a reference to a new asset in a journal entry after a split.""" - journal_entry = frappe.get_doc("Journal Entry", entry_name) - entries_to_add = [] - - adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add) - add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount) - - # Save and repost the journal entry - journal_entry.flags.ignore_validate_update_after_submit = True - journal_entry.save() - - journal_entry.docstatus = 2 - journal_entry.make_gl_entries(1) - journal_entry.docstatus = 1 - journal_entry.make_gl_entries() - - -def adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add): - """Adjust existing accounts and prepare new entries for the new asset.""" - for account in journal_entry.get("accounts"): - if account.reference_name == old_asset_name: - entries_to_add.append(frappe.copy_doc(account).as_dict()) - adjust_account_balance(account, depreciation_amount) - - -def adjust_account_balance(account, depreciation_amount): - """Adjust the balance of an account based on the depreciation amount.""" - if account.credit: - account.credit -= depreciation_amount - account.credit_in_account_currency -= account.exchange_rate * depreciation_amount - elif account.debit: - account.debit -= depreciation_amount - account.debit_in_account_currency -= account.exchange_rate * depreciation_amount - - -def add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount): - """Add new entries for the new asset to the journal entry.""" - idx = len(journal_entry.get("accounts")) + 1 - for entry in entries_to_add: - entry.reference_name = new_asset_name - if entry.credit: - entry.credit = depreciation_amount - entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount - elif entry.debit: - entry.debit = depreciation_amount - entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount - entry.idx = idx - idx += 1 - journal_entry.append("accounts", entry) diff --git a/erpnext/assets/doctype/asset/mapper.py b/erpnext/assets/doctype/asset/mapper.py new file mode 100644 index 00000000000..282d58a987c --- /dev/null +++ b/erpnext/assets/doctype/asset/mapper.py @@ -0,0 +1,394 @@ +# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint, flt, get_link_to_form + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions +from erpnext.assets.doctype.asset.depreciation import ( + get_depreciation_accounts, + get_disposal_account_and_cost_center, +) +from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, + get_depr_schedule, +) + + +@frappe.whitelist() +def make_sales_invoice(asset: str, item_code: str, company: str, sell_qty: int, serial_no: str | None = None): + asset_doc = frappe.get_doc("Asset", asset) + si = frappe.new_doc("Sales Invoice") + si.company = company + si.currency = frappe.get_cached_value("Company", company, "default_currency") + disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(company) + si.append( + "items", + { + "item_code": item_code, + "is_fixed_asset": 1, + "asset": asset, + "income_account": disposal_account, + "serial_no": serial_no, + "cost_center": depreciation_cost_center, + "qty": sell_qty, + }, + ) + + accounting_dimensions = get_dimensions(with_cost_center_and_project=True) + for dimension in accounting_dimensions[0]: + si.update( + { + dimension["fieldname"]: asset_doc.get(dimension["fieldname"]) + or dimension.get("default_dimension") + } + ) + + si.set_missing_values() + return si + + +@frappe.whitelist() +def create_asset_maintenance( + asset: str, + item_code: str, + item_name: str, + asset_category: str, + company: str, +): + asset_maintenance = frappe.new_doc("Asset Maintenance") + asset_maintenance.update( + { + "asset_name": asset, + "company": company, + "item_code": item_code, + "item_name": item_name, + "asset_category": asset_category, + } + ) + return asset_maintenance + + +@frappe.whitelist() +def create_asset_repair( + company: str, + asset: str, + asset_name: str, +): + asset_repair = frappe.new_doc("Asset Repair") + asset_repair.update({"company": company, "asset": asset, "asset_name": asset_name}) + return asset_repair + + +@frappe.whitelist() +def create_asset_capitalization( + company: str, + asset: str, + asset_name: str, + item_code: str, +): + asset_capitalization = frappe.new_doc("Asset Capitalization") + asset_capitalization.update( + { + "target_asset": asset, + "company": company, + "target_asset_name": asset_name, + "target_item_code": item_code, + } + ) + return asset_capitalization + + +@frappe.whitelist() +def create_asset_value_adjustment( + asset: str, + asset_category: str, + company: str, +): + asset_value_adjustment = frappe.new_doc("Asset Value Adjustment") + asset_value_adjustment.update({"asset": asset, "company": company, "asset_category": asset_category}) + return asset_value_adjustment + + +@frappe.whitelist() +def make_journal_entry(asset_name: str): + asset = frappe.get_doc("Asset", asset_name) + ( + fixed_asset_account, + accumulated_depreciation_account, + depreciation_expense_account, + ) = get_depreciation_accounts(asset.asset_category, asset.company) + + depreciation_cost_center, depreciation_series = frappe.get_cached_value( + "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] + ) + depreciation_cost_center = asset.cost_center or depreciation_cost_center + + je = frappe.new_doc("Journal Entry") + je.voucher_type = "Depreciation Entry" + je.naming_series = depreciation_series + je.company = asset.company + je.remark = _("Depreciation Entry against asset {0}").format(asset_name) + + je.append( + "accounts", + { + "account": depreciation_expense_account, + "reference_type": "Asset", + "reference_name": asset.name, + "cost_center": depreciation_cost_center, + }, + ) + + je.append( + "accounts", + { + "account": accumulated_depreciation_account, + "reference_type": "Asset", + "reference_name": asset.name, + }, + ) + + return je + + +@frappe.whitelist() +def make_asset_movement( + assets: list[dict] | str, + purpose: str = "Transfer", +): + if isinstance(assets, str): + assets = json.loads(assets) + + if len(assets) == 0: + frappe.throw(_("At least one asset has to be selected.")) + + asset_movement = frappe.new_doc("Asset Movement") + asset_movement.purpose = purpose + for asset in assets: + asset = frappe.get_doc("Asset", asset.get("name")) + asset_movement.company = asset.get("company") + asset_movement.append( + "assets", + { + "asset": asset.get("name"), + "source_location": asset.get("location"), + "from_employee": asset.get("custodian"), + }, + ) + + if asset_movement.get("assets"): + return asset_movement.as_dict() + + +@frappe.whitelist() +def split_asset(asset_name: str, split_qty: int): + """Split an asset into two based on the given quantity.""" + existing_asset = frappe.get_doc("Asset", asset_name) + split_qty = cint(split_qty) + + validate_split_quantity(existing_asset, split_qty) + remaining_qty = existing_asset.asset_quantity - split_qty + + splitted_asset = create_new_asset_from_split(existing_asset, split_qty) + update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset) + + return splitted_asset + + +def validate_split_quantity(existing_asset, split_qty): + if split_qty >= existing_asset.asset_quantity: + frappe.throw(_("Split Quantity must be less than Asset Quantity")) + + +def create_new_asset_from_split(existing_asset, split_qty): + """Create a new asset from the split quantity.""" + return process_asset_split(existing_asset, split_qty, is_new_asset=True) + + +def update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset): + """Update the existing asset with the remaining quantity.""" + process_asset_split(existing_asset, remaining_qty, splitted_asset=splitted_asset) + + +def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_asset=False): + """Handle asset creation or update during the split.""" + scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity) + new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset + asset_doc = new_asset if is_new_asset else existing_asset + asset_doc.flags.is_split_asset = True + + set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset) + log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset) + + update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset) + return new_asset + + +def set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset): + asset_doc.net_purchase_amount = existing_asset.net_purchase_amount * scaling_factor + asset_doc.purchase_amount = existing_asset.net_purchase_amount * scaling_factor + asset_doc.additional_asset_cost = existing_asset.additional_asset_cost * scaling_factor + asset_doc.total_asset_cost = asset_doc.net_purchase_amount + asset_doc.additional_asset_cost + asset_doc.opening_accumulated_depreciation = ( + existing_asset.opening_accumulated_depreciation * scaling_factor + ) + asset_doc.value_after_depreciation = existing_asset.value_after_depreciation * scaling_factor + asset_doc.asset_quantity = split_qty + asset_doc.split_from = existing_asset.name if is_new_asset else None + + for row in asset_doc.get("finance_books"): + row.value_after_depreciation = row.value_after_depreciation * scaling_factor + row.expected_value_after_useful_life = row.expected_value_after_useful_life * scaling_factor + + if not is_new_asset: + asset_doc.flags.ignore_validate_update_after_submit = True + asset_doc.save() + + +def log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset): + if is_new_asset: + asset_doc.insert() + add_asset_activity( + asset_doc.name, + _("Asset created after being split from Asset {0}").format( + get_link_to_form("Asset", existing_asset.name) + ), + ) + asset_doc.submit() + asset_doc.set_status() + else: + add_asset_activity( + existing_asset.name, + _("Asset updated after being split into Asset {0}").format( + get_link_to_form("Asset", splitted_asset.name) + ), + ) + + +def update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset): + """Update finance books and depreciation schedules for the asset.""" + for fb_row in asset_doc.get("finance_books"): + reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset) + + if is_new_asset: + for row in new_asset.get("finance_books"): + depr_schedule_doc = get_depr_schedule(new_asset.name, "Active", row.finance_book) + for schedule in depr_schedule_doc: + if schedule.journal_entry: + add_reference_in_jv_on_split( + schedule.journal_entry, + new_asset.name, + existing_asset.name, + schedule.depreciation_amount, + ) + + +def reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset): + """Reschedule depreciation for an asset after a split.""" + current_depr_schedule_doc = get_asset_depr_schedule_doc( + existing_asset.name, "Active", fb_row.finance_book + ) + if not current_depr_schedule_doc: + return + + new_depr_schedule_doc = create_new_depr_schedule( + current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row + ) + + update_depreciation_terms(new_depr_schedule_doc, scaling_factor) + add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset) + + if not is_new_asset: + current_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True + current_depr_schedule_doc.cancel() + + new_depr_schedule_doc.submit() + + +def create_new_depr_schedule(current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row): + """Create a new depreciation schedule based on the current one.""" + new_depr_schedule_doc = frappe.copy_doc(current_depr_schedule_doc) + new_depr_schedule_doc.asset_doc = new_asset if is_new_asset else existing_asset + new_depr_schedule_doc.fb_row = fb_row + new_depr_schedule_doc.fetch_asset_details() + return new_depr_schedule_doc + + +def update_depreciation_terms(new_depr_schedule_doc, scaling_factor): + """Update depreciation terms with scaled amounts.""" + accumulated_depreciation = 0 + for term in new_depr_schedule_doc.get("depreciation_schedule"): + depreciation_amount = flt( + term.depreciation_amount * scaling_factor, term.precision("depreciation_amount") + ) + term.depreciation_amount = depreciation_amount + accumulated_depreciation = flt( + accumulated_depreciation + depreciation_amount, term.precision("depreciation_amount") + ) + term.accumulated_depreciation_amount = accumulated_depreciation + + +def add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset): + notes = _("This schedule was created when Asset {0} was {1} into new Asset {2}.").format( + get_link_to_form(existing_asset.doctype, existing_asset.name), + "split" if is_new_asset else "updated after being split", + get_link_to_form(new_asset.doctype, new_asset.name), + ) + new_depr_schedule_doc.notes = notes + + +def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount): + """Add a reference to a new asset in a journal entry after a split.""" + journal_entry = frappe.get_doc("Journal Entry", entry_name) + entries_to_add = [] + + adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add) + add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount) + + journal_entry.flags.ignore_validate_update_after_submit = True + journal_entry.save() + + journal_entry.docstatus = 2 + journal_entry.make_gl_entries(1) + journal_entry.docstatus = 1 + journal_entry.make_gl_entries() + + +def adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add): + """Adjust existing accounts and prepare new entries for the new asset.""" + for account in journal_entry.get("accounts"): + if account.reference_name == old_asset_name: + entries_to_add.append(frappe.copy_doc(account).as_dict()) + adjust_account_balance(account, depreciation_amount) + + +def adjust_account_balance(account, depreciation_amount): + """Adjust the balance of an account based on the depreciation amount.""" + if account.credit: + account.credit -= depreciation_amount + account.credit_in_account_currency -= account.exchange_rate * depreciation_amount + elif account.debit: + account.debit -= depreciation_amount + account.debit_in_account_currency -= account.exchange_rate * depreciation_amount + + +def add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount): + """Add new entries for the new asset to the journal entry.""" + idx = len(journal_entry.get("accounts")) + 1 + for entry in entries_to_add: + entry.reference_name = new_asset_name + if entry.credit: + entry.credit = depreciation_amount + entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount + elif entry.debit: + entry.debit = depreciation_amount + entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount + entry.idx = idx + idx += 1 + journal_entry.append("accounts", entry) From 516406c25b02616f34d9f023dd95e24582312035 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 13:39:35 +0530 Subject: [PATCH 54/65] fix(purchase_order): re-export get_mapped_purchase_invoice for test compatibility --- erpnext/buying/doctype/purchase_order/purchase_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c57666643ff..5ea6b4384ef 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -29,6 +29,7 @@ from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ) from .mapper import ( + get_mapped_purchase_invoice, make_inter_company_sales_order, make_purchase_invoice, make_purchase_invoice_from_portal, From c324c823fba5676821077a3f704f435d81461a00 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 29 May 2026 16:56:40 +0530 Subject: [PATCH 55/65] fix(purchase_order): re-export get_mapped_subcontracting_order for test compatibility --- erpnext/buying/doctype/purchase_order/purchase_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 5ea6b4384ef..084b725a4cc 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -30,6 +30,7 @@ from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import from .mapper import ( get_mapped_purchase_invoice, + get_mapped_subcontracting_order, make_inter_company_sales_order, make_purchase_invoice, make_purchase_invoice_from_portal, From 498cd2b371115d78f59edd8f9471002f509a1f68 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 31 May 2026 12:52:26 +0530 Subject: [PATCH 56/65] refactor(sales_invoice): extract non-GL services (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the sales_invoice.py monolith into focused service modules under sales_invoice/services/: - fixed_assets.py — FixedAssetService (depreciation, disposal, split) - inter_company.py — validate/link/unlink inter-company docs - loyalty.py — LoyaltyService (earn, redeem, delete points) - pos.py — POSService + POS free functions - status.py — StatusService + is_overdue / get_discounting_status - timesheet_billing.py — TimesheetBillingService Lifecycle hooks (validate/on_submit/on_cancel) call services directly; no thin shims. The 7 methods POS Invoice calls via self.* are kept on the class with an explicit comment. @frappe.whitelist() doc-methods and framework hooks (set_status, set_indicator) stay on the class. sales_invoice.py: 2156 → 1205 lines. All 29 snapshot + 121 SI tests green. Co-Authored-By: Claude Sonnet 4.6 --- .../doctype/sales_invoice/sales_invoice.py | 1203 ++--------------- .../sales_invoice/services/fixed_assets.py | 173 +++ .../sales_invoice/services/inter_company.py | 68 + .../doctype/sales_invoice/services/loyalty.py | 162 +++ .../doctype/sales_invoice/services/pos.py | 396 ++++++ .../doctype/sales_invoice/services/status.py | 130 ++ .../services/timesheet_billing.py | 121 ++ 7 files changed, 1179 insertions(+), 1074 deletions(-) create mode 100644 erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/inter_company.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/loyalty.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/pos.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/status.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 89851d8c16e..eac209cacad 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -5,17 +5,13 @@ import frappe import frappe.utils from frappe import _, msgprint, throw -from frappe.model.document import Document from frappe.query_builder import Case -from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate +from frappe.utils import cint, flt, formatdate, get_link_to_form from frappe.utils.data import comma_and import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date -from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( - get_loyalty_program_details_with_points, - validate_loyalty_points, -) +from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points from erpnext.accounts.doctype.pricing_rule.utils import ( update_coupon_code_count, validate_coupon_code, @@ -25,24 +21,10 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger validate_docs_for_voucher_types, ) from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding -from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import get_due_date, get_party_account -from erpnext.accounts.utils import ( - get_account_currency, - update_voucher_outstanding, -) -from erpnext.assets.doctype.asset.asset import split_asset -from erpnext.assets.doctype.asset.depreciation import ( - depreciate_asset, - get_gl_entries_on_asset_disposal, - get_gl_entries_on_asset_regain, - reset_depreciation_schedule, - reverse_depreciation_entry_made_on_disposal, -) -from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity +from erpnext.accounts.utils import update_voucher_outstanding from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.selling_controller import SellingController -from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.setup.doctype.company.company import update_company_current_month_sales from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so @@ -60,14 +42,35 @@ from .mapper import ( update_taxes, validate_inter_company_transaction, ) +from .services.fixed_assets import FixedAssetService +from .services.inter_company import ( + unlink_inter_company_doc, + update_linked_doc, + validate_inter_company_party, +) +from .services.loyalty import LoyaltyService +from .services.pos import ( + PartialPaymentValidationError, + POSService, + get_all_mode_of_payments, + get_mode_of_payment_info, + get_mode_of_payments_info, + update_multi_mode_option, +) +from .services.pos import ( + get_bank_cash_account as _get_bank_cash_account, +) +from .services.status import ( + StatusService, + get_discounting_status, + get_total_in_party_account_currency, + is_overdue, +) +from .services.timesheet_billing import TimesheetBillingService form_grid_templates = {"items": "templates/form_grid/item_grid.html"} -class PartialPaymentValidationError(frappe.ValidationError): - pass - - class SalesInvoice(SellingController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -285,21 +288,7 @@ class SalesInvoice(SellingController): def set_indicator(self): """Set indicator for portal""" - if self.outstanding_amount < 0: - self.indicator_title = _("Credit Note Issued") - self.indicator_color = "gray" - elif self.outstanding_amount > 0 and getdate(self.due_date) >= getdate(nowdate()): - self.indicator_color = "orange" - self.indicator_title = _("Unpaid") - elif self.outstanding_amount > 0 and getdate(self.due_date) < getdate(nowdate()): - self.indicator_color = "red" - self.indicator_title = _("Overdue") - elif cint(self.is_return) == 1: - self.indicator_title = _("Return") - self.indicator_color = "gray" - else: - self.indicator_color = "green" - self.indicator_title = _("Paid") + StatusService(self).set_indicator() def onload(self): super().onload() @@ -321,15 +310,15 @@ class SalesInvoice(SellingController): SalesTaxWithholding(self).on_validate() self.validate_proj_cust() - self.validate_pos_return() + POSService(self).validate_pos_return() self.validate_with_previous_doc() self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("uom", "qty") self.check_sales_order_on_hold_or_close("sales_order") self.validate_debit_to_acc() self.clear_unallocated_advances("Sales Invoice Advance", "advances") - self.validate_fixed_asset() - self.set_income_account_for_fixed_assets() + FixedAssetService(self).validate_fixed_asset() + FixedAssetService(self).set_income_account_for_fixed_assets() self.validate_item_cost_centers() self.check_conversion_rate() self.validate_accounts() @@ -338,7 +327,6 @@ class SalesInvoice(SellingController): self.doctype, self.customer, self.company, self.inter_company_invoice_reference ) - # Validating coupon code if self.coupon_code: validate_coupon_code(self.coupon_code) @@ -346,8 +334,8 @@ class SalesInvoice(SellingController): self.validate_pos() if cint(self.is_created_using_pos): - self.validate_created_using_pos() - self.validate_full_payment() + POSService(self).validate_created_using_pos() + POSService(self).validate_full_payment() self.validate_dropship_item() @@ -357,10 +345,7 @@ class SalesInvoice(SellingController): self.validate_delivery_note() - is_deferred_invoice = any(d.get("enable_deferred_revenue") for d in self.get("items")) - - # validate service stop date to lie in between start and end date - if is_deferred_invoice: + if any(d.get("enable_deferred_revenue") for d in self.get("items")): validate_service_stop_date(self) if not self.is_opening: @@ -372,7 +357,7 @@ class SalesInvoice(SellingController): frappe.throw(_("Direct return is not allowed for Timesheet.")) if not self.is_return: - self.validate_time_sheets_are_submitted() + TimesheetBillingService(self).validate_time_sheets_are_submitted() from erpnext.accounts.services.billing_validation import BillingValidationService @@ -386,20 +371,19 @@ class SalesInvoice(SellingController): row.billing_amount = -abs(row.billing_amount) self.update_packing_list() - self.set_billing_hours_and_amount() - self.update_timesheet_billing_for_project() + TimesheetBillingService(self).set_billing_hours_and_amount() + TimesheetBillingService(self).update_timesheet_billing_for_project() self.set_status() if self.is_pos and not self.is_return: - self.verify_payment_amount_is_positive() + POSService(self).verify_payment_amount_is_positive() - # validate amount in mode of payments for returned invoices for pos must be negative if self.is_pos and self.is_return: - self.verify_payment_amount_is_negative() + POSService(self).verify_payment_amount_is_negative() if self.redeem_loyalty_points and self.loyalty_points and not self.is_consolidated: validate_loyalty_points(self, self.loyalty_points) - self.allow_write_off_only_on_pos() + POSService(self).allow_write_off_only_on_pos() self.reset_default_field_value("set_warehouse", "items", "warehouse") self.validate_subcontracted_sales_order() self.validate_scio_self_rm_qty() @@ -416,36 +400,6 @@ class SalesInvoice(SellingController): validate_docs_for_voucher_types(["Sales Invoice"]) validate_docs_for_deferred_accounting([self.name], []) - def validate_fixed_asset(self): - if self.doctype != "Sales Invoice": - return - - for d in self.get("items"): - if d.is_fixed_asset: - if d.asset: - if not self.is_return: - asset_status = frappe.db.get_value("Asset", d.asset, "status") - if self.update_stock: - frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) - - elif asset_status in ("Scrapped", "Cancelled", "Capitalized"): - frappe.throw( - _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format( - d.idx, d.asset, asset_status - ) - ) - elif asset_status == "Sold" and not self.is_return: - frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset)) - elif not self.return_against: - frappe.throw( - _("Row #{0}: Return Against is required for returning asset").format(d.idx) - ) - else: - frappe.throw( - _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code), - title=_("Missing Asset"), - ) - def validate_item_cost_centers(self): for item in self.items: item.validate_cost_center(self.company) @@ -455,14 +409,14 @@ class SalesInvoice(SellingController): validate_account_head(item.idx, item.income_account, self.company, _("Income")) def before_save(self): - self.set_account_for_mode_of_payment() - self.set_paid_amount() + POSService(self).set_account_for_mode_of_payment() + POSService(self).set_paid_amount() def before_submit(self): self.add_remarks() def on_submit(self): - self.validate_pos_paid_amount() + POSService(self).validate_pos_paid_amount() if not self.auto_repeat: frappe.get_cached_doc("Authorization Control").validate_approving_authority( @@ -483,8 +437,6 @@ class SalesInvoice(SellingController): self.update_billing_status_in_dn() self.clear_unallocated_mode_of_payments() - # Updating stock ledger should always be called after updating prevdoc status, - # because updating reserved qty in bin depends upon updated delivered qty in SO if self.update_stock == 1: for table_name in ["items", "packed_items"]: if not self.get(table_name): @@ -497,11 +449,9 @@ class SalesInvoice(SellingController): self.update_stock_reservation_entries() self.update_stock_ledger() - self.split_asset_based_on_sale_qty() + FixedAssetService(self).split_asset_based_on_sale_qty() + FixedAssetService(self).process_asset_depreciation() - self.process_asset_depreciation() - - # this sequence because outstanding may get -ve self.make_gl_entries() if self.update_stock == 1: @@ -515,7 +465,9 @@ class SalesInvoice(SellingController): if cint(self.is_pos) != 1 and not self.is_return: self.update_against_document_in_jv() - self.update_time_sheet(None if (self.is_return and self.return_against) else self.name) + TimesheetBillingService(self).update_time_sheet( + None if (self.is_return and self.return_against) else self.name + ) if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) @@ -525,7 +477,6 @@ class SalesInvoice(SellingController): if self.coupon_code: update_coupon_code_count(self.coupon_code, "used") - # create the loyalty point ledger entry if the customer is enrolled in any loyalty program if ( not self.is_return and not self.is_consolidated @@ -535,67 +486,22 @@ class SalesInvoice(SellingController): self.make_loyalty_point_entry() elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) - against_si_doc.delete_loyalty_point_entry() - against_si_doc.make_loyalty_point_entry() + LoyaltyService(against_si_doc).delete_loyalty_point_entry() + LoyaltyService(against_si_doc).make_loyalty_point_entry() if self.redeem_loyalty_points and not self.is_consolidated and self.loyalty_points: self.apply_loyalty_points() self.process_common_party_accounting() self.update_billed_qty_in_scio() - def validate_pos_return(self): - if self.is_consolidated: - # pos return is already validated in pos invoice - return - - if self.is_pos and self.is_return: - total_amount_in_payments = 0 - for payment in self.payments: - total_amount_in_payments += payment.amount - invoice_total = self.rounded_total or self.grand_total - if total_amount_in_payments < invoice_total: - frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total)) - - def validate_pos_paid_amount(self): - if len(self.payments) == 0 and self.is_pos and flt(self.grand_total) > 0: - frappe.throw(_("At least one mode of payment is required for POS invoice.")) - - def check_if_consolidated_invoice(self): - # since POS Invoice extends Sales Invoice, we explicitly check if doctype is Sales Invoice - if self.doctype == "Sales Invoice" and self.is_consolidated: - invoice_or_credit_note = "consolidated_credit_note" if self.is_return else "consolidated_invoice" - pos_closing_entry = frappe.get_all( - "POS Invoice Merge Log", - filters={invoice_or_credit_note: self.name}, - pluck="pos_closing_entry", - ) - if pos_closing_entry and pos_closing_entry[0]: - msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( - frappe.bold(_("Consolidated Sales Invoice")), - get_link_to_form("POS Closing Entry", pos_closing_entry[0]), - ) - frappe.throw(msg, title=_("Not Allowed")) - - def check_if_created_using_pos_and_pos_closing_entry_generated(self): - if self.doctype == "Sales Invoice" and self.is_created_using_pos and self.pos_closing_entry: - pos_closing_entry_docstatus = frappe.db.get_value( - "POS Closing Entry", self.pos_closing_entry, "docstatus" - ) - if pos_closing_entry_docstatus == 1: - frappe.throw( - msg=_("To cancel this Sales Invoice you need to cancel the POS Closing Entry {}.").format( - get_link_to_form("POS Closing Entry", self.pos_closing_entry) - ), - title=_("Not Allowed"), - ) - def before_cancel(self): - # check if generated via POS and already included in POS Closing Entry - self.check_if_created_using_pos_and_pos_closing_entry_generated() - self.check_if_consolidated_invoice() + POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated() + POSService(self).check_if_consolidated_invoice() super().before_cancel() - self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None) + TimesheetBillingService(self).update_time_sheet( + self.return_against if (self.is_return and self.return_against) else None + ) def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) @@ -616,13 +522,11 @@ class SalesInvoice(SellingController): self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Sales Order") - # Updating stock ledger should always be called after updating prevdoc status, - # because updating reserved qty in bin depends upon updated delivered qty in SO SalesTaxWithholding(self).on_cancel() if self.update_stock == 1: self.update_stock_ledger() - self.process_asset_depreciation() + FixedAssetService(self).process_asset_depreciation() self.make_gl_entries_on_cancel() @@ -638,16 +542,17 @@ class SalesInvoice(SellingController): if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) self.update_project() + if not self.is_return and not self.is_consolidated and self.loyalty_program: self.delete_loyalty_point_entry() elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) - against_si_doc.delete_loyalty_point_entry() - against_si_doc.make_loyalty_point_entry() + LoyaltyService(against_si_doc).delete_loyalty_point_entry() + LoyaltyService(against_si_doc).make_loyalty_point_entry() unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) - self.unlink_sales_invoice_from_timesheets() + TimesheetBillingService(self).unlink_sales_invoice_from_timesheets() self.ignore_linked_doctypes = ( "GL Entry", "Stock Ledger Entry", @@ -672,7 +577,7 @@ class SalesInvoice(SellingController): and self.is_created_using_pos and not self.pos_closing_entry ): - self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode() + POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode() self.update_billed_qty_in_scio() @@ -740,25 +645,9 @@ class SalesInvoice(SellingController): if validate_against_credit_limit: check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order) - def unlink_sales_invoice_from_timesheets(self): - for row in self.timesheets: - timesheet = frappe.get_doc("Timesheet", row.time_sheet) - timesheet.unlink_sales_invoice(self.name) - timesheet.flags.ignore_validate_update_after_submit = True - timesheet.db_update_all() - - def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self): - pos_invoices = frappe.get_all( - "POS Invoice", filters={"consolidated_invoice": self.name}, pluck="name" - ) - if pos_invoices: - for pos_invoice in pos_invoices: - pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice) - pos_invoice_doc.cancel() - @frappe.whitelist() def set_missing_values(self, for_validate: bool = False): - pos = self.set_pos_fields(for_validate) + pos = POSService(self).set_pos_fields(for_validate) if not self.debit_to: self.debit_to = get_party_account("Customer", self.customer, self.company) @@ -792,221 +681,29 @@ class SalesInvoice(SellingController): "set_default_payment": pos.get("set_grand_total_to_default_mop", 1), } + # Called by POS Invoice + def set_pos_fields(self, for_validate=False): + return POSService(self).set_pos_fields(for_validate) + @frappe.whitelist() def reset_mode_of_payments(self): - if self.pos_profile: - pos_profile = frappe.get_cached_doc("POS Profile", self.pos_profile) - update_multi_mode_option(self, pos_profile) - self.paid_amount = 0 - - def update_time_sheet(self, sales_invoice): - for d in self.timesheets: - if d.time_sheet: - timesheet = frappe.get_doc("Timesheet", d.time_sheet) - self.update_time_sheet_detail(timesheet, d, sales_invoice) - timesheet.calculate_total_amounts() - timesheet.calculate_percentage_billed() - timesheet.flags.ignore_validate_update_after_submit = True - timesheet.set_status() - timesheet.db_update_all() - - def update_billed_qty_in_scio(self): - if self.is_return: - return - - table = frappe.qb.DocType("Subcontracting Inward Order Received Item") - data = frappe._dict( - { - item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty - for item in self.items - if item.scio_detail - } - ) - - if data: - case_expr = Case() - for name, qty in data.items(): - case_expr = case_expr.when(table.name == name, table.billed_qty + qty) - frappe.qb.update(table).set(table.billed_qty, case_expr).where( - (table.name.isin(list(data.keys()))) & (table.docstatus == 1) - ).run() - - def update_time_sheet_detail(self, timesheet, args, sales_invoice): - for data in timesheet.time_logs: - if ( - (self.project and args.timesheet_detail == data.name) - or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name) - or ( - not sales_invoice - and data.sales_invoice == self.name - and args.timesheet_detail == data.name - ) - or ( - self.is_return - and self.return_against - and data.sales_invoice - and data.sales_invoice == self.return_against - and not sales_invoice - and args.timesheet_detail == data.name - ) - ): - data.sales_invoice = sales_invoice - - def on_update_after_submit(self): - fields_to_check = [ - "additional_discount_account", - "cash_bank_account", - "account_for_change_amount", - "write_off_account", - "loyalty_redemption_account", - "unrealized_profit_loss_account", - "is_opening", - ] - child_tables = { - "items": ("income_account", "expense_account", "discount_account"), - "taxes": ("account_head",), - } - self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables) - if self.needs_repost: - self.validate_for_repost() - self.repost_accounting_entries() - - def set_paid_amount(self): - paid_amount = 0.0 - base_paid_amount = 0.0 - for data in self.payments: - data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount")) - paid_amount += data.amount - base_paid_amount += data.base_amount - - self.paid_amount = paid_amount - self.base_paid_amount = base_paid_amount + POSService(self).reset_mode_of_payments() @frappe.whitelist() def set_account_for_mode_of_payment(self): - for payment in self.payments: - payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") + POSService(self).set_account_for_mode_of_payment() - def validate_time_sheets_are_submitted(self): - # Note: This validation is skipped for return invoices - # to allow returns to reference already-billed timesheet details - for data in self.timesheets: - # Handle invoice duplication - if data.time_sheet and data.timesheet_detail: - if sales_invoice := frappe.db.get_value( - "Timesheet Detail", data.timesheet_detail, "sales_invoice" - ): - frappe.throw( - _("Row {0}: Sales Invoice {1} is already created for {2}").format( - data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) - ) - ) + # Called by POS Invoice + def validate_pos(self): + POSService(self).validate_pos() - if data.time_sheet: - status = frappe.db.get_value("Timesheet", data.time_sheet, "status") - if status not in ["Submitted", "Payslip", "Partially Billed"]: - frappe.throw( - _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) - ) + # Called by POS Invoice + def validate_pos_opening_entry(self): + POSService(self).validate_pos_opening_entry() - def set_pos_fields(self, for_validate=False): - """Set retail related fields from POS Profiles""" - if cint(self.is_pos) != 1: - return - - if not self.account_for_change_amount: - self.account_for_change_amount = frappe.get_cached_value( - "Company", self.company, "default_cash_account" - ) - - from erpnext.stock.get_item_details import ( - ItemDetailsCtx, - get_pos_profile, - get_pos_profile_item_details_, - ) - - if not self.pos_profile and not self.flags.ignore_pos_profile: - pos_profile = get_pos_profile(self.company) or {} - if not pos_profile: - return - self.pos_profile = pos_profile.get("name") - - pos = {} - if self.pos_profile: - pos = frappe.get_doc("POS Profile", self.pos_profile) - - if pos: - if not for_validate: - update_multi_mode_option(self, pos) - self.tax_category = pos.get("tax_category") - - if not for_validate and not self.customer: - self.customer = pos.customer - - if not for_validate: - self.ignore_pricing_rule = pos.ignore_pricing_rule - - if pos.get("account_for_change_amount"): - self.account_for_change_amount = pos.get("account_for_change_amount") - - for fieldname in ( - "currency", - "letter_head", - "tc_name", - "company", - "select_print_heading", - "write_off_account", - "taxes_and_charges", - "write_off_cost_center", - "apply_discount_on", - "cost_center", - ): - if (not for_validate) or (for_validate and not self.get(fieldname)): - self.set(fieldname, pos.get(fieldname)) - - if pos.get("company_address"): - self.company_address = pos.get("company_address") - - if self.customer: - customer_price_list, customer_group = frappe.get_value( - "Customer", self.customer, ["default_price_list", "customer_group"] - ) - customer_group_price_list = frappe.get_value( - "Customer Group", customer_group, "default_price_list" - ) - selling_price_list = ( - customer_price_list or customer_group_price_list or pos.get("selling_price_list") - ) - else: - selling_price_list = pos.get("selling_price_list") - - if selling_price_list: - self.set("selling_price_list", selling_price_list) - - if not for_validate: - self.update_stock = cint(pos.get("update_stock")) - - # set pos values in items - for item in self.get("items"): - if item.get("item_code"): - profile_details = get_pos_profile_item_details_( - ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True - ) - for fname, val in profile_details.items(): - if (not for_validate) or (for_validate and not item.get(fname)): - item.set(fname, val) - - # fetch terms - if self.tc_name and not self.terms: - self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms") - - # fetch charges - if self.taxes_and_charges and not len(self.get("taxes")): - from erpnext.accounts.services.taxes import TaxService - - TaxService(self).set_taxes() - - return pos + # Called by POS Invoice + def clear_unallocated_mode_of_payments(self): + POSService(self).clear_unallocated_mode_of_payments() def get_company_abbr(self): return frappe.db.sql("select abbr from tabCompany where name=%s", self.company)[0][0] @@ -1046,15 +743,6 @@ class SalesInvoice(SellingController): self.party_account_currency = account.account_currency - def clear_unallocated_mode_of_payments(self): - self.set("payments", self.get("payments", {"amount": ["not in", [0, None, ""]]})) - - frappe.db.sql( - """delete from `tabSales Invoice Payment` where parent = %s - and amount = 0""", - self.name, - ) - def validate_with_previous_doc(self): super().validate_with_previous_doc( { @@ -1120,7 +808,6 @@ class SalesInvoice(SellingController): self.remarks += " " + _("dated {0}").format(formatdate(self.po_date)) def validate_auto_set_posting_time(self): - # Don't auto set the posting date and time if invoice is amended if self.is_new() and self.amended_from: self.set_posting_time = 1 @@ -1157,68 +844,6 @@ class SalesInvoice(SellingController): if not res: throw(_("Customer {0} does not belong to project {1}").format(self.customer, self.project)) - def validate_pos(self): - if self.is_return: - invoice_total = self.rounded_total or self.grand_total - if abs(flt(self.paid_amount)) + abs(flt(self.write_off_amount)) - abs( - flt(invoice_total) - ) > 1.0 / (10.0 ** (self.precision("grand_total") + 1.0)): - frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) - - def validate_created_using_pos(self): - if self.is_created_using_pos and not self.pos_profile: - frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction.")) - - self.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type") - if self.invoice_type_in_pos == "POS Invoice" and not self.is_return: - frappe.throw(_("Transactions using Sales Invoice in POS are disabled.")) - - self.validate_pos_opening_entry() - - def validate_full_payment(self): - allow_partial_payment = frappe.db.get_value("POS Profile", self.pos_profile, "allow_partial_payment") - invoice_total = flt(self.rounded_total) or flt(self.grand_total) - - if ( - self.docstatus == 1 - and not self.is_return - and not allow_partial_payment - and self.paid_amount < invoice_total - ): - frappe.throw( - msg=_("Partial Payment in POS Transactions are not allowed."), - exc=PartialPaymentValidationError, - ) - - def validate_pos_opening_entry(self): - opening_entries = frappe.get_all( - "POS Opening Entry", - fields=["name", "period_start_date"], - filters={"pos_profile": self.pos_profile, "status": "Open"}, - order_by="period_start_date desc", - ) - if not opening_entries: - frappe.throw( - title=_("POS Opening Entry Missing"), - msg=_("No open POS Opening Entry found for POS Profile {0}.").format( - frappe.bold(self.pos_profile) - ), - ) - if len(opening_entries) > 1: - frappe.throw( - title=_("Multiple POS Opening Entry"), - msg=_( - "POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding." - ).format(self.pos_profile), - ) - if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today(): - frappe.throw( - title=_("Outdated POS Opening Entry"), - msg=_( - "POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry." - ).format(opening_entries[0].get("name")), - ) - def validate_warehouse(self): super().validate_warehouse() @@ -1243,10 +868,6 @@ class SalesInvoice(SellingController): ), ) - def allow_write_off_only_on_pos(self): - if not self.is_pos and self.write_off_account: - self.write_off_account = None - def validate_subcontracted_sales_order(self): if self.has_subcontracted: if [item for item in self.items if not item.sales_order and not item.scio_detail]: @@ -1327,82 +948,13 @@ class SalesInvoice(SellingController): else: self.set("packed_items", []) - def set_billing_hours_and_amount(self): - if not self.project: - for timesheet in self.timesheets: - ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet) - if not timesheet.billing_hours and ts_doc.total_billable_hours: - timesheet.billing_hours = ts_doc.total_billable_hours - - if not timesheet.billing_amount and ts_doc.total_billable_amount: - timesheet.billing_amount = ts_doc.total_billable_amount - - def update_timesheet_billing_for_project(self): - if ( - not self.is_return - and not self.timesheets - and self.project - and self.is_auto_fetch_timesheet_enabled() - ): - self.add_timesheet_data() - else: - self.calculate_billing_amount_for_timesheet() - @frappe.whitelist() def is_auto_fetch_timesheet_enabled(self): return frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice") @frappe.whitelist() def add_timesheet_data(self): - self.set("timesheets", []) - if self.project: - for data in get_projectwise_timesheet_data(self.project): - self.append( - "timesheets", - { - "time_sheet": data.time_sheet, - "billing_hours": data.billing_hours, - "billing_amount": data.billing_amount, - "timesheet_detail": data.name, - "activity_type": data.activity_type, - "description": data.description, - }, - ) - - self.calculate_billing_amount_for_timesheet() - - def calculate_billing_amount_for_timesheet(self): - def timesheet_sum(field): - return sum((ts.get(field) or 0.0) for ts in self.timesheets) - - self.total_billing_amount = timesheet_sum("billing_amount") - self.total_billing_hours = timesheet_sum("billing_hours") - - def get_warehouse(self): - user_pos_profile = frappe.db.sql( - """select name, warehouse from `tabPOS Profile` - where ifnull(user,'') = %s and company = %s""", - (frappe.session["user"], self.company), - ) - warehouse = user_pos_profile[0][1] if user_pos_profile else None - - if not warehouse: - global_pos_profile = frappe.db.sql( - """select name, warehouse from `tabPOS Profile` - where (user is null or user = '') and company = %s""", - self.company, - ) - - if global_pos_profile: - warehouse = global_pos_profile[0][1] - elif not user_pos_profile: - msgprint(_("POS Profile required to make POS Entry"), raise_exception=True) - - return warehouse - - def set_income_account_for_fixed_assets(self): - for item in self.items: - item.set_income_account_for_fixed_asset(self.company) + TimesheetBillingService(self).add_timesheet_data() def check_prev_docstatus(self): for d in self.get("items"): @@ -1418,138 +970,6 @@ class SalesInvoice(SellingController): ): throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) - def split_asset_based_on_sale_qty(self): - asset_qty_map = self.get_asset_qty() - for asset, qty in asset_qty_map.items(): - if qty["actual_qty"] < qty["sale_qty"]: - frappe.throw( - _( - "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)." - ).format(asset, qty["actual_qty"]) - ) - - remaining_qty = qty["actual_qty"] - qty["sale_qty"] - if remaining_qty > 0: - split_asset(asset, remaining_qty) - - def get_asset_qty(self): - asset_qty_map = {} - - assets = {row.asset for row in self.items if row.is_fixed_asset and row.asset} - if not assets or self.is_return: - return asset_qty_map - - asset_actual_qty = dict( - frappe.db.get_all( - "Asset", - {"name": ["in", list(assets)]}, - ["name", "asset_quantity"], - as_list=True, - ) - ) - for row in self.items: - if row.is_fixed_asset and row.asset: - actual_qty = asset_actual_qty.get(row.asset) - if row.asset in asset_qty_map.keys(): - asset_qty_map[row.asset]["sale_qty"] += flt(row.qty) - else: - asset_qty_map.setdefault( - row.asset, - { - "sale_qty": flt(row.qty), - "actual_qty": flt(actual_qty), - }, - ) - - return asset_qty_map - - def process_asset_depreciation(self): - if self.is_internal_transfer(): - return - - if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1): - self.depreciate_asset_on_sale() - else: - self.restore_asset() - - self.update_asset() - - def depreciate_asset_on_sale(self): - """ - Depreciate asset on sale or cancellation of return sales invoice - """ - disposal_date = self.get_disposal_date() - for d in self.get("items"): - if d.asset: - asset = frappe.get_doc("Asset", d.asset) - if asset.calculate_depreciation and asset.status != "Fully Depreciated": - depreciate_asset(asset, disposal_date, self.get_note_for_asset_sale(asset)) - - def get_note_for_asset_sale(self, asset): - return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format( - get_link_to_form(asset.doctype, asset.name), - _("returned") if self.is_return else _("sold"), - get_link_to_form(self.doctype, self.get("name")), - ) - - def restore_asset(self): - """ - Restore asset on return or cancellation of original sales invoice - """ - - for d in self.get("items"): - if d.asset: - asset = frappe.get_cached_doc("Asset", d.asset) - if asset.calculate_depreciation: - reverse_depreciation_entry_made_on_disposal(asset) - - note = self.get_note_for_asset_return(asset) - reset_depreciation_schedule(asset, note) - - def get_note_for_asset_return(self, asset): - asset_link = get_link_to_form(asset.doctype, asset.name) - invoice_link = get_link_to_form(self.doctype, self.get("name")) - if self.is_return: - return _( - "This schedule was created when Asset {0} was returned through Sales Invoice {1}." - ).format(asset_link, invoice_link) - else: - return _( - "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation." - ).format(asset_link, invoice_link) - - def update_asset(self): - """ - Update asset status, disposal date and asset activity on sale or return sales invoice - """ - - def _update_asset(asset, disposal_date, note, asset_status=None): - frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date) - add_asset_activity(asset.name, note) - asset.set_status(asset_status) - - disposal_date = self.get_disposal_date() - for d in self.get("items"): - if d.asset: - asset = frappe.get_cached_doc("Asset", d.asset) - - if (self.is_return and self.docstatus == 1) or (not self.is_return and self.docstatus == 2): - note = _("Asset returned") if self.is_return else _("Asset sold") - asset_status, disposal_date = None, None - else: - note = _("Asset sold") if not self.is_return else _("Return invoice of asset cancelled") - asset_status = "Sold" - - _update_asset(asset, disposal_date, note, asset_status) - - def get_disposal_date(self): - if self.is_return: - disposal_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") - else: - disposal_date = self.posting_date - - return disposal_date - def make_gl_entries(self, gl_entries=None, from_repost=False): from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries @@ -1558,7 +978,6 @@ class SalesInvoice(SellingController): gl_entries = self.get_gl_entries() if gl_entries: - # if POS and amount is written off, updating outstanding amt after posting all gl entries update_outstanding = ( "No" if (cint(self.is_pos) or self.write_off_account or cint(self.redeem_loyalty_points)) @@ -1646,194 +1065,60 @@ class SalesInvoice(SellingController): project.calculate_gross_margin() project.db_update() - def verify_payment_amount_is_positive(self): - for entry in self.payments: - if entry.amount < 0: - frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx)) - - def verify_payment_amount_is_negative(self): - for entry in self.payments: - if entry.amount > 0: - frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx)) - - # collection of the loyalty points, create the ledger entry for that. - def make_loyalty_point_entry(self): - returned_amount = self.get_returned_amount() - current_amount = flt(self.grand_total) - cint(self.loyalty_amount) - eligible_amount = current_amount - returned_amount - lp_details = get_loyalty_program_details_with_points( - self.customer, - company=self.company, - current_transaction_amount=current_amount, - loyalty_program=self.loyalty_program, - expiry_date=self.posting_date, - include_expired_entry=True, - ) - if ( - lp_details - and getdate(lp_details.from_date) <= getdate(self.posting_date) - and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(self.posting_date)) - ): - collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0 - points_earned = cint(eligible_amount / collection_factor) - - doc = frappe.get_doc( - { - "doctype": "Loyalty Point Entry", - "company": self.company, - "loyalty_program": lp_details.loyalty_program, - "loyalty_program_tier": lp_details.tier_name, - "customer": self.customer, - "invoice_type": self.doctype, - "invoice": self.name, - "loyalty_points": points_earned, - "purchase_amount": eligible_amount, - "expiry_date": add_days(self.posting_date, lp_details.expiry_duration), - "posting_date": self.posting_date, - } - ) - doc.flags.ignore_permissions = 1 - doc.save() - self.set_loyalty_program_tier() - - # valdite the redemption and then delete the loyalty points earned on cancel of the invoice - def delete_loyalty_point_entry(self): - lp_entry = frappe.db.sql( - "select name from `tabLoyalty Point Entry` where invoice=%s", (self.name), as_dict=1 - ) - - if not lp_entry: + def update_billed_qty_in_scio(self): + if self.is_return: return - against_lp_entry = frappe.db.sql( - """select name, invoice from `tabLoyalty Point Entry` - where redeem_against=%s""", - (lp_entry[0].name), - as_dict=1, + + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + data = frappe._dict( + { + item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty + for item in self.items + if item.scio_detail + } ) - if against_lp_entry: - invoice_list = ", ".join([d.invoice for d in against_lp_entry]) - frappe.throw( - _( - """{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}""" - ).format(self.doctype, self.doctype, invoice_list) - ) - else: - frappe.db.sql("""delete from `tabLoyalty Point Entry` where invoice=%s""", (self.name)) - # Set loyalty program - self.set_loyalty_program_tier() - def set_loyalty_program_tier(self): - lp_details = get_loyalty_program_details_with_points( - self.customer, - company=self.company, - loyalty_program=self.loyalty_program, - include_expired_entry=True, - ) - customer = frappe.get_doc("Customer", self.customer) - customer.db_set("loyalty_program_tier", lp_details.tier_name) + if data: + case_expr = Case() + for name, qty in data.items(): + case_expr = case_expr.when(table.name == name, table.billed_qty + qty) + frappe.qb.update(table).set(table.billed_qty, case_expr).where( + (table.name.isin(list(data.keys()))) & (table.docstatus == 1) + ).run() - def get_returned_amount(self): - from frappe.query_builder.functions import Sum + def on_update_after_submit(self): + fields_to_check = [ + "additional_discount_account", + "cash_bank_account", + "account_for_change_amount", + "write_off_account", + "loyalty_redemption_account", + "unrealized_profit_loss_account", + "is_opening", + ] + child_tables = { + "items": ("income_account", "expense_account", "discount_account"), + "taxes": ("account_head",), + } + self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables) + if self.needs_repost: + self.validate_for_repost() + self.repost_accounting_entries() - doc = frappe.qb.DocType(self.doctype) - returned_amount = ( - frappe.qb.from_(doc) - .select(Sum(doc.grand_total)) - .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.name)) - ).run() + # Called by POS Invoice + def make_loyalty_point_entry(self): + LoyaltyService(self).make_loyalty_point_entry() - return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 + # Called by POS Invoice + def delete_loyalty_point_entry(self): + LoyaltyService(self).delete_loyalty_point_entry() - # redeem the loyalty points. + # Called by POS Invoice def apply_loyalty_points(self): - from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import ( - get_loyalty_point_entries, - get_redemption_details, - ) - - loyalty_point_entries = get_loyalty_point_entries( - self.customer, self.loyalty_program, self.company, self.posting_date - ) - redemption_details = get_redemption_details(self.customer, self.loyalty_program, self.company) - - points_to_redeem = self.loyalty_points - for lp_entry in loyalty_point_entries: - if lp_entry.invoice_type != self.doctype or lp_entry.invoice == self.name: - # redeemption should be done against same doctype - # also it shouldn't be against itself - continue - available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name)) - if available_points > points_to_redeem: - redeemed_points = points_to_redeem - else: - redeemed_points = available_points - doc = frappe.get_doc( - { - "doctype": "Loyalty Point Entry", - "company": self.company, - "loyalty_program": self.loyalty_program, - "loyalty_program_tier": lp_entry.loyalty_program_tier, - "customer": self.customer, - "invoice_type": self.doctype, - "invoice": self.name, - "redeem_against": lp_entry.name, - "loyalty_points": -1 * redeemed_points, - "purchase_amount": self.grand_total, - "expiry_date": lp_entry.expiry_date, - "posting_date": self.posting_date, - } - ) - doc.flags.ignore_permissions = 1 - doc.save() - points_to_redeem -= redeemed_points - if points_to_redeem < 1: # since points_to_redeem is integer - break + LoyaltyService(self).apply_loyalty_points() def set_status(self, update=False, status=None, update_modified=True): - if self.is_new(): - if self.get("amended_from"): - self.status = "Draft" - return - - outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount")) - total = get_total_in_party_account_currency(self) - - if not status: - if self.docstatus == 2: - status = "Cancelled" - elif self.docstatus == 1: - if self.is_internal_transfer(): - self.status = "Internal Transfer" - elif is_overdue(self, total): - self.status = "Overdue" - elif 0 < outstanding_amount < total: - self.status = "Partly Paid" - elif outstanding_amount > 0 and getdate(self.due_date) >= getdate(): - self.status = "Unpaid" - # Check if outstanding amount is 0 due to credit note issued against invoice - elif self.is_return == 0 and frappe.db.get_value( - "Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1} - ): - self.status = "Credit Note Issued" - elif self.is_return == 1: - self.status = "Return" - elif outstanding_amount <= 0: - self.status = "Paid" - else: - self.status = "Submitted" - - if ( - self.status in ("Unpaid", "Partly Paid", "Overdue") - and self.is_discounted - and get_discounting_status(self.name) == "Disbursed" - ): - self.status += " and Discounted" - - else: - self.status = "Draft" - - if update: - self.db_set("status", self.status, update_modified=update_modified) + StatusService(self).set_status(update, status, update_modified) @frappe.whitelist() def is_subcontracted(self): @@ -1853,129 +1138,6 @@ class SalesInvoice(SellingController): return self.has_subcontracted -def get_total_in_party_account_currency(doc): - total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total" - if doc.party_account_currency != doc.currency: - total_fieldname = "base_" + total_fieldname - - return flt(doc.get(total_fieldname), doc.precision(total_fieldname)) - - -def is_overdue(doc, total): - outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) - if outstanding_amount <= 0: - return - - today = getdate() - if doc.get("is_pos") or not doc.get("payment_schedule"): - return getdate(doc.due_date) < today - - # calculate payable amount till date - payment_amount_field = ( - "base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount" - ) - - payable_amount = flt( - sum( - payment.get(payment_amount_field) - for payment in doc.payment_schedule - if getdate(payment.due_date) < today - ), - doc.precision("outstanding_amount"), - ) - - return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount - - -def get_discounting_status(sales_invoice): - status = None - - invoice_discounting_list = frappe.db.sql( - """ - select status - from `tabInvoice Discounting` id, `tabDiscounted Invoice` d - where - id.name = d.parent - and d.sales_invoice=%s - and id.docstatus=1 - and status in ('Disbursed', 'Settled') - """, - sales_invoice, - ) - - for d in invoice_discounting_list: - status = d[0] - if status == "Disbursed": - break - - return status - - -def validate_inter_company_party(doctype, party, company, inter_company_reference): - if not party: - return - - if doctype in ["Sales Invoice", "Sales Order"]: - partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer" - - if doctype == "Sales Invoice": - ref_doc = "Purchase Invoice" - else: - ref_doc = "Purchase Order" - else: - partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier" - - if doctype == "Purchase Invoice": - ref_doc = "Sales Invoice" - else: - ref_doc = "Sales Order" - - if inter_company_reference: - doc = frappe.get_doc(ref_doc, inter_company_reference) - ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer - if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party: - frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype))) - if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company: - frappe.throw(_("Invalid Company for Inter Company Transaction.")) - - elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party: - companies = frappe.get_all( - "Allowed To Transact With", - fields=["company"], - filters={"parenttype": partytype, "parent": party}, - ) - companies = [d.company for d in companies] - if company not in companies: - frappe.throw( - _( - "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record." - ).format(_(partytype), company) - ) - - -def update_linked_doc(doctype, name, inter_company_reference): - if doctype in ["Sales Invoice", "Purchase Invoice"]: - ref_field = "inter_company_invoice_reference" - else: - ref_field = "inter_company_order_reference" - - if inter_company_reference: - frappe.db.set_value(doctype, inter_company_reference, ref_field, name) - - -def unlink_inter_company_doc(doctype, name, inter_company_reference): - if doctype in ["Sales Invoice", "Purchase Invoice"]: - ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice" - ref_field = "inter_company_invoice_reference" - else: - ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order" - ref_field = "inter_company_order_reference" - - if inter_company_reference: - frappe.db.set_value(doctype, name, ref_field, "") - frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "") - - def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -1992,134 +1154,27 @@ def get_list_context(context=None): return list_context -@frappe.whitelist() -def get_bank_cash_account(mode_of_payment: str, company: str): - account = frappe.db.get_value( - "Mode of Payment Account", {"parent": mode_of_payment, "company": company}, "default_account" - ) - if not account: - frappe.throw( - _("Please set default Cash or Bank account in Mode of Payment {0}").format( - get_link_to_form("Mode of Payment", mode_of_payment) - ), - title=_("Missing Account"), - ) - return {"account": account} - - @erpnext.allow_regional def make_regional_gl_entries(gl_entries, doc): return gl_entries @frappe.whitelist() -def get_loyalty_programs(customer: str): - """sets applicable loyalty program to the customer or returns a list of applicable programs""" - from erpnext.selling.doctype.customer.customer import get_loyalty_programs - - customer = frappe.get_doc("Customer", customer) - if customer.loyalty_program: - return [customer.loyalty_program] - - lp_details = get_loyalty_programs(customer) - - if len(lp_details) == 1: - customer.db_set("loyalty_program", lp_details[0]) - return lp_details - else: - return lp_details +def get_bank_cash_account(mode_of_payment: str, company: str) -> dict: + return _get_bank_cash_account(mode_of_payment, company) -def update_multi_mode_option(doc, pos_profile): - def append_payment(payment_mode): - payment = doc.append("payments", {}) - payment.default = payment_mode.default - payment.mode_of_payment = payment_mode.mop - payment.account = payment_mode.default_account - payment.type = payment_mode.type +@frappe.whitelist() +def get_loyalty_programs(customer: str) -> list: + from .services.loyalty import get_loyalty_programs as _get - mop_refetched = bool(doc.payments) and not doc.is_created_using_pos - - doc.set("payments", []) - invalid_modes = [] - mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")] - mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company) - - for row in pos_profile.get("payments"): - payment_mode = mode_of_payments_info.get(row.mode_of_payment) - if not payment_mode: - invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment)) - continue - - payment_mode.default = row.default - append_payment(payment_mode) - - if invalid_modes: - if invalid_modes == 1: - msg = _("Please set default Cash or Bank account in Mode of Payment {}") - else: - msg = _("Please set default Cash or Bank account in Mode of Payments {}") - frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) - - if mop_refetched: - frappe.toast( - _("Payment methods refreshed. Please review before proceeding."), - indicator="orange", - ) - - -def get_all_mode_of_payments(doc): - return frappe.db.sql( - """ - select mpa.default_account, mpa.parent, mp.type as type - from `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""", - {"company": doc.company}, - as_dict=1, - ) - - -def get_mode_of_payments_info(mode_of_payments, company): - data = frappe.db.sql( - """ - select - mpa.default_account, mpa.parent as mop, mp.type as type - from - `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where - mpa.parent = mp.name and - mpa.company = %s and - mp.enabled = 1 and - mp.name in %s - group by - mp.name - """, - (company, mode_of_payments), - as_dict=1, - ) - - return {row.get("mop"): row for row in data} - - -def get_mode_of_payment_info(mode_of_payment, company): - return frappe.db.sql( - """ - select mpa.default_account, mpa.parent, mp.type as type - from `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", - (company, mode_of_payment), - as_dict=1, - ) + return _get(customer) def check_if_return_invoice_linked_with_payment_entry(self): - # If a Return invoice is linked with payment entry along with other invoices, - # the cancellation of the Return causes allocated amount to be greater than paid - if not frappe.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): return - payment_entries = [] if self.is_return and self.return_against: invoice = self.return_against else: diff --git a/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py new file mode 100644 index 00000000000..3b793085304 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py @@ -0,0 +1,173 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Fixed asset lifecycle helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import flt, get_link_to_form + +from erpnext.assets.doctype.asset.asset import split_asset +from erpnext.assets.doctype.asset.depreciation import ( + depreciate_asset, + reset_depreciation_schedule, + reverse_depreciation_entry_made_on_disposal, +) +from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity + + +class FixedAssetService: + def __init__(self, doc): + self.doc = doc + + def validate_fixed_asset(self) -> None: + doc = self.doc + if doc.doctype != "Sales Invoice": + return + + for d in doc.get("items"): + if not d.is_fixed_asset: + continue + + if d.asset: + if not doc.is_return: + asset_status = frappe.db.get_value("Asset", d.asset, "status") + if doc.update_stock: + frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) + elif asset_status in ("Scrapped", "Cancelled", "Capitalized"): + frappe.throw( + _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format( + d.idx, d.asset, asset_status + ) + ) + elif asset_status == "Sold" and not doc.is_return: + frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset)) + elif not doc.return_against: + frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx)) + else: + frappe.throw( + _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code), + title=_("Missing Asset"), + ) + + def set_income_account_for_fixed_assets(self) -> None: + for item in self.doc.items: + item.set_income_account_for_fixed_asset(self.doc.company) + + def process_asset_depreciation(self) -> None: + doc = self.doc + if doc.is_internal_transfer(): + return + + if (doc.is_return and doc.docstatus == 2) or (not doc.is_return and doc.docstatus == 1): + self._depreciate_asset_on_sale() + else: + self._restore_asset() + + self._update_asset() + + def split_asset_based_on_sale_qty(self) -> None: + asset_qty_map = self._get_asset_qty() + for asset, qty in asset_qty_map.items(): + if qty["actual_qty"] < qty["sale_qty"]: + frappe.throw( + _( + "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)." + ).format(asset, qty["actual_qty"]) + ) + + remaining_qty = qty["actual_qty"] - qty["sale_qty"] + if remaining_qty > 0: + split_asset(asset, remaining_qty) + + def get_disposal_date(self) -> str: + doc = self.doc + if doc.is_return: + return frappe.db.get_value("Sales Invoice", doc.return_against, "posting_date") + return doc.posting_date + + def _depreciate_asset_on_sale(self) -> None: + disposal_date = self.get_disposal_date() + for d in self.doc.get("items"): + if d.asset: + asset = frappe.get_doc("Asset", d.asset) + if asset.calculate_depreciation and asset.status != "Fully Depreciated": + depreciate_asset(asset, disposal_date, self._get_note_for_asset_sale(asset)) + + def _restore_asset(self) -> None: + for d in self.doc.get("items"): + if d.asset: + asset = frappe.get_cached_doc("Asset", d.asset) + if asset.calculate_depreciation: + reverse_depreciation_entry_made_on_disposal(asset) + reset_depreciation_schedule(asset, self._get_note_for_asset_return(asset)) + + def _update_asset(self) -> None: + doc = self.doc + disposal_date = self.get_disposal_date() + + for d in doc.get("items"): + if not d.asset: + continue + + asset = frappe.get_cached_doc("Asset", d.asset) + + if (doc.is_return and doc.docstatus == 1) or (not doc.is_return and doc.docstatus == 2): + note = _("Asset returned") if doc.is_return else _("Asset sold") + asset_status, disposal_date = None, None + else: + note = _("Asset sold") if not doc.is_return else _("Return invoice of asset cancelled") + asset_status = "Sold" + + frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date) + add_asset_activity(asset.name, note) + asset.set_status(asset_status) + + def _get_asset_qty(self) -> dict: + doc = self.doc + asset_qty_map = {} + + assets = {row.asset for row in doc.items if row.is_fixed_asset and row.asset} + if not assets or doc.is_return: + return asset_qty_map + + asset_actual_qty = dict( + frappe.db.get_all( + "Asset", + {"name": ["in", list(assets)]}, + ["name", "asset_quantity"], + as_list=True, + ) + ) + for row in doc.items: + if row.is_fixed_asset and row.asset: + actual_qty = asset_actual_qty.get(row.asset) + if row.asset in asset_qty_map: + asset_qty_map[row.asset]["sale_qty"] += flt(row.qty) + else: + asset_qty_map[row.asset] = { + "sale_qty": flt(row.qty), + "actual_qty": flt(actual_qty), + } + + return asset_qty_map + + def _get_note_for_asset_sale(self, asset) -> str: + doc = self.doc + return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format( + get_link_to_form(asset.doctype, asset.name), + _("returned") if doc.is_return else _("sold"), + get_link_to_form(doc.doctype, doc.get("name")), + ) + + def _get_note_for_asset_return(self, asset) -> str: + doc = self.doc + asset_link = get_link_to_form(asset.doctype, asset.name) + invoice_link = get_link_to_form(doc.doctype, doc.get("name")) + if doc.is_return: + return _( + "This schedule was created when Asset {0} was returned through Sales Invoice {1}." + ).format(asset_link, invoice_link) + return _( + "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation." + ).format(asset_link, invoice_link) diff --git a/erpnext/accounts/doctype/sales_invoice/services/inter_company.py b/erpnext/accounts/doctype/sales_invoice/services/inter_company.py new file mode 100644 index 00000000000..c6e3abaa24b --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/inter_company.py @@ -0,0 +1,68 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Inter-company transaction helpers for Sales Invoice.""" + +import frappe +from frappe import _ + + +def validate_inter_company_party( + doctype: str, party: str, company: str, inter_company_reference: str | None +) -> None: + if not party: + return + + if doctype in ["Sales Invoice", "Sales Order"]: + partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer" + ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order" + else: + partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier" + ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order" + + if inter_company_reference: + doc = frappe.get_doc(ref_doc, inter_company_reference) + ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer + if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party: + frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype))) + if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company: + frappe.throw(_("Invalid Company for Inter Company Transaction.")) + + elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party: + companies = [ + d.company + for d in frappe.get_all( + "Allowed To Transact With", + fields=["company"], + filters={"parenttype": partytype, "parent": party}, + ) + ] + if company not in companies: + frappe.throw( + _( + "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record." + ).format(_(partytype), company) + ) + + +def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None: + ref_field = ( + "inter_company_invoice_reference" + if doctype in ["Sales Invoice", "Purchase Invoice"] + else "inter_company_order_reference" + ) + if inter_company_reference: + frappe.db.set_value(doctype, inter_company_reference, ref_field, name) + + +def unlink_inter_company_doc(doctype: str, name: str, inter_company_reference: str | None) -> None: + if doctype in ["Sales Invoice", "Purchase Invoice"]: + ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice" + ref_field = "inter_company_invoice_reference" + else: + ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order" + ref_field = "inter_company_order_reference" + + if inter_company_reference: + frappe.db.set_value(doctype, name, ref_field, "") + frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "") diff --git a/erpnext/accounts/doctype/sales_invoice/services/loyalty.py b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py new file mode 100644 index 00000000000..706894b33b6 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py @@ -0,0 +1,162 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Loyalty program helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import add_days, cint, flt, getdate + +from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( + get_loyalty_program_details_with_points, +) + + +class LoyaltyService: + def __init__(self, doc): + self.doc = doc + + def make_loyalty_point_entry(self) -> None: + doc = self.doc + returned_amount = self._get_returned_amount() + current_amount = flt(doc.grand_total) - cint(doc.loyalty_amount) + eligible_amount = current_amount - returned_amount + lp_details = get_loyalty_program_details_with_points( + doc.customer, + company=doc.company, + current_transaction_amount=current_amount, + loyalty_program=doc.loyalty_program, + expiry_date=doc.posting_date, + include_expired_entry=True, + ) + if ( + lp_details + and getdate(lp_details.from_date) <= getdate(doc.posting_date) + and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(doc.posting_date)) + ): + collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0 + points_earned = cint(eligible_amount / collection_factor) + + entry = frappe.get_doc( + { + "doctype": "Loyalty Point Entry", + "company": doc.company, + "loyalty_program": lp_details.loyalty_program, + "loyalty_program_tier": lp_details.tier_name, + "customer": doc.customer, + "invoice_type": doc.doctype, + "invoice": doc.name, + "loyalty_points": points_earned, + "purchase_amount": eligible_amount, + "expiry_date": add_days(doc.posting_date, lp_details.expiry_duration), + "posting_date": doc.posting_date, + } + ) + entry.flags.ignore_permissions = 1 + entry.save() + self._set_loyalty_program_tier() + + def delete_loyalty_point_entry(self) -> None: + doc = self.doc + lp_entry = frappe.db.sql( + "select name from `tabLoyalty Point Entry` where invoice=%s", (doc.name), as_dict=1 + ) + + if not lp_entry: + return + + against_lp_entry = frappe.db.sql( + """select name, invoice from `tabLoyalty Point Entry` + where redeem_against=%s""", + (lp_entry[0].name), + as_dict=1, + ) + if against_lp_entry: + invoice_list = ", ".join([d.invoice for d in against_lp_entry]) + frappe.throw( + _( + """{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}""" + ).format(doc.doctype, doc.doctype, invoice_list) + ) + else: + frappe.db.sql("""delete from `tabLoyalty Point Entry` where invoice=%s""", (doc.name)) + self._set_loyalty_program_tier() + + def apply_loyalty_points(self) -> None: + from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import ( + get_loyalty_point_entries, + get_redemption_details, + ) + + doc = self.doc + loyalty_point_entries = get_loyalty_point_entries( + doc.customer, doc.loyalty_program, doc.company, doc.posting_date + ) + redemption_details = get_redemption_details(doc.customer, doc.loyalty_program, doc.company) + + points_to_redeem = doc.loyalty_points + for lp_entry in loyalty_point_entries: + if lp_entry.invoice_type != doc.doctype or lp_entry.invoice == doc.name: + continue + available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name)) + redeemed_points = min(available_points, points_to_redeem) + entry = frappe.get_doc( + { + "doctype": "Loyalty Point Entry", + "company": doc.company, + "loyalty_program": doc.loyalty_program, + "loyalty_program_tier": lp_entry.loyalty_program_tier, + "customer": doc.customer, + "invoice_type": doc.doctype, + "invoice": doc.name, + "redeem_against": lp_entry.name, + "loyalty_points": -1 * redeemed_points, + "purchase_amount": doc.grand_total, + "expiry_date": lp_entry.expiry_date, + "posting_date": doc.posting_date, + } + ) + entry.flags.ignore_permissions = 1 + entry.save() + points_to_redeem -= redeemed_points + if points_to_redeem < 1: + break + + def _set_loyalty_program_tier(self) -> None: + doc = self.doc + lp_details = get_loyalty_program_details_with_points( + doc.customer, + company=doc.company, + loyalty_program=doc.loyalty_program, + include_expired_entry=True, + ) + customer = frappe.get_doc("Customer", doc.customer) + customer.db_set("loyalty_program_tier", lp_details.tier_name) + + def _get_returned_amount(self) -> float: + from frappe.query_builder.functions import Sum + + doc = frappe.qb.DocType(self.doc.doctype) + returned_amount = ( + frappe.qb.from_(doc) + .select(Sum(doc.grand_total)) + .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.doc.name)) + ).run() + + return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 + + +def get_loyalty_programs(customer: str) -> list: + """Return applicable loyalty programs for the customer.""" + from erpnext.selling.doctype.customer.customer import get_loyalty_programs as _get + + customer_doc = frappe.get_doc("Customer", customer) + if customer_doc.loyalty_program: + return [customer_doc.loyalty_program] + + lp_details = _get(customer_doc) + + if len(lp_details) == 1: + customer_doc.db_set("loyalty_program", lp_details[0]) + + return lp_details diff --git a/erpnext/accounts/doctype/sales_invoice/services/pos.py b/erpnext/accounts/doctype/sales_invoice/services/pos.py new file mode 100644 index 00000000000..2a40eee9292 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/pos.py @@ -0,0 +1,396 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""POS helpers for Sales Invoice.""" + +import frappe +from frappe import _, msgprint +from frappe.utils import cint, flt, get_link_to_form + + +class PartialPaymentValidationError(frappe.ValidationError): + pass + + +class POSService: + def __init__(self, doc): + self.doc = doc + + def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None: + """Populate POS-profile fields on the invoice; return the profile or None.""" + doc = self.doc + if cint(doc.is_pos) != 1: + return None + + if not doc.account_for_change_amount: + doc.account_for_change_amount = frappe.get_cached_value( + "Company", doc.company, "default_cash_account" + ) + + from erpnext.stock.get_item_details import ( + ItemDetailsCtx, + get_pos_profile, + get_pos_profile_item_details_, + ) + + if not doc.pos_profile and not doc.flags.ignore_pos_profile: + pos_profile = get_pos_profile(doc.company) or {} + if not pos_profile: + return None + doc.pos_profile = pos_profile.get("name") + + pos = {} + if doc.pos_profile: + pos = frappe.get_doc("POS Profile", doc.pos_profile) + + if pos: + if not for_validate: + update_multi_mode_option(doc, pos) + doc.tax_category = pos.get("tax_category") + + if not for_validate and not doc.customer: + doc.customer = pos.customer + + if not for_validate: + doc.ignore_pricing_rule = pos.ignore_pricing_rule + + if pos.get("account_for_change_amount"): + doc.account_for_change_amount = pos.get("account_for_change_amount") + + for fieldname in ( + "currency", + "letter_head", + "tc_name", + "company", + "select_print_heading", + "write_off_account", + "taxes_and_charges", + "write_off_cost_center", + "apply_discount_on", + "cost_center", + ): + if (not for_validate) or (for_validate and not doc.get(fieldname)): + doc.set(fieldname, pos.get(fieldname)) + + if pos.get("company_address"): + doc.company_address = pos.get("company_address") + + if doc.customer: + customer_price_list, customer_group = frappe.get_value( + "Customer", doc.customer, ["default_price_list", "customer_group"] + ) + customer_group_price_list = frappe.get_value( + "Customer Group", customer_group, "default_price_list" + ) + selling_price_list = ( + customer_price_list or customer_group_price_list or pos.get("selling_price_list") + ) + else: + selling_price_list = pos.get("selling_price_list") + + if selling_price_list: + doc.set("selling_price_list", selling_price_list) + + if not for_validate: + doc.update_stock = cint(pos.get("update_stock")) + + for item in doc.get("items"): + if item.get("item_code"): + profile_details = get_pos_profile_item_details_( + ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True + ) + for fname, val in profile_details.items(): + if (not for_validate) or (for_validate and not item.get(fname)): + item.set(fname, val) + + if doc.tc_name and not doc.terms: + doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms") + + if doc.taxes_and_charges and not len(doc.get("taxes")): + from erpnext.accounts.services.taxes import TaxService + + TaxService(doc).set_taxes() + + return pos + + def set_paid_amount(self) -> None: + doc = self.doc + paid_amount = 0.0 + base_paid_amount = 0.0 + for data in doc.payments: + data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount")) + paid_amount += data.amount + base_paid_amount += data.base_amount + doc.paid_amount = paid_amount + doc.base_paid_amount = base_paid_amount + + def set_account_for_mode_of_payment(self) -> None: + for payment in self.doc.payments: + payment.account = get_bank_cash_account(payment.mode_of_payment, self.doc.company).get("account") + + def reset_mode_of_payments(self) -> None: + doc = self.doc + if doc.pos_profile: + pos_profile = frappe.get_cached_doc("POS Profile", doc.pos_profile) + update_multi_mode_option(doc, pos_profile) + doc.paid_amount = 0 + + def validate_pos_return(self) -> None: + doc = self.doc + if doc.is_consolidated: + return + + if doc.is_pos and doc.is_return: + total_amount_in_payments = sum(payment.amount for payment in doc.payments) + invoice_total = doc.rounded_total or doc.grand_total + if total_amount_in_payments < invoice_total: + frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total)) + + def validate_pos_paid_amount(self) -> None: + doc = self.doc + if len(doc.payments) == 0 and doc.is_pos and flt(doc.grand_total) > 0: + frappe.throw(_("At least one mode of payment is required for POS invoice.")) + + def validate_pos(self) -> None: + doc = self.doc + if doc.is_return: + invoice_total = doc.rounded_total or doc.grand_total + if abs(flt(doc.paid_amount)) + abs(flt(doc.write_off_amount)) - abs(flt(invoice_total)) > 1.0 / ( + 10.0 ** (doc.precision("grand_total") + 1.0) + ): + frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) + + def validate_created_using_pos(self) -> None: + doc = self.doc + if doc.is_created_using_pos and not doc.pos_profile: + frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction.")) + + doc.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type") + if doc.invoice_type_in_pos == "POS Invoice" and not doc.is_return: + frappe.throw(_("Transactions using Sales Invoice in POS are disabled.")) + + self.validate_pos_opening_entry() + + def validate_full_payment(self) -> None: + doc = self.doc + allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment") + invoice_total = flt(doc.rounded_total) or flt(doc.grand_total) + + if ( + doc.docstatus == 1 + and not doc.is_return + and not allow_partial_payment + and doc.paid_amount < invoice_total + ): + frappe.throw( + msg=_("Partial Payment in POS Transactions are not allowed."), + exc=PartialPaymentValidationError, + ) + + def validate_pos_opening_entry(self) -> None: + doc = self.doc + opening_entries = frappe.get_all( + "POS Opening Entry", + fields=["name", "period_start_date"], + filters={"pos_profile": doc.pos_profile, "status": "Open"}, + order_by="period_start_date desc", + ) + if not opening_entries: + frappe.throw( + title=_("POS Opening Entry Missing"), + msg=_("No open POS Opening Entry found for POS Profile {0}.").format( + frappe.bold(doc.pos_profile) + ), + ) + if len(opening_entries) > 1: + frappe.throw( + title=_("Multiple POS Opening Entry"), + msg=_( + "POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding." + ).format(doc.pos_profile), + ) + if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today(): + frappe.throw( + title=_("Outdated POS Opening Entry"), + msg=_( + "POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry." + ).format(opening_entries[0].get("name")), + ) + + def check_if_consolidated_invoice(self) -> None: + doc = self.doc + if doc.doctype == "Sales Invoice" and doc.is_consolidated: + invoice_or_credit_note = "consolidated_credit_note" if doc.is_return else "consolidated_invoice" + pos_closing_entry = frappe.get_all( + "POS Invoice Merge Log", + filters={invoice_or_credit_note: doc.name}, + pluck="pos_closing_entry", + ) + if pos_closing_entry and pos_closing_entry[0]: + msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( + frappe.bold(_("Consolidated Sales Invoice")), + get_link_to_form("POS Closing Entry", pos_closing_entry[0]), + ) + frappe.throw(msg, title=_("Not Allowed")) + + def check_if_created_using_pos_and_pos_closing_entry_generated(self) -> None: + doc = self.doc + if doc.doctype == "Sales Invoice" and doc.is_created_using_pos and doc.pos_closing_entry: + pos_closing_entry_docstatus = frappe.db.get_value( + "POS Closing Entry", doc.pos_closing_entry, "docstatus" + ) + if pos_closing_entry_docstatus == 1: + frappe.throw( + msg=_( + "To cancel this Sales Invoice you need to cancel the POS Closing Entry {0}." + ).format(get_link_to_form("POS Closing Entry", doc.pos_closing_entry)), + title=_("Not Allowed"), + ) + + def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self) -> None: + pos_invoices = frappe.get_all( + "POS Invoice", filters={"consolidated_invoice": self.doc.name}, pluck="name" + ) + for pos_invoice in pos_invoices: + frappe.get_doc("POS Invoice", pos_invoice).cancel() + + def clear_unallocated_mode_of_payments(self) -> None: + doc = self.doc + doc.set("payments", doc.get("payments", {"amount": ["not in", [0, None, ""]]})) + frappe.db.sql( + """delete from `tabSales Invoice Payment` where parent = %s and amount = 0""", + doc.name, + ) + + def allow_write_off_only_on_pos(self) -> None: + if not self.doc.is_pos and self.doc.write_off_account: + self.doc.write_off_account = None + + def verify_payment_amount_is_positive(self) -> None: + for entry in self.doc.payments: + if entry.amount < 0: + frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx)) + + def verify_payment_amount_is_negative(self) -> None: + for entry in self.doc.payments: + if entry.amount > 0: + frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx)) + + def get_warehouse(self) -> str | None: + doc = self.doc + user_pos_profile = frappe.db.sql( + """select name, warehouse from `tabPOS Profile` + where ifnull(user,'') = %s and company = %s""", + (frappe.session["user"], doc.company), + ) + warehouse = user_pos_profile[0][1] if user_pos_profile else None + + if not warehouse: + global_pos_profile = frappe.db.sql( + """select name, warehouse from `tabPOS Profile` + where (user is null or user = '') and company = %s""", + doc.company, + ) + if global_pos_profile: + warehouse = global_pos_profile[0][1] + elif not user_pos_profile: + msgprint(_("POS Profile required to make POS Entry"), raise_exception=True) + + return warehouse + + +def get_bank_cash_account(mode_of_payment: str, company: str) -> dict: + account = frappe.db.get_value( + "Mode of Payment Account", + {"parent": mode_of_payment, "company": company}, + "default_account", + ) + if not account: + frappe.throw( + _("Please set default Cash or Bank account in Mode of Payment {0}").format( + get_link_to_form("Mode of Payment", mode_of_payment) + ), + title=_("Missing Account"), + ) + return {"account": account} + + +def update_multi_mode_option(doc, pos_profile) -> None: + def append_payment(payment_mode): + payment = doc.append("payments", {}) + payment.default = payment_mode.default + payment.mode_of_payment = payment_mode.mop + payment.account = payment_mode.default_account + payment.type = payment_mode.type + + mop_refetched = bool(doc.payments) and not doc.is_created_using_pos + + doc.set("payments", []) + invalid_modes = [] + mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")] + mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company) + + for row in pos_profile.get("payments"): + payment_mode = mode_of_payments_info.get(row.mode_of_payment) + if not payment_mode: + invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment)) + continue + + payment_mode.default = row.default + append_payment(payment_mode) + + if invalid_modes: + if invalid_modes == 1: + msg = _("Please set default Cash or Bank account in Mode of Payment {}") + else: + msg = _("Please set default Cash or Bank account in Mode of Payments {}") + frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) + + if mop_refetched: + frappe.toast( + _("Payment methods refreshed. Please review before proceeding."), + indicator="orange", + ) + + +def get_all_mode_of_payments(doc) -> list: + return frappe.db.sql( + """ + select mpa.default_account, mpa.parent, mp.type as type + from `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""", + {"company": doc.company}, + as_dict=1, + ) + + +def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict: + data = frappe.db.sql( + """ + select + mpa.default_account, mpa.parent as mop, mp.type as type + from + `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where + mpa.parent = mp.name and + mpa.company = %s and + mp.enabled = 1 and + mp.name in %s + group by + mp.name + """, + (company, mode_of_payments), + as_dict=1, + ) + return {row.get("mop"): row for row in data} + + +def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list: + return frappe.db.sql( + """ + select mpa.default_account, mpa.parent, mp.type as type + from `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", + (company, mode_of_payment), + as_dict=1, + ) diff --git a/erpnext/accounts/doctype/sales_invoice/services/status.py b/erpnext/accounts/doctype/sales_invoice/services/status.py new file mode 100644 index 00000000000..8ec179d9853 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/status.py @@ -0,0 +1,130 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Status computation and display helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import cint, flt, getdate, nowdate + + +class StatusService: + def __init__(self, doc): + self.doc = doc + + def set_status( + self, update: bool = False, status: str | None = None, update_modified: bool = True + ) -> None: + doc = self.doc + if doc.is_new(): + if doc.get("amended_from"): + doc.status = "Draft" + return + + outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) + total = get_total_in_party_account_currency(doc) + + if not status: + if doc.docstatus == 2: + status = "Cancelled" + elif doc.docstatus == 1: + if doc.is_internal_transfer(): + doc.status = "Internal Transfer" + elif is_overdue(doc, total): + doc.status = "Overdue" + elif 0 < outstanding_amount < total: + doc.status = "Partly Paid" + elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate(): + doc.status = "Unpaid" + elif doc.is_return == 0 and frappe.db.get_value( + "Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1} + ): + doc.status = "Credit Note Issued" + elif doc.is_return == 1: + doc.status = "Return" + elif outstanding_amount <= 0: + doc.status = "Paid" + else: + doc.status = "Submitted" + + if ( + doc.status in ("Unpaid", "Partly Paid", "Overdue") + and doc.is_discounted + and get_discounting_status(doc.name) == "Disbursed" + ): + doc.status += " and Discounted" + + else: + doc.status = "Draft" + + if update: + doc.db_set("status", doc.status, update_modified=update_modified) + + def set_indicator(self) -> None: + doc = self.doc + if doc.outstanding_amount < 0: + doc.indicator_title = _("Credit Note Issued") + doc.indicator_color = "gray" + elif doc.outstanding_amount > 0 and getdate(doc.due_date) >= getdate(nowdate()): + doc.indicator_color = "orange" + doc.indicator_title = _("Unpaid") + elif doc.outstanding_amount > 0 and getdate(doc.due_date) < getdate(nowdate()): + doc.indicator_color = "red" + doc.indicator_title = _("Overdue") + elif cint(doc.is_return) == 1: + doc.indicator_title = _("Return") + doc.indicator_color = "gray" + else: + doc.indicator_color = "green" + doc.indicator_title = _("Paid") + + +def get_total_in_party_account_currency(doc) -> float: + total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total" + if doc.party_account_currency != doc.currency: + total_fieldname = "base_" + total_fieldname + return flt(doc.get(total_fieldname), doc.precision(total_fieldname)) + + +def is_overdue(doc, total: float) -> bool | None: + outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) + if outstanding_amount <= 0: + return + + today = getdate() + if doc.get("is_pos") or not doc.get("payment_schedule"): + return getdate(doc.due_date) < today + + payment_amount_field = ( + "base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount" + ) + payable_amount = flt( + sum( + payment.get(payment_amount_field) + for payment in doc.payment_schedule + if getdate(payment.due_date) < today + ), + doc.precision("outstanding_amount"), + ) + return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount + + +def get_discounting_status(sales_invoice: str) -> str | None: + status = None + invoice_discounting_list = frappe.db.sql( + """ + select status + from `tabInvoice Discounting` id, `tabDiscounted Invoice` d + where + id.name = d.parent + and d.sales_invoice=%s + and id.docstatus=1 + and status in ('Disbursed', 'Settled') + """, + sales_invoice, + ) + for d in invoice_discounting_list: + status = d[0] + if status == "Disbursed": + break + return status diff --git a/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py b/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py new file mode 100644 index 00000000000..f688363dfc7 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py @@ -0,0 +1,121 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Timesheet billing helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data + + +class TimesheetBillingService: + def __init__(self, doc): + self.doc = doc + + def validate_time_sheets_are_submitted(self) -> None: + for data in self.doc.timesheets: + if data.time_sheet and data.timesheet_detail: + if sales_invoice := frappe.db.get_value( + "Timesheet Detail", data.timesheet_detail, "sales_invoice" + ): + frappe.throw( + _("Row {0}: Sales Invoice {1} is already created for {2}").format( + data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) + ) + ) + + if data.time_sheet: + status = frappe.db.get_value("Timesheet", data.time_sheet, "status") + if status not in ["Submitted", "Payslip", "Partially Billed"]: + frappe.throw( + _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) + ) + + def update_time_sheet(self, sales_invoice: str | None) -> None: + for d in self.doc.timesheets: + if d.time_sheet: + timesheet = frappe.get_doc("Timesheet", d.time_sheet) + self._update_time_sheet_detail(timesheet, d, sales_invoice) + timesheet.calculate_total_amounts() + timesheet.calculate_percentage_billed() + timesheet.flags.ignore_validate_update_after_submit = True + timesheet.set_status() + timesheet.db_update_all() + + def unlink_sales_invoice_from_timesheets(self) -> None: + for row in self.doc.timesheets: + timesheet = frappe.get_doc("Timesheet", row.time_sheet) + timesheet.unlink_sales_invoice(self.doc.name) + timesheet.flags.ignore_validate_update_after_submit = True + timesheet.db_update_all() + + def set_billing_hours_and_amount(self) -> None: + doc = self.doc + if doc.project: + return + + for timesheet in doc.timesheets: + ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet) + if not timesheet.billing_hours and ts_doc.total_billable_hours: + timesheet.billing_hours = ts_doc.total_billable_hours + if not timesheet.billing_amount and ts_doc.total_billable_amount: + timesheet.billing_amount = ts_doc.total_billable_amount + + def update_timesheet_billing_for_project(self) -> None: + doc = self.doc + if ( + not doc.is_return + and not doc.timesheets + and doc.project + and frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice") + ): + self.add_timesheet_data() + else: + self.calculate_billing_amount_for_timesheet() + + def add_timesheet_data(self) -> None: + doc = self.doc + doc.set("timesheets", []) + if doc.project: + for data in get_projectwise_timesheet_data(doc.project): + doc.append( + "timesheets", + { + "time_sheet": data.time_sheet, + "billing_hours": data.billing_hours, + "billing_amount": data.billing_amount, + "timesheet_detail": data.name, + "activity_type": data.activity_type, + "description": data.description, + }, + ) + self.calculate_billing_amount_for_timesheet() + + def calculate_billing_amount_for_timesheet(self) -> None: + doc = self.doc + doc.total_billing_amount = sum(flt(ts.billing_amount) for ts in doc.timesheets) + doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets) + + def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None: + doc = self.doc + for data in timesheet.time_logs: + if ( + (doc.project and args.timesheet_detail == data.name) + or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name) + or ( + not sales_invoice + and data.sales_invoice == doc.name + and args.timesheet_detail == data.name + ) + or ( + doc.is_return + and doc.return_against + and data.sales_invoice + and data.sales_invoice == doc.return_against + and not sales_invoice + and args.timesheet_detail == data.name + ) + ): + data.sales_invoice = sales_invoice From 530e587bf2cd2087f327b1015b9b8ee8ee03c4ce Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 1 Jun 2026 18:17:56 +0530 Subject: [PATCH 57/65] refactor: use mapper paths directly, drop re-export shims Repoint all JS method strings and Python imports for mapper functions across 18 doctypes from the doctype module to its mapper module, and remove the now-unused re-export shims from each doctype file (keeping only names used internally). --- erpnext/accounts/doctype/dunning/dunning.js | 2 +- .../accounts/doctype/dunning/test_dunning.py | 4 +- .../payment_entry/test_payment_entry.py | 4 +- .../payment_request/payment_request.py | 2 +- .../test_pos_closing_entry.py | 2 +- .../doctype/purchase_invoice/mapper.py | 2 +- .../purchase_invoice/purchase_invoice.js | 10 +-- .../purchase_invoice/purchase_invoice.py | 2 - .../purchase_invoice/test_purchase_invoice.py | 24 +++--- .../doctype/sales_invoice/sales_invoice.js | 18 ++--- .../doctype/sales_invoice/sales_invoice.py | 14 ---- .../sales_invoice/services/fixed_assets.py | 2 +- .../sales_invoice/test_sales_invoice.py | 18 ++--- .../doctype/tax_rule/test_tax_rule.py | 2 +- .../test_tax_withholding_category.py | 2 +- .../test_unreconcile_payment.py | 2 +- .../report/gross_profit/test_gross_profit.py | 12 +-- .../accounts/services/child_item_update.py | 2 +- erpnext/accounts/test_gl_characterization.py | 6 +- erpnext/assets/doctype/asset/asset.js | 14 ++-- erpnext/assets/doctype/asset/asset.py | 10 --- erpnext/assets/doctype/asset/test_asset.py | 8 +- .../doctype/asset_repair/test_asset_repair.py | 2 + .../buying/doctype/purchase_order/mapper.py | 2 +- .../doctype/purchase_order/purchase_order.js | 12 +-- .../doctype/purchase_order/purchase_order.py | 6 -- .../purchase_order/test_purchase_order.py | 20 ++--- .../doctype/request_for_quotation/mapper.py | 2 +- .../request_for_quotation.js | 8 +- .../request_for_quotation.py | 6 -- .../test_request_for_quotation.py | 8 +- erpnext/buying/doctype/supplier/supplier.py | 4 +- .../supplier_quotation/supplier_quotation.js | 8 +- .../supplier_quotation/supplier_quotation.py | 2 - .../test_supplier_quotation.py | 2 +- ...st_requested_items_to_order_and_receive.py | 4 +- .../tests/test_accounts_controller.py | 12 +-- .../tests/test_item_wise_inventory_account.py | 2 +- erpnext/controllers/tests/test_mapper.py | 2 +- erpnext/crm/doctype/lead/lead.js | 8 +- erpnext/crm/doctype/lead/lead.py | 4 +- erpnext/crm/doctype/lead/test_lead.py | 6 +- erpnext/crm/doctype/opportunity/mapper.py | 2 +- .../crm/doctype/opportunity/opportunity.js | 10 +-- .../crm/doctype/opportunity/opportunity.py | 8 -- .../doctype/opportunity/test_opportunity.py | 4 +- .../maintenance_schedule.js | 2 +- .../maintenance_visit/maintenance_visit.js | 2 +- .../doctype/job_card/job_card.js | 8 +- .../doctype/job_card/job_card.py | 4 +- .../doctype/job_card/test_job_card.py | 20 ++--- .../production_plan/test_production_plan.py | 20 ++--- .../doctype/work_order/test_work_order.py | 8 +- .../doctype/work_order/work_order.js | 25 +++--- .../doctype/work_order/work_order.py | 2 - .../doctype/workstation/workstation.js | 2 +- .../projects/doctype/project/test_project.py | 2 +- .../doctype/timesheet/test_timesheet.py | 2 +- erpnext/public/js/communication.js | 4 +- erpnext/selling/doctype/customer/customer.js | 6 +- erpnext/selling/doctype/customer/customer.py | 4 - .../selling/doctype/customer/test_customer.py | 2 + .../installation_note/installation_note.js | 2 +- .../selling/doctype/quotation/quotation.js | 6 +- .../selling/doctype/quotation/quotation.py | 5 -- .../doctype/quotation/test_quotation.py | 30 +++---- erpnext/selling/doctype/sales_order/mapper.py | 2 +- .../doctype/sales_order/sales_order.js | 28 +++---- .../doctype/sales_order/sales_order.py | 16 ---- .../doctype/sales_order/test_sales_order.py | 24 +++--- .../page/point_of_sale/pos_controller.js | 2 +- ...st_payment_terms_status_for_sales_order.py | 2 +- ...t_pending_so_items_for_purchase_request.py | 2 +- .../test_sales_order_analysis.py | 2 +- erpnext/setup/demo.py | 4 +- erpnext/stock/doctype/batch/test_batch.py | 2 +- .../doctype/delivery_note/delivery_note.js | 20 ++--- .../doctype/delivery_note/delivery_note.py | 7 -- .../delivery_note/delivery_note_list.js | 2 +- erpnext/stock/doctype/delivery_note/mapper.py | 2 +- .../delivery_note/test_delivery_note.py | 54 ++++++------- .../doctype/delivery_trip/delivery_trip.js | 2 +- .../test_inventory_dimension.py | 2 +- .../item_alternative/test_item_alternative.py | 2 +- .../test_landed_cost_voucher.py | 4 +- .../material_request/material_request.js | 14 ++-- .../material_request/material_request.py | 8 -- .../material_request/test_material_request.py | 8 +- .../doctype/packed_item/test_packed_item.py | 8 +- .../doctype/packing_slip/packing_slip.js | 2 +- .../doctype/packing_slip/test_packing_slip.py | 2 +- erpnext/stock/doctype/pick_list/mapper.py | 2 +- erpnext/stock/doctype/pick_list/pick_list.js | 10 +-- erpnext/stock/doctype/pick_list/pick_list.py | 4 - .../stock/doctype/pick_list/test_pick_list.py | 11 +-- .../stock/doctype/purchase_receipt/mapper.py | 2 +- .../purchase_receipt/purchase_receipt.js | 16 ++-- .../purchase_receipt/purchase_receipt.py | 8 -- .../purchase_receipt/test_purchase_receipt.py | 78 +++++++++---------- .../stock/doctype/shipment/test_shipment.py | 2 +- .../stock/doctype/stock_entry/stock_entry.js | 6 +- .../doctype/stock_entry/test_stock_entry.py | 18 ++--- .../test_stock_ledger_entry.py | 2 +- .../test_stock_reservation_entry.py | 8 +- erpnext/stock/tests/test_get_item_details.py | 2 +- .../subcontracting_inward_order.js | 2 +- .../test_subcontracting_inward_order.py | 6 +- .../subcontracting_order.js | 2 +- .../test_subcontracting_order.py | 4 +- .../subcontracting_receipt.js | 6 +- .../subcontracting_receipt.py | 2 - .../test_subcontracting_receipt.py | 2 +- erpnext/templates/includes/rfq.js | 2 +- 113 files changed, 394 insertions(+), 484 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js index e9d091f2e85..cd928a414c1 100644 --- a/erpnext/accounts/doctype/dunning/dunning.js +++ b/erpnext/accounts/doctype/dunning/dunning.js @@ -60,7 +60,7 @@ frappe.ui.form.on("Dunning", { if (frm.doc.docstatus === 0) { frm.add_custom_button(__("Fetch Overdue Payments"), () => { erpnext.utils.map_current_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning", + method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning", source_doctype: "Sales Invoice", date_field: "due_date", target: frm, diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 6eaf1d8798e..0110877ce90 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -8,7 +8,7 @@ from frappe.utils import add_days, nowdate, today from erpnext import get_default_cost_center from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry -from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( +from erpnext.accounts.doctype.sales_invoice.mapper import ( create_dunning as create_dunning_from_sales_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( @@ -73,7 +73,7 @@ class TestDunning(ERPNextTestSuite): dunning = create_dunning_from_sales_invoice(si1.name) dunning.overdue_payments = [] - method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning" + method = "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning" updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning) self.assertEqual(len(updated_dunning.overdue_payments), 2) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 759a6f0cfa2..8923a74e2b4 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -196,7 +196,7 @@ class TestPaymentEntry(ERPNextTestSuite): self.assertEqual(outstanding_amount, 100) def test_reference_outstanding_amount_on_advance_pull(self): - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice so = make_sales_order(qty=1, rate=1000) pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") @@ -1567,7 +1567,7 @@ class TestPaymentEntry(ERPNextTestSuite): self.check_pl_entries() def test_advance_as_liability_against_order(self): - from erpnext.buying.doctype.purchase_order.purchase_order import ( + from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_invoice as _make_purchase_invoice, ) from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index a74f3808142..7c9be8dbe07 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -443,7 +443,7 @@ class PaymentRequest(Document): self.update_reference_advance_payment_status() def make_invoice(self): - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice si = make_sales_invoice(self.reference_name, ignore_permissions=True) si.allocate_advances_automatically = True diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py index d066c0910d4..bcee69b64ba 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py @@ -330,7 +330,7 @@ class TestPOSClosingEntry(ERPNextTestSuite): """ Test Sales Invoice and Return Sales Invoice creation during POS Invoice mode. """ - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return test_user, pos_profile = init_user_and_profile() diff --git a/erpnext/accounts/doctype/purchase_invoice/mapper.py b/erpnext/accounts/doctype/purchase_invoice/mapper.py index d1c8df11df0..7c50121f1e5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/mapper.py +++ b/erpnext/accounts/doctype/purchase_invoice/mapper.py @@ -39,7 +39,7 @@ def make_stock_entry(source_name: str, target_doc: str | Document | None = None) @frappe.whitelist() def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction + from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction return make_inter_company_transaction("Purchase Invoice", source_name, target_doc) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 8818d4d1d06..21d585f0a95 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -156,7 +156,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. __("Purchase Order"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice", + method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice", source_doctype: "Purchase Order", target: me.frm, setters: { @@ -181,7 +181,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. __("Purchase Receipt"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice", source_doctype: "Purchase Receipt", target: me.frm, setters: { @@ -414,7 +414,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. make_inter_company_invoice(frm) { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_inter_company_sales_invoice", + method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_inter_company_sales_invoice", frm: frm, }); } @@ -474,7 +474,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. make_debit_note() { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_debit_note", + method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_debit_note", frm: this.frm, }); } @@ -701,7 +701,7 @@ frappe.ui.form.on("Purchase Invoice", { make_purchase_receipt: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt", + method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt", frm: frm, freeze_message: __("Creating Purchase Receipt ..."), }); diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 6138d02568f..32371980002 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -40,8 +40,6 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( update_billed_amount_based_on_po, ) -from .mapper import make_debit_note, make_inter_company_sales_invoice, make_purchase_receipt, make_stock_entry - class WarehouseMissingError(frappe.ValidationError): pass diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 509120acce3..2bd50bdfbed 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -8,8 +8,8 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today import erpnext from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry -from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice -from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice as make_pi_from_po +from erpnext.buying.doctype.purchase_order.mapper import get_mapped_purchase_invoice +from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice as make_pi_from_po from erpnext.buying.doctype.purchase_order.test_purchase_order import ( create_pr_against_po, create_purchase_order, @@ -20,9 +20,9 @@ from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.exceptions import InvalidCurrency from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import create_item -from erpnext.stock.doctype.material_request.material_request import make_purchase_order +from erpnext.stock.doctype.material_request.mapper import make_purchase_order from erpnext.stock.doctype.material_request.test_material_request import make_material_request -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( +from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as create_purchase_invoice_from_receipt, ) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( @@ -80,7 +80,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): pi.delete() def test_update_received_qty_in_material_request(self): - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice """ Test if the received_qty in Material Request is updated correctly when @@ -346,7 +346,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1} ) def test_purchase_invoice_with_exchange_rate_difference(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as create_purchase_invoice, ) @@ -388,7 +388,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): ) def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as create_purchase_invoice, ) @@ -2162,7 +2162,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): create_pr_against_po, create_purchase_order, ) - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as make_pi_from_pr, ) @@ -2748,10 +2748,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): def test_invoice_against_returned_pr(self): from erpnext.stock.doctype.item.test_item import make_item - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as make_purchase_invoice_from_pr, ) - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return_against_rejected_warehouse, ) @@ -2892,7 +2892,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): self.assertEqual(invoice.grand_total, 300) def test_pr_pi_over_billing(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as make_purchase_invoice_from_pr, ) @@ -2940,7 +2940,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): self.assertEqual(pi.discount_amount, discount_amount) def test_returned_item_purchase_receipt(self): - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( + from erpnext.accounts.doctype.purchase_invoice.mapper import ( make_purchase_receipt as make_purchase_receipt_from_pi, ) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 097d4f1ad03..42c51dc2a0b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -197,21 +197,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( make_invoice_discounting() { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting", + method: "erpnext.accounts.doctype.sales_invoice.mapper.create_invoice_discounting", frm: this.frm, }); } make_dunning() { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning", + method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning", frm: this.frm, }); } make_maintenance_schedule() { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", + method: "erpnext.accounts.doctype.sales_invoice.mapper.make_maintenance_schedule", frm: this.frm, }); } @@ -361,7 +361,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( __("Sales Order"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice", + method: "erpnext.selling.doctype.sales_order.mapper.make_sales_invoice", source_doctype: "Sales Order", target: me.frm, setters: { @@ -383,7 +383,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( __("Quotation"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.quotation.quotation.make_sales_invoice", + method: "erpnext.selling.doctype.quotation.mapper.make_sales_invoice", source_doctype: "Quotation", target: me.frm, setters: [ @@ -421,7 +421,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( }); } erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", + method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice", source_doctype: "Delivery Note", target: me.frm, date_field: "posting_date", @@ -501,7 +501,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( make_inter_company_invoice() { let me = this; frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_inter_company_purchase_invoice", + method: "erpnext.accounts.doctype.sales_invoice.mapper.make_inter_company_purchase_invoice", frm: me.frm, }); } @@ -579,7 +579,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( make_sales_return() { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return", + method: "erpnext.accounts.doctype.sales_invoice.mapper.make_sales_return", frm: this.frm, }); } @@ -712,7 +712,7 @@ extend_cscript(cur_frm.cscript, new erpnext.accounts.SalesInvoiceController({ fr cur_frm.cscript["Make Delivery Note"] = function () { frappe.model.open_mapped_doc({ - method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_delivery_note", + method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note", frm: cur_frm, }); }; diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index eac209cacad..93ec4f4d875 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -28,20 +28,6 @@ from erpnext.controllers.selling_controller import SellingController from erpnext.setup.doctype.company.company import update_company_current_month_sales from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so -from .mapper import ( - create_dunning, - create_invoice_discounting, - get_inter_company_details, - make_delivery_note, - make_inter_company_purchase_invoice, - make_inter_company_transaction, - make_maintenance_schedule, - make_sales_return, - set_purchase_references, - update_address, - update_taxes, - validate_inter_company_transaction, -) from .services.fixed_assets import FixedAssetService from .services.inter_company import ( unlink_inter_company_doc, diff --git a/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py index 3b793085304..a2843bef7a0 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py +++ b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py @@ -7,12 +7,12 @@ import frappe from frappe import _ from frappe.utils import flt, get_link_to_form -from erpnext.assets.doctype.asset.asset import split_asset from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, reset_depreciation_schedule, reverse_depreciation_entry_made_on_disposal, ) +from erpnext.assets.doctype.asset.mapper import split_asset from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 3a1ab35db7e..d72c5548327 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -19,7 +19,7 @@ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import Warehouse from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import ( unlink_payment_on_cancel_of_invoice, ) -from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction +from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction from erpnext.accounts.utils import PaymentEntryUnlinkError from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries from erpnext.assets.doctype.asset.test_asset import create_asset @@ -30,7 +30,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError, update_invo from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency from erpnext.selling.doctype.customer.test_customer import get_customer_dict -from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice +from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( @@ -78,7 +78,7 @@ class TestSalesInvoice(ERPNextTestSuite): def test_invalid_rate_without_override(self): from frappe import ValidationError - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice + from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice si = create_sales_invoice( customer="_Test Internal Customer 3", company="_Test Company", is_internal_customer=1, rate=100 @@ -1022,7 +1022,7 @@ class TestSalesInvoice(ERPNextTestSuite): self.validate_pos_gl_entry(si, pos, 50) def test_pos_returns_with_repayment(self): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return pos_profile = make_pos_profile() @@ -1141,7 +1141,7 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(pos.outstanding_amount, 0.0) self.assertEqual(pos.status, "Paid") - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return pos_return = make_sales_return(pos.name) pos_return.save().submit() @@ -3926,7 +3926,7 @@ class TestSalesInvoice(ERPNextTestSuite): from erpnext.accounts.doctype.loyalty_program.test_loyalty_program import ( create_sales_invoice_record, ) - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order # Set up loyalty program @@ -4064,7 +4064,7 @@ class TestSalesInvoice(ERPNextTestSuite): from frappe.model.mapper import map_docs map_docs( - method="erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", + method="erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice", source_names=json.dumps([dn1.name, dn2.name]), target_doc=si, args=json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}), @@ -4107,7 +4107,7 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(expected, actual) def test_pos_returns_without_update_outstanding_for_self(self): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return pos_profile = make_pos_profile() pos_profile.payments = [] @@ -4477,7 +4477,7 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(project.total_billed_amount, 300) def test_pos_returns_with_party_account_currency(self): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return pos_profile = make_pos_profile() pos_profile.payments = [] diff --git a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py index d36011bc5ff..d9fd85315ca 100644 --- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py @@ -4,7 +4,7 @@ import frappe from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule, get_tax_template -from erpnext.crm.doctype.opportunity.opportunity import make_quotation +from erpnext.crm.doctype.opportunity.mapper import make_quotation from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index 13697084cbf..f5c4a1b65db 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -9,7 +9,7 @@ from frappe.utils import add_days, add_months, getdate, today from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.utils import get_fiscal_year -from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice +from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py index e3bfed7de55..53d80e4099e 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py @@ -9,7 +9,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal from erpnext.accounts.party import get_party_account from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order -from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice +from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 74f4a0eba6b..776bed9b7f0 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -2,10 +2,10 @@ import frappe from frappe import qb from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate -from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return +from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note, make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.gross_profit.gross_profit import execute -from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice +from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -354,7 +354,7 @@ class TestGrossProfit(ERPNextTestSuite): do_not_submit=False, ) - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note, make_sales_invoice, ) @@ -522,7 +522,7 @@ class TestGrossProfit(ERPNextTestSuite): do_not_submit=False, ) - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note, make_sales_invoice, ) @@ -732,8 +732,8 @@ class TestGrossProfit(ERPNextTestSuite): self.assertEqual(total[8], 100.0) def test_drop_ship(self): - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice + from erpnext.selling.doctype.sales_order.mapper import make_purchase_order, make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py index c3b83302272..704a2f8e820 100644 --- a/erpnext/accounts/services/child_item_update.py +++ b/erpnext/accounts/services/child_item_update.py @@ -33,7 +33,7 @@ class ChildItemUpdater: def update(self, trans_items: str) -> None: """Process item additions, edits, and deletions from trans_items JSON.""" from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items - from erpnext.selling.doctype.quotation.quotation import get_ordered_items + from erpnext.selling.doctype.quotation.mapper import get_ordered_items data = frappe.parse_json(trans_items) any_qty_changed = False diff --git a/erpnext/accounts/test_gl_characterization.py b/erpnext/accounts/test_gl_characterization.py index 4cfc9a7f803..e7eb65c0edf 100644 --- a/erpnext/accounts/test_gl_characterization.py +++ b/erpnext/accounts/test_gl_characterization.py @@ -21,9 +21,9 @@ from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( set_default_account_for_mode_of_payment, ) from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry -from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note +from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice -from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return +from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.gl_snapshot import assert_gl_snapshot from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -471,7 +471,7 @@ class TestGLCharacterization(IntegrationTestCase): qty=5, rate=100, ) - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return ret = make_purchase_return(original.name) ret.posting_date = POSTING_DATE diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 1ba9b12d2b1..2418bc3930f 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -333,7 +333,7 @@ frappe.ui.form.on("Asset", { make_journal_entry: function (frm) { frappe.call({ - method: "erpnext.assets.doctype.asset.asset.make_journal_entry", + method: "erpnext.assets.doctype.asset.mapper.make_journal_entry", args: { asset_name: frm.doc.name, }, @@ -570,7 +570,7 @@ frappe.ui.form.on("Asset", { asset_category: frm.doc.asset_category, company: frm.doc.company, }, - method: "erpnext.assets.doctype.asset.asset.create_asset_maintenance", + method: "erpnext.assets.doctype.asset.mapper.create_asset_maintenance", callback: function (r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); @@ -585,7 +585,7 @@ frappe.ui.form.on("Asset", { asset: frm.doc.name, asset_name: frm.doc.asset_name, }, - method: "erpnext.assets.doctype.asset.asset.create_asset_repair", + method: "erpnext.assets.doctype.asset.mapper.create_asset_repair", callback: function (r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); @@ -601,7 +601,7 @@ frappe.ui.form.on("Asset", { asset_name: frm.doc.asset_name, item_code: frm.doc.item_code, }, - method: "erpnext.assets.doctype.asset.asset.create_asset_capitalization", + method: "erpnext.assets.doctype.asset.mapper.create_asset_capitalization", callback: function (r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); @@ -612,7 +612,7 @@ frappe.ui.form.on("Asset", { sell_asset: function (frm) { const make_sales_invoice = (sell_qty) => { frappe.call({ - method: "erpnext.assets.doctype.asset.asset.make_sales_invoice", + method: "erpnext.assets.doctype.asset.mapper.make_sales_invoice", args: { asset: frm.doc.name, item_code: frm.doc.item_code, @@ -696,7 +696,7 @@ frappe.ui.form.on("Asset", { asset_name: frm.doc.name, split_qty: cint(dialog_data.split_qty), }, - method: "erpnext.assets.doctype.asset.asset.split_asset", + method: "erpnext.assets.doctype.asset.mapper.split_asset", callback: function (r) { let doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); @@ -716,7 +716,7 @@ frappe.ui.form.on("Asset", { asset_category: frm.doc.asset_category, company: frm.doc.company, }, - method: "erpnext.assets.doctype.asset.asset.create_asset_value_adjustment", + method: "erpnext.assets.doctype.asset.mapper.create_asset_value_adjustment", freeze: 1, callback: function (r) { var doclist = frappe.model.sync(r.message); diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index cf749a4c6b8..1c9f3871d47 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -37,16 +37,6 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched ) from erpnext.controllers.accounts_controller import AccountsController -from .mapper import ( - create_asset_capitalization, - create_asset_maintenance, - create_asset_repair, - create_asset_value_adjustment, - make_journal_entry, - make_sales_invoice, - split_asset, -) - class Asset(AccountsController): # begin: auto-generated types diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index a1c5fc5e55e..b973ce9ddb9 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -18,8 +18,6 @@ from frappe.utils.data import add_to_date from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.assets.doctype.asset.asset import ( - make_sales_invoice, - split_asset, update_maintenance_status, ) from erpnext.assets.doctype.asset.depreciation import ( @@ -27,11 +25,15 @@ from erpnext.assets.doctype.asset.depreciation import ( restore_asset, scrap_asset, ) +from erpnext.assets.doctype.asset.mapper import ( + make_sales_invoice, + split_asset, +) from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( get_asset_depr_schedule_doc, get_depr_schedule, ) -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( +from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as make_invoice, ) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 717435e4caa..d48890dde7b 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -9,6 +9,8 @@ from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowt from erpnext.assets.doctype.asset.asset import ( get_asset_account, get_asset_value_after_depreciation, +) +from erpnext.assets.doctype.asset.mapper import ( make_sales_invoice, ) from erpnext.assets.doctype.asset.test_asset import ( diff --git a/erpnext/buying/doctype/purchase_order/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py index 23aa32f4410..e3e7cbe5bfc 100644 --- a/erpnext/buying/doctype/purchase_order/mapper.py +++ b/erpnext/buying/doctype/purchase_order/mapper.py @@ -199,7 +199,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions @frappe.whitelist() def make_inter_company_sales_order(source_name: str, target_doc: str | Document | None = None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction + from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction return make_inter_company_transaction("Purchase Order", source_name, target_doc) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 85c159ed491..71501ffcc8f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -458,14 +458,14 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( make_inter_company_order(frm) { frappe.model.open_mapped_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_inter_company_sales_order", + method: "erpnext.buying.doctype.purchase_order.mapper.make_inter_company_sales_order", frm: frm, }); } make_purchase_receipt() { frappe.model.open_mapped_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt", + method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_receipt", frm: this.frm, freeze_message: __("Creating Purchase Receipt ..."), }); @@ -473,14 +473,14 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( make_purchase_invoice() { frappe.model.open_mapped_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice", + method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice", frm: this.frm, }); } make_subcontracting_order() { frappe.model.open_mapped_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_subcontracting_order", + method: "erpnext.buying.doctype.purchase_order.mapper.make_subcontracting_order", frm: this.frm, freeze_message: __("Creating Subcontracting Order ..."), }); @@ -492,7 +492,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( __("Material Request"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order", + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", source_doctype: "Material Request", target: me.frm, setters: { @@ -517,7 +517,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( __("Supplier Quotation"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order", + method: "erpnext.buying.doctype.supplier_quotation.mapper.make_purchase_order", source_doctype: "Supplier Quotation", target: me.frm, setters: { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 084b725a4cc..e7cd7eb385f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -29,12 +29,6 @@ from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ) from .mapper import ( - get_mapped_purchase_invoice, - get_mapped_subcontracting_order, - make_inter_company_sales_order, - make_purchase_invoice, - make_purchase_invoice_from_portal, - make_purchase_receipt, make_subcontracting_order, ) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index c361e66229e..4a4a4a35dcb 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -11,19 +11,19 @@ from frappe.utils.data import today from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.party import get_due_date_from_template -from erpnext.buying.doctype.purchase_order.purchase_order import ( +from erpnext.buying.doctype.purchase_order.mapper import ( make_inter_company_sales_order, make_purchase_receipt, ) -from erpnext.buying.doctype.purchase_order.purchase_order import ( +from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_invoice as make_pi_from_po, ) from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order from erpnext.stock.doctype.item.test_item import make_item -from erpnext.stock.doctype.material_request.material_request import make_purchase_order +from erpnext.stock.doctype.material_request.mapper import make_purchase_order from erpnext.stock.doctype.material_request.test_material_request import make_material_request -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( +from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as make_pi_from_pr, ) from erpnext.tests.utils import ERPNextTestSuite @@ -519,7 +519,7 @@ class TestPurchaseOrder(ERPNextTestSuite): self.assertEqual(po.get("items")[0].received_qty, 5) def test_purchase_order_invoice_receipt_workflow(self): - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_purchase_receipt + from erpnext.accounts.doctype.purchase_invoice.mapper import make_purchase_receipt po = create_purchase_order() pi = make_pi_from_po(po.name) @@ -958,14 +958,14 @@ class TestPurchaseOrder(ERPNextTestSuite): def test_internal_transfer_flow(self): from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center - from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + from erpnext.accounts.doctype.sales_invoice.mapper import ( make_inter_company_purchase_invoice, ) - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note, make_sales_invoice, ) - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1) frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) @@ -1106,7 +1106,7 @@ class TestPurchaseOrder(ERPNextTestSuite): self.assertEqual(po.items[0].fg_item_qty, 30) def test_new_sc_flow(self): - from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order + from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order po = create_po_for_sc_testing() sco = make_subcontracting_order(po.name) @@ -1234,7 +1234,7 @@ class TestPurchaseOrder(ERPNextTestSuite): self.assertEqual(frappe.db.get_value(po.doctype, po.name, "advance_payment_status"), "Not Initiated") def test_po_billed_amount_against_return_entry(self): - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note + from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note # Create a Purchase Order and Fully Bill it po = create_purchase_order() diff --git a/erpnext/buying/doctype/request_for_quotation/mapper.py b/erpnext/buying/doctype/request_for_quotation/mapper.py index 1f9878b03ab..05ba754812f 100644 --- a/erpnext/buying/doctype/request_for_quotation/mapper.py +++ b/erpnext/buying/doctype/request_for_quotation/mapper.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from erpnext.accounts.party import get_party_account_currency, get_party_details -from erpnext.stock.doctype.material_request.material_request import set_missing_values +from erpnext.stock.doctype.material_request.mapper import set_missing_values @frappe.whitelist() diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 8baeba950b9..33e09c00de2 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -209,7 +209,7 @@ frappe.ui.form.on("Request for Quotation", { return frappe.call({ type: "GET", - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq", + method: "erpnext.buying.doctype.request_for_quotation.mapper.make_supplier_quotation_from_rfq", args: { source_name: doc.name, for_supplier: args.supplier, @@ -361,7 +361,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll __("Material Request"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation", + method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation", source_doctype: "Material Request", target: me.frm, setters: { @@ -385,7 +385,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll __("Opportunity"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_request_for_quotation", + method: "erpnext.crm.doctype.opportunity.mapper.make_request_for_quotation", source_doctype: "Opportunity", target: me.frm, setters: { @@ -425,7 +425,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll dialog.hide(); erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_item_from_material_requests_based_on_supplier", + method: "erpnext.buying.doctype.request_for_quotation.mapper.get_item_from_material_requests_based_on_supplier", source_name: args.supplier, target: me.frm, setters: { diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 0dce4fce279..cebbece0405 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -15,12 +15,6 @@ from frappe.utils.user import get_user_fullname from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController -from .mapper import ( - create_supplier_quotation, - get_item_from_material_requests_based_on_supplier, - make_supplier_quotation_from_rfq, -) - STANDARD_USERS = ("Guest", "Administrator") diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 6f28a15451b..261fcdfc94a 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -8,13 +8,15 @@ import frappe from frappe.tests import change_settings from frappe.utils import nowdate -from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( +from erpnext.buying.doctype.request_for_quotation.mapper import ( create_supplier_quotation, - get_pdf, make_supplier_quotation_from_rfq, ) +from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( + get_pdf, +) from erpnext.controllers.accounts_controller import InvalidQtyError -from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq +from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity from erpnext.stock.doctype.item.test_item import make_item from erpnext.templates.pages.rfq import check_supplier_has_docname_access diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index b9adc94dd16..2a6794072f5 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -184,7 +184,7 @@ class Supplier(TransactionBase): ) def create_primary_contact(self): - from erpnext.selling.doctype.customer.customer import make_contact + from erpnext.selling.doctype.customer.mapper import make_contact if not self.supplier_primary_contact: if self.mobile_no or self.email_id: @@ -196,7 +196,7 @@ class Supplier(TransactionBase): def create_primary_address(self): from frappe.contacts.doctype.address.address import get_address_display - from erpnext.selling.doctype.customer.customer import make_address + from erpnext.selling.doctype.customer.mapper import make_address if self.flags.is_new_doc and self.get("address_line1"): address = make_address(self) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index deb87234c50..d35be93b209 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -56,7 +56,7 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e __("Material Request"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_supplier_quotation", + method: "erpnext.stock.doctype.material_request.mapper.make_supplier_quotation", source_doctype: "Material Request", target: me.frm, setters: { @@ -91,7 +91,7 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e frappe.throw({ message: __("Please select a Supplier"), title: __("Mandatory") }); } erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq", + method: "erpnext.buying.doctype.request_for_quotation.mapper.make_supplier_quotation_from_rfq", source_doctype: "Request for Quotation", target: me.frm, setters: { @@ -112,13 +112,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e make_purchase_order() { frappe.model.open_mapped_doc({ - method: "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order", + method: "erpnext.buying.doctype.supplier_quotation.mapper.make_purchase_order", frm: this.frm, }); } make_quotation() { frappe.model.open_mapped_doc({ - method: "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_quotation", + method: "erpnext.buying.doctype.supplier_quotation.mapper.make_quotation", frm: this.frm, }); } diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index e267f6228c4..9521769fa82 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -10,8 +10,6 @@ from frappe.utils import getdate, nowdate from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController -from .mapper import make_purchase_invoice, make_purchase_order, make_quotation - form_grid_templates = {"items": "templates/form_grid/item_grid.html"} diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index c271d34b35d..e8e03713177 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -8,7 +8,7 @@ import frappe from frappe.tests import change_settings from frappe.utils import add_days, today -from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order +from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py index 38f2d7426ff..acf29b75043 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py @@ -4,12 +4,12 @@ import frappe from frappe.utils import add_days, today -from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt +from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt from erpnext.buying.report.requested_items_to_order_and_receive.requested_items_to_order_and_receive import ( get_data, ) from erpnext.stock.doctype.item.test_item import create_item -from erpnext.stock.doctype.material_request.material_request import make_purchase_order +from erpnext.stock.doctype.material_request.mapper import make_purchase_order from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 838c32c4276..3e0aa6f0c80 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -810,7 +810,7 @@ class TestAccountsController(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Stock Settings", {"allow_internal_transfer_at_arms_length_price": 1}) def test_16_internal_transfer_at_arms_length_price(self): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice + from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse prepare_data_for_internal_transfer() @@ -2247,7 +2247,7 @@ class TestAccountsController(ERPNextTestSuite): Test that additional discount amount is not copied repeatedly when creating multiple delivery notes from a single sales order with discount_amount set """ - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order # Create a sales order with discount amount @@ -2283,7 +2283,7 @@ class TestAccountsController(ERPNextTestSuite): Test that additional discount amount is not copied repeatedly when creating multiple purchase receipts from a single purchase order with discount_amount set """ - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order # Create a purchase order with discount amount @@ -2319,7 +2319,7 @@ class TestAccountsController(ERPNextTestSuite): Test that discount amount is partially applied when some discount has already been used in previous mapped transactions """ - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order # Create a sales order with discount amount @@ -2357,7 +2357,7 @@ class TestAccountsController(ERPNextTestSuite): Test that discount amount is not adjusted when additional_discount_percentage is set in the source document (as it will be recalculated based on percentage) """ - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order # Create a sales order with discount percentage instead of amount @@ -2385,7 +2385,7 @@ class TestAccountsController(ERPNextTestSuite): Test that discount amount is correctly adjusted when multiple return invoices are created against the same original invoice to prevent over-returning discount """ - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return # Create original sales invoice with discount si = create_sales_invoice(qty=10, rate=100, do_not_submit=True) diff --git a/erpnext/controllers/tests/test_item_wise_inventory_account.py b/erpnext/controllers/tests/test_item_wise_inventory_account.py index 2f45ea31466..97d87f2e348 100644 --- a/erpnext/controllers/tests/test_item_wise_inventory_account.py +++ b/erpnext/controllers/tests/test_item_wise_inventory_account.py @@ -6,8 +6,8 @@ import frappe from frappe.utils import add_days, today from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record -from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt diff --git a/erpnext/controllers/tests/test_mapper.py b/erpnext/controllers/tests/test_mapper.py index 3a804d78b8f..fbd10274147 100644 --- a/erpnext/controllers/tests/test_mapper.py +++ b/erpnext/controllers/tests/test_mapper.py @@ -23,7 +23,7 @@ class TestMapper(ERPNextTestSuite): so, item_list_3 = self.make_sales_order() # Map source docs to target with corresponding mapper method - method = "erpnext.selling.doctype.quotation.quotation.make_sales_order" + method = "erpnext.selling.doctype.quotation.mapper.make_sales_order" updated_so = mapper.map_docs(method, json.dumps([qtn1.name, qtn2.name]), so) # Assert that all inserted items are present in updated sales order diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js index 72356875eb2..42639a4ecec 100644 --- a/erpnext/crm/doctype/lead/lead.js +++ b/erpnext/crm/doctype/lead/lead.js @@ -88,14 +88,14 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller make_customer() { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.lead.lead.make_customer", + method: "erpnext.crm.doctype.lead.mapper.make_customer", frm: this.frm, }); } make_quotation() { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.lead.lead.make_quotation", + method: "erpnext.crm.doctype.lead.mapper.make_quotation", frm: this.frm, }); } @@ -171,7 +171,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller callback: function (r) { if (!r.exc) { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.lead.lead.make_opportunity", + method: "erpnext.crm.doctype.lead.mapper.make_opportunity", frm: frm, }); } @@ -184,7 +184,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller d.show(); } else { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.lead.lead.make_opportunity", + method: "erpnext.crm.doctype.lead.mapper.make_opportunity", frm: frm, }); } diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index e5a6ce7632d..d62e81271b4 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -14,9 +14,7 @@ from frappe.utils.data import DateTimeLikeObject from erpnext.accounts.party import set_taxes from erpnext.controllers.selling_controller import SellingController from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events -from erpnext.selling.doctype.customer.customer import parse_full_name - -from .mapper import make_customer, make_lead_from_communication, make_opportunity, make_quotation +from erpnext.selling.doctype.customer.mapper import parse_full_name class Lead(SellingController, CRMNote): diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py index 7f1ac27d5cd..01f3f117614 100644 --- a/erpnext/crm/doctype/lead/test_lead.py +++ b/erpnext/crm/doctype/lead/test_lead.py @@ -4,14 +4,14 @@ import frappe from frappe.utils import random_string, today -from erpnext.crm.doctype.lead.lead import make_opportunity +from erpnext.crm.doctype.lead.mapper import make_opportunity from erpnext.crm.utils import get_linked_prospect from erpnext.tests.utils import ERPNextTestSuite class TestLead(ERPNextTestSuite): def test_make_customer(self): - from erpnext.crm.doctype.lead.lead import make_customer + from erpnext.crm.doctype.lead.mapper import make_customer lead = frappe.db.get_all("Lead", {"lead_name": "_Test Lead"})[0].name @@ -41,7 +41,7 @@ class TestLead(ERPNextTestSuite): self.assertEqual(contact_doc.has_link(customer.doctype, customer.name), True) def test_make_customer_from_organization(self): - from erpnext.crm.doctype.lead.lead import make_customer + from erpnext.crm.doctype.lead.mapper import make_customer lead = frappe.db.get_all("Lead", {"lead_name": "_Test Lead 1"})[0].name customer = make_customer(lead) diff --git a/erpnext/crm/doctype/opportunity/mapper.py b/erpnext/crm/doctype/opportunity/mapper.py index b3a66614613..55e081cfb09 100644 --- a/erpnext/crm/doctype/opportunity/mapper.py +++ b/erpnext/crm/doctype/opportunity/mapper.py @@ -128,7 +128,7 @@ def make_supplier_quotation(source_name: str, target_doc: str | Document | None def make_opportunity_from_communication( communication: str, company: str, ignore_communication_links: bool = False ): - from erpnext.crm.doctype.lead.lead import make_lead_from_communication + from erpnext.crm.doctype.lead.mapper import make_lead_from_communication doc = frappe.get_doc("Communication", communication) diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index 1bda0e5568f..d2a1b5c504b 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -40,7 +40,7 @@ frappe.ui.form.on("Opportunity", { erpnext.utils.get_party_details(frm); } else if (frm.doc.opportunity_from == "Lead") { erpnext.utils.map_current_doc({ - method: "erpnext.crm.doctype.lead.lead.make_opportunity", + method: "erpnext.crm.doctype.lead.mapper.make_opportunity", source_name: frm.doc.party_name, frm: frm, }); @@ -204,14 +204,14 @@ frappe.ui.form.on("Opportunity", { make_supplier_quotation: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_supplier_quotation", + method: "erpnext.crm.doctype.opportunity.mapper.make_supplier_quotation", frm: frm, }); }, make_request_for_quotation: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_request_for_quotation", + method: "erpnext.crm.doctype.opportunity.mapper.make_request_for_quotation", frm: frm, }); }, @@ -341,14 +341,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { create_quotation() { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_quotation", + method: "erpnext.crm.doctype.opportunity.mapper.make_quotation", frm: this.frm, }); } make_customer() { frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_customer", + method: "erpnext.crm.doctype.opportunity.mapper.make_customer", frm: this.frm, }); } diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 17d321a88d6..6dc5f6a47b4 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -22,14 +22,6 @@ from erpnext.crm.utils import ( from erpnext.setup.utils import get_exchange_rate from erpnext.utilities.transaction_base import TransactionBase -from .mapper import ( - make_customer, - make_opportunity_from_communication, - make_quotation, - make_request_for_quotation, - make_supplier_quotation, -) - class Opportunity(TransactionBase, CRMNote): # begin: auto-generated types diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 7032d3882ac..62fad25a574 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -4,9 +4,9 @@ import frappe from frappe.utils import now_datetime, random_string, today -from erpnext.crm.doctype.lead.lead import make_customer +from erpnext.crm.doctype.lead.mapper import make_customer from erpnext.crm.doctype.lead.test_lead import make_lead -from erpnext.crm.doctype.opportunity.opportunity import make_quotation +from erpnext.crm.doctype.opportunity.mapper import make_quotation from erpnext.crm.utils import get_linked_communication_list from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js index 43c7e1c0204..1720448bc9a 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js @@ -65,7 +65,7 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap __("Sales Order"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_schedule", + method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_schedule", source_doctype: "Sales Order", target: me.frm, setters: { diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js index 0a05791b1e9..fa583d8a45e 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js @@ -126,7 +126,7 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui. return; } erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_visit", + method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_visit", source_doctype: "Sales Order", target: me.frm, setters: { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 795136d2374..51441e87430 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -368,7 +368,7 @@ frappe.ui.form.on("Job Card", { if (frm.doc.docstatus === 1 && frm.doc.for_quantity > frm.doc.manufactured_qty) { frm.add_custom_button(__("Make Subcontracting PO"), () => { frappe.model.open_mapped_doc({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_subcontracting_po", + method: "erpnext.manufacturing.doctype.job_card.mapper.make_subcontracting_po", frm: frm, }); }).addClass("btn-primary"); @@ -483,7 +483,7 @@ frappe.ui.form.on("Job Card", { make_corrective_job_card(frm, operation, for_operation) { frappe.call({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_corrective_job_card", + method: "erpnext.manufacturing.doctype.job_card.mapper.make_corrective_job_card", args: { source_name: frm.doc.name, operation: operation, @@ -816,7 +816,7 @@ frappe.ui.form.on("Job Card", { make_material_request(frm) { frappe.model.open_mapped_doc({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_material_request", + method: "erpnext.manufacturing.doctype.job_card.mapper.make_material_request", frm: frm, run_link_triggers: true, }); @@ -824,7 +824,7 @@ frappe.ui.form.on("Job Card", { make_stock_entry(frm) { frappe.model.open_mapped_doc({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", + method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry", frm: frm, run_link_triggers: true, }); diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 45be24b8ad7..c8ee5688fe3 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -36,7 +36,9 @@ from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import get_subcontracting_boms_for_finished_goods, ) -from .mapper import make_corrective_job_card, make_material_request, make_stock_entry, make_subcontracting_po +from .mapper import ( + make_stock_entry, +) class OverlapError(frappe.ValidationError): diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index feeb758c8e7..5916e4f6116 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -12,10 +12,12 @@ from erpnext.manufacturing.doctype.job_card.job_card import ( JobCardOverTransferError, OperationMismatchError, OverlapError, +) +from erpnext.manufacturing.doctype.job_card.mapper import ( make_corrective_job_card, make_material_request, ) -from erpnext.manufacturing.doctype.job_card.job_card import ( +from erpnext.manufacturing.doctype.job_card.mapper import ( make_stock_entry as make_stock_entry_from_jc, ) from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record @@ -552,7 +554,7 @@ class TestJobCard(ERPNextTestSuite): corrective_job_card.submit() wo.reload() - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_stock_entry_for_wo, ) @@ -623,7 +625,7 @@ class TestJobCard(ERPNextTestSuite): assertStatus("Cancelled") def test_job_card_material_request_and_bom_details(self): - from erpnext.stock.doctype.material_request.material_request import make_stock_entry + from erpnext.stock.doctype.material_request.mapper import make_stock_entry create_bom_with_multiple_operations() work_order = make_wo_with_transfer_against_jc() @@ -647,7 +649,7 @@ class TestJobCard(ERPNextTestSuite): setup_bom, setup_operations, ) - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_stock_entry_for_wo, ) from erpnext.stock.doctype.item.test_item import make_item @@ -788,10 +790,10 @@ class TestJobCard(ERPNextTestSuite): setup_bom, setup_operations, ) - from erpnext.manufacturing.doctype.work_order.work_order import make_job_card - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_stock_entry_for_wo, ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -1072,7 +1074,7 @@ class TestJobCard(ERPNextTestSuite): job_card.save() job_card.submit() - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_stock_entry_for_wo, ) @@ -1091,10 +1093,10 @@ class TestJobCard(ERPNextTestSuite): setup_bom, setup_operations, ) - from erpnext.manufacturing.doctype.work_order.work_order import make_job_card - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_stock_entry_for_wo, ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 19b04032a3b..22879da6eb5 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -10,9 +10,9 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_sales_orders, get_warehouse_list, ) +from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_se_from_wo from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError -from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_se_from_wo -from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note +from erpnext.selling.doctype.sales_order.mapper import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( @@ -523,13 +523,13 @@ class TestProductionPlan(ERPNextTestSuite): ) def make_purchase_receipt_from_po(po_doc): - from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order + from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order from erpnext.controllers.subcontracting_controller import make_rm_stock_entry from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( make_subcontracting_receipt, ) - from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( + from erpnext.subcontracting.doctype.subcontracting_receipt.mapper import ( make_purchase_receipt as scr_make_purchase_receipt, ) @@ -2210,9 +2210,9 @@ class TestProductionPlan(ERPNextTestSuite): self.assertEqual(mr_items_dict["RM Item 2"], 80) def test_stock_reservation_against_production_plan(self): - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom - from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.material_request.mapper import make_purchase_order frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) @@ -2322,9 +2322,9 @@ class TestProductionPlan(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) def test_stock_reservation_of_serial_nos_against_production_plan(self): - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom - from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.material_request.mapper import make_purchase_order frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) @@ -2469,9 +2469,9 @@ class TestProductionPlan(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) def test_stock_reservation_of_batch_nos_against_production_plan(self): - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom - from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.material_request.mapper import make_purchase_order frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 4ae120ece7f..d56c83e1cd8 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -9,8 +9,12 @@ from frappe.tests import timeout from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError -from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc +from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry as make_stock_entry_from_jc from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.doctype.work_order.mapper import ( + make_stock_entry, + make_stock_return_entry, +) from erpnext.manufacturing.doctype.work_order.work_order import ( CapacityError, ItemHasVariantError, @@ -18,8 +22,6 @@ from erpnext.manufacturing.doctype.work_order.work_order import ( StockOverProductionError, close_work_order, make_job_card, - make_stock_entry, - make_stock_return_entry, stop_unstop, ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 5131b30c889..7586b4ab956 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -289,7 +289,7 @@ frappe.ui.form.on("Work Order", { create_stock_return_entry: function (frm) { frappe.call({ - method: "erpnext.manufacturing.doctype.work_order.work_order.make_stock_return_entry", + method: "erpnext.manufacturing.doctype.work_order.mapper.make_stock_return_entry", args: { work_order: frm.doc.name, }, @@ -445,7 +445,7 @@ frappe.ui.form.on("Work Order", { frappe.msgprint(__("Disassemble Qty cannot be less than or equal to 0.")); return; } - return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", { + return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", { work_order_id: frm.doc.name, purpose: "Disassemble", qty: data.qty, @@ -822,7 +822,7 @@ erpnext.work_order = { .show_prompt_for_qty_input(frm, purpose, qty, 1) .then((data) => { return frappe.xcall( - "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", + "erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", { work_order_id: frm.doc.name, purpose: purpose, @@ -1110,7 +1110,7 @@ erpnext.work_order = { make_se: function (frm, purpose, qty, is_additional_transfer_entry) { if (qty) { frappe - .xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", { + .xcall("erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", { work_order_id: frm.doc.name, purpose: purpose, qty: qty, @@ -1123,14 +1123,11 @@ erpnext.work_order = { } else { this.show_prompt_for_qty_input(frm, purpose) .then((data) => { - return frappe.xcall( - "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", - { - work_order_id: frm.doc.name, - purpose: purpose, - qty: data.qty, - } - ); + return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", { + work_order_id: frm.doc.name, + purpose: purpose, + qty: data.qty, + }); }) .then((stock_entry) => { frappe.model.sync(stock_entry); @@ -1142,7 +1139,7 @@ erpnext.work_order = { create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") { this.show_prompt_for_qty_input(frm, purpose) .then((data) => { - return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", { + return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", { source_name: frm.doc.name, for_qty: data.qty, }); @@ -1166,7 +1163,7 @@ erpnext.work_order = { } frappe.call({ - method: "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", + method: "erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", args: { work_order_id: frm.doc.name, purpose: "Material Consumption for Manufacture", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f46c1b4d83e..f6cd50b922d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -40,8 +40,6 @@ from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company from erpnext.utilities.transaction_base import validate_uom_is_integer -from .mapper import create_pick_list, make_stock_entry, make_stock_return_entry - class OverProductionError(frappe.ValidationError): pass diff --git a/erpnext/manufacturing/doctype/workstation/workstation.js b/erpnext/manufacturing/doctype/workstation/workstation.js index 4c1c9d2c976..dae339fd716 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.js +++ b/erpnext/manufacturing/doctype/workstation/workstation.js @@ -402,7 +402,7 @@ class WorkstationDashboard { if (r.message) { me.prepare_materials_modal(r.message, job_card, (job_card) => { frappe.call({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", + method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry", args: { source_name: job_card, }, diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index aa37c34ef89..a15f8bfb867 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -6,7 +6,7 @@ from frappe.utils import add_days, getdate, nowdate from erpnext.projects.doctype.project_template.test_project_template import make_project_template from erpnext.projects.doctype.task.test_task import create_task -from erpnext.selling.doctype.sales_order.sales_order import make_project as make_project_from_so +from erpnext.selling.doctype.sales_order.mapper import make_project as make_project_from_so from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index cf3d9a17b2d..9495e0ef0d9 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -5,7 +5,7 @@ import datetime import frappe from frappe.utils import add_to_date, now_datetime, nowdate -from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return +from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.projects.doctype.task.test_task import create_task from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice diff --git a/erpnext/public/js/communication.js b/erpnext/public/js/communication.js index c8905e14af2..0eb276e10f1 100644 --- a/erpnext/public/js/communication.js +++ b/erpnext/public/js/communication.js @@ -45,7 +45,7 @@ frappe.ui.form.on("Communication", { make_lead_from_communication: (frm) => { return frappe.call({ - method: "erpnext.crm.doctype.lead.lead.make_lead_from_communication", + method: "erpnext.crm.doctype.lead.mapper.make_lead_from_communication", args: { communication: frm.doc.name, }, @@ -89,7 +89,7 @@ frappe.ui.form.on("Communication", { fields, (data) => { frappe.call({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_opportunity_from_communication", + method: "erpnext.crm.doctype.opportunity.mapper.make_opportunity_from_communication", args: { communication: frm.doc.name, company: data.company, diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index a2abaa5527d..0ee8c5555f4 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -13,7 +13,7 @@ frappe.ui.form.on("Customer", { frm.make_methods = { Quotation: () => frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.customer.customer.make_quotation", + method: "erpnext.selling.doctype.customer.mapper.make_quotation", frm: frm, }), "Sales Order": () => @@ -24,12 +24,12 @@ frappe.ui.form.on("Customer", { }), Opportunity: () => frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.customer.customer.make_opportunity", + method: "erpnext.selling.doctype.customer.mapper.make_opportunity", frm: frm, }), "Payment Entry": () => frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.customer.customer.make_payment_entry", + method: "erpnext.selling.doctype.customer.mapper.make_payment_entry", frm: frm, }), "Pricing Rule": () => frm.trigger("make_pricing_rule"), diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index f4787a6ab9b..a79bc9e935a 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -30,10 +30,6 @@ from erpnext.utilities.transaction_base import TransactionBase from .mapper import ( make_address, make_contact, - make_opportunity, - make_payment_entry, - make_quotation, - parse_full_name, ) diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 4bd26408c67..a20b72805aa 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -12,6 +12,8 @@ from erpnext.exceptions import PartyDisabled, PartyFrozen from erpnext.selling.doctype.customer.customer import ( get_credit_limit, get_customer_outstanding, +) +from erpnext.selling.doctype.customer.mapper import ( parse_full_name, ) from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/selling/doctype/installation_note/installation_note.js b/erpnext/selling/doctype/installation_note/installation_note.js index 4e13fa76d5c..43badd36c06 100644 --- a/erpnext/selling/doctype/installation_note/installation_note.js +++ b/erpnext/selling/doctype/installation_note/installation_note.js @@ -59,7 +59,7 @@ erpnext.selling.InstallationNote = class InstallationNote extends frappe.ui.form __("From Delivery Note"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_installation_note", + method: "erpnext.stock.doctype.delivery_note.mapper.make_installation_note", source_doctype: "Delivery Note", target: me.frm, date_field: "posting_date", diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index a692aa3e3ca..895e33415f1 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -154,7 +154,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. __("Opportunity"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_quotation", + method: "erpnext.crm.doctype.opportunity.mapper.make_quotation", source_doctype: "Opportunity", target: me.frm, setters: [ @@ -195,7 +195,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. this.show_alternative_items_dialog(); } else { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + method: "erpnext.selling.doctype.quotation.mapper.make_sales_order", frm: me.frm, }); } @@ -362,7 +362,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. ], primary_action: function () { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + method: "erpnext.selling.doctype.quotation.mapper.make_sales_order", frm: me.frm, args: { selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children(), diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 864106613bb..b2d6e7838a1 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -10,12 +10,7 @@ from frappe.utils import getdate, nowdate from erpnext.controllers.selling_controller import SellingController from .mapper import ( - _make_sales_order, - create_customer_from_lead, - create_customer_from_prospect, get_ordered_items, - make_sales_invoice, - make_sales_order, ) form_grid_templates = {"items": "templates/form_grid/item_grid.html"} diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 2f25eb4ac75..fd2b40d3a18 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -8,7 +8,7 @@ from frappe.tests import change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate -from erpnext.selling.doctype.quotation.quotation import make_sales_order +from erpnext.selling.doctype.quotation.mapper import make_sales_order from erpnext.tests.utils import ERPNextTestSuite @@ -243,7 +243,7 @@ class TestQuotation(ERPNextTestSuite): {"automatically_fetch_payment_terms": 1}, ) def test_make_sales_order_terms_copied(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0]) quotation.transaction_date = nowdate() @@ -256,7 +256,7 @@ class TestQuotation(ERPNextTestSuite): self.assertTrue(sales_order.get("payment_schedule")) def test_do_not_add_ordered_items_in_new_sales_order(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order from erpnext.stock.doctype.item.test_item import make_item item = make_item("_Test Item for Quotation for SO", {"is_stock_item": 1}) @@ -321,7 +321,7 @@ class TestQuotation(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0) def test_maintain_rate_in_sales_cycle_is_enforced(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order maintain_rate = frappe.db.get_single_value("Selling Settings", "maintain_same_sales_rate") frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1) @@ -339,7 +339,7 @@ class TestQuotation(ERPNextTestSuite): frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", maintain_rate) def test_make_sales_order_with_different_currency(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0]) quotation.transaction_date = nowdate() @@ -359,7 +359,7 @@ class TestQuotation(ERPNextTestSuite): self.assertNotEqual(sales_order.currency, quotation.currency) def test_make_sales_order(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0]) quotation.transaction_date = nowdate() @@ -391,7 +391,7 @@ class TestQuotation(ERPNextTestSuite): }, ) def test_make_sales_order_with_terms(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0]) quotation.transaction_date = nowdate() @@ -441,7 +441,7 @@ class TestQuotation(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, quotation.validate) def test_so_from_expired_quotation(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order frappe.db.set_single_value("Selling Settings", "allow_sales_order_creation_for_expired_quotation", 0) @@ -457,8 +457,8 @@ class TestQuotation(ERPNextTestSuite): make_sales_order(quotation.name) def test_create_quotation_with_margin(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.quotation.mapper import make_sales_order + from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note, make_sales_invoice, ) @@ -550,7 +550,7 @@ class TestQuotation(ERPNextTestSuite): def test_product_bundle_mapping_on_creating_so(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order from erpnext.stock.doctype.item.test_item import make_item make_item("_Test Product Bundle", {"is_stock_item": 0}) @@ -877,7 +877,7 @@ class TestQuotation(ERPNextTestSuite): self.assertEqual(quotation.items[1].amount, 240) def test_alternative_items_sales_order_mapping_with_stock_items(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order from erpnext.stock.doctype.item.test_item import make_item frappe.flags.args = frappe._dict() @@ -1002,7 +1002,7 @@ class TestQuotation(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1}) def test_so_from_zero_qty_quotation(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order from erpnext.stock.doctype.item.test_item import make_item make_item("_Test Item 2", {"is_stock_item": 1}) @@ -1035,7 +1035,7 @@ class TestQuotation(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_duplicate_items_in_quotation(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order from erpnext.stock.doctype.item.test_item import make_item # item code same but description different @@ -1138,7 +1138,7 @@ class TestQuotation(ERPNextTestSuite): {"automatically_fetch_payment_terms": 1}, ) def test_make_sales_order_with_payment_terms(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.selling.doctype.quotation.mapper import make_sales_order template = frappe.get_doc( { diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index 967a423451c..2a517934c92 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -948,7 +948,7 @@ def make_raw_material_request( @frappe.whitelist() def make_inter_company_purchase_order(source_name: str, target_doc: str | Document | None = None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction + from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction return make_inter_company_transaction("Sales Order", source_name, target_doc) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 23b7165c17a..5a69eb223e4 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -179,7 +179,7 @@ frappe.ui.form.on("Sales Order", { __("Purchase Order"), () => { erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_inter_company_sales_order", + method: "erpnext.buying.doctype.purchase_order.mapper.make_inter_company_sales_order", source_doctype: "Purchase Order", target: frm, setters: [ @@ -1229,7 +1229,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex __("Quotation"), function () { let d = erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + method: "erpnext.selling.doctype.quotation.mapper.make_sales_order", source_doctype: "Quotation", target: me.frm, setters: [ @@ -1278,7 +1278,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex create_pick_list() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", + method: "erpnext.selling.doctype.sales_order.mapper.create_pick_list", frm: this.frm, }); } @@ -1406,7 +1406,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_production_plan() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_production_plan", + method: "erpnext.selling.doctype.sales_order.mapper.make_production_plan", frm: this.frm, }); } @@ -1421,7 +1421,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_material_request() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_material_request", + method: "erpnext.selling.doctype.sales_order.mapper.make_material_request", frm: this.frm, }); } @@ -1519,7 +1519,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex primary_action: function () { var data = d.get_values(); me.frm.call({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_raw_material_request", + method: "erpnext.selling.doctype.sales_order.mapper.make_raw_material_request", args: { items: data, company: me.frm.doc.company, @@ -1614,7 +1614,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_delivery_note(delivery_dates, for_reserved_stock = false) { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", + method: "erpnext.selling.doctype.sales_order.mapper.make_delivery_note", frm: this.frm, args: { delivery_dates, @@ -1627,35 +1627,35 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_sales_invoice() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice", + method: "erpnext.selling.doctype.sales_order.mapper.make_sales_invoice", frm: this.frm, }); } make_maintenance_schedule() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_schedule", + method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_schedule", frm: this.frm, }); } make_project() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_project", + method: "erpnext.selling.doctype.sales_order.mapper.make_project", frm: this.frm, }); } make_inter_company_order() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_inter_company_purchase_order", + method: "erpnext.selling.doctype.sales_order.mapper.make_inter_company_purchase_order", frm: this.frm, }); } make_maintenance_visit() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_visit", + method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_visit", frm: this.frm, }); } @@ -1769,7 +1769,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex dialog.hide(); return frappe.call({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order", + method: "erpnext.selling.doctype.sales_order.mapper.make_purchase_order", freeze_message: __("Creating Purchase Order ..."), args: { source_name: me.frm.doc.name, @@ -1889,7 +1889,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_subcontracting_inward_order() { frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order", + method: "erpnext.selling.doctype.sales_order.mapper.make_subcontracting_inward_order", frm: this.frm, freeze_message: __("Creating Subcontracting Inward Order ..."), }); diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index bee4dfb4ee2..cb3de5e560d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -31,22 +31,6 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor from erpnext.stock.get_item_details import get_default_bom from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty -from .mapper import ( - create_pick_list, - make_delivery_note, - make_inter_company_purchase_order, - make_maintenance_schedule, - make_maintenance_visit, - make_material_request, - make_production_plan, - make_project, - make_purchase_order, - make_raw_material_request, - make_sales_invoice, - make_subcontracting_inward_order, - make_work_orders, -) - form_grid_templates = {"items": "templates/form_grid/item_grid.html"} diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index da46870b958..08ad447edaa 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -19,8 +19,7 @@ from erpnext.maintenance.doctype.maintenance_visit.test_maintenance_visit import ) from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle -from erpnext.selling.doctype.sales_order.sales_order import ( - WarehouseRequired, +from erpnext.selling.doctype.sales_order.mapper import ( create_pick_list, make_delivery_note, make_material_request, @@ -29,6 +28,9 @@ from erpnext.selling.doctype.sales_order.sales_order import ( make_sales_invoice, make_work_orders, ) +from erpnext.selling.doctype.sales_order.sales_order import ( + WarehouseRequired, +) from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_bin_details @@ -252,7 +254,7 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(len(si1.get("items")), 0) def test_so_billed_amount_against_return_entry(self): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return so = make_sales_order(do_not_submit=True) so.submit() @@ -1163,7 +1165,7 @@ class TestSalesOrder(ERPNextTestSuite): def test_drop_shipping(self): from erpnext.buying.doctype.purchase_order.purchase_order import update_status - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_purchase_order, ) from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status @@ -1259,7 +1261,7 @@ class TestSalesOrder(ERPNextTestSuite): so.cancel() def test_drop_shipping_partial_order(self): - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_purchase_order, ) from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status @@ -1319,7 +1321,7 @@ class TestSalesOrder(ERPNextTestSuite): def test_drop_shipping_full_for_default_suppliers(self): """Test if multiple POs are generated in one go against different default suppliers.""" - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_purchase_order, ) @@ -1363,7 +1365,7 @@ class TestSalesOrder(ERPNextTestSuite): Tests if the the Product Bundles in the Items table of Sales Orders are replaced with their child items(from the Packed Items table) on creating a Purchase Order from it. """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.mapper import make_purchase_order product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) @@ -1393,7 +1395,7 @@ class TestSalesOrder(ERPNextTestSuite): """ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.mapper import make_purchase_order product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) @@ -1915,7 +1917,7 @@ class TestSalesOrder(ERPNextTestSuite): def test_so_back_updated_from_wo_via_mr(self): "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_se_from_wo, ) from erpnext.stock.doctype.material_request.material_request import raise_work_orders @@ -2346,7 +2348,7 @@ class TestSalesOrder(ERPNextTestSuite): self.assertTrue(row.warehouse == warehouse) def test_pick_list_for_batch(self): - from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note + from erpnext.stock.doctype.pick_list.mapper import create_delivery_note batch_item = make_item( "_Test Batch Item for Pick LIST", @@ -2664,7 +2666,7 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(so.status, "To Deliver and Bill") def test_item_tax_transfer_from_sales_to_purchase(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.mapper import make_purchase_order item_tax = frappe.new_doc("Item Tax Template") item_tax.title = "Test Item Tax Template" diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index eefc932bcc1..eeafb7ae5ec 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -617,7 +617,7 @@ erpnext.PointOfSale.Controller = class { method: doc.doctype == "POS Invoice" ? "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return" - : "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return", + : "erpnext.accounts.doctype.sales_invoice.mapper.make_sales_return", args: { source_name: doc.name, target_doc: this.frm.doc, diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py index 1b583967a47..ca7e338e936 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -3,7 +3,7 @@ import datetime import frappe from frappe.utils import add_days, add_months, nowdate -from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice +from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import ( execute, diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py index 3f540a3b94a..166ff34b7a0 100644 --- a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py +++ b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py @@ -4,7 +4,7 @@ from frappe.utils import add_months, nowdate -from erpnext.selling.doctype.sales_order.sales_order import make_material_request +from erpnext.selling.doctype.sales_order.mapper import make_material_request from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import ( execute, diff --git a/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py index 1a200c7eba3..4d351742ee6 100644 --- a/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py @@ -1,7 +1,7 @@ import frappe from frappe.utils import add_days -from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice +from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.report.sales_order_analysis.sales_order_analysis import execute from erpnext.stock.doctype.item.test_item import create_item diff --git a/erpnext/setup/demo.py b/erpnext/setup/demo.py index c460b1520c4..29049a54794 100644 --- a/erpnext/setup/demo.py +++ b/erpnext/setup/demo.py @@ -11,8 +11,8 @@ from frappe.utils import add_days, get_url_to_form, getdate from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.utils import get_fiscal_year -from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice -from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice +from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice +from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index ed4a8c5509c..284cabe8255 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -387,7 +387,7 @@ class TestBatch(ERPNextTestSuite): self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90) def test_ignore_reserved_qty(self): - from erpnext.selling.doctype.sales_order.sales_order import create_pick_list + from erpnext.selling.doctype.sales_order.mapper import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order batch_item_name = "Reserve Batch Item" diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 1b7f147f30c..6c5e1fadf04 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -89,7 +89,7 @@ frappe.ui.form.on("Delivery Note", { __("Credit Note"), function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", + method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice", frm: cur_frm, }); }, @@ -114,7 +114,7 @@ frappe.ui.form.on("Delivery Note", { __(button_label), function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_inter_company_purchase_receipt", + method: "erpnext.stock.doctype.delivery_note.mapper.make_inter_company_purchase_receipt", frm: frm, }); }, @@ -163,7 +163,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( }); } erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", + method: "erpnext.selling.doctype.sales_order.mapper.make_delivery_note", args: { for_reserved_stock: 1, }, @@ -205,7 +205,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( }); } erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.pick_list.pick_list.create_dn_for_pick_lists", + method: "erpnext.stock.doctype.pick_list.mapper.create_dn_for_pick_lists", source_doctype: "Pick List", target: me.frm, setters: [ @@ -296,7 +296,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( __("Packing Slip"), function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip", + method: "erpnext.stock.doctype.delivery_note.mapper.make_packing_slip", frm: me.frm, }); }, @@ -367,7 +367,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( make_shipment() { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_shipment", + method: "erpnext.stock.doctype.delivery_note.mapper.make_shipment", frm: this.frm, }); } @@ -383,28 +383,28 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( make_sales_invoice() { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", + method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice", frm: this.frm, }); } make_installation_note() { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_installation_note", + method: "erpnext.stock.doctype.delivery_note.mapper.make_installation_note", frm: this.frm, }); } make_sales_return() { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_return", + method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_return", frm: this.frm, }); } make_delivery_trip() { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_delivery_trip", + method: "erpnext.stock.doctype.delivery_note.mapper.make_delivery_trip", frm: cur_frm, }); } diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index b4a89673c8f..a86055692b6 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -14,14 +14,7 @@ from erpnext.controllers.selling_controller import SellingController from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from .mapper import ( - make_delivery_trip, - make_installation_note, - make_inter_company_purchase_receipt, - make_inter_company_transaction, - make_packing_slip, make_sales_invoice, - make_sales_return, - make_shipment, ) form_grid_templates = {"items": "templates/form_grid/item_grid.html"} diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index 56698ccf76b..bf55636133a 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -43,7 +43,7 @@ frappe.listview_settings["Delivery Note"] = { type: "POST", method: "frappe.model.mapper.map_docs", args: { - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_delivery_trip", + method: "erpnext.stock.doctype.delivery_note.mapper.make_delivery_trip", source_names: docnames, target_doc: cur_frm.doc, }, diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py index ad9417f4db8..782cd3afd7b 100644 --- a/erpnext/stock/doctype/delivery_note/mapper.py +++ b/erpnext/stock/doctype/delivery_note/mapper.py @@ -406,7 +406,7 @@ def make_inter_company_purchase_receipt(source_name: str, target_doc: str | Docu def make_inter_company_transaction(doctype: str, source_name: str, target_doc=None): - from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + from erpnext.accounts.doctype.sales_invoice.mapper import ( get_inter_company_details, set_purchase_references, update_address, diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 58f5d71b3d4..c0dd01c2433 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -19,7 +19,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import ( create_dn_against_so, make_sales_order, ) -from erpnext.stock.doctype.delivery_note.delivery_note import ( +from erpnext.stock.doctype.delivery_note.mapper import ( make_delivery_trip, make_sales_invoice, ) @@ -218,7 +218,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(cstr(serial_no.get(field)), value) def test_delivery_note_return_against_denormalized_serial_no(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos frappe.flags.ignore_serial_batch_bundle_validation = True @@ -1012,7 +1012,7 @@ class TestDeliveryNote(ERPNextTestSuite): def test_dn_billing_status_case2(self): # SO -> SI and SO -> DN1, DN2 - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note, make_sales_invoice, ) @@ -1054,7 +1054,7 @@ class TestDeliveryNote(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": True}) def test_sales_invoice_qty_after_return(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item = make_item( "Test Sales Invoice Qty After Return", @@ -1085,8 +1085,8 @@ class TestDeliveryNote(ERPNextTestSuite): def test_dn_billing_status_case3(self): # SO -> DN1 -> SI and SO -> SI and SO -> DN2 - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note - from erpnext.selling.doctype.sales_order.sales_order import ( + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import ( make_sales_invoice as make_sales_invoice_from_so, ) @@ -1136,8 +1136,8 @@ class TestDeliveryNote(ERPNextTestSuite): def test_dn_billing_status_case4(self): # SO -> SI -> DN - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice so = make_sales_order(po_no="12345") @@ -1160,7 +1160,7 @@ class TestDeliveryNote(ERPNextTestSuite): def test_dn_billing_status_case5(self): # SO -> SI(with update stock partial invoice) # SO -> DN - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice so = make_sales_order(po_no="12345") @@ -1260,8 +1260,8 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(expected_values[gle.account]["cost_center"], gle.cost_center) def test_make_sales_invoice_from_dn_for_returned_qty(self): - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note + from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice so = make_sales_order(qty=2) so.submit() @@ -1280,7 +1280,7 @@ class TestDeliveryNote(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_make_sales_invoice_from_dn_with_returned_qty_duplicate_items(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice + from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice dn = create_delivery_note(qty=8, do_not_submit=True) dn.append( @@ -1387,8 +1387,8 @@ class TestDeliveryNote(ERPNextTestSuite): # | # |---> DN(Partial Sales Return) ---> SI(Credit Note) - from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice so = make_sales_order(qty=10) si = make_sales_invoice(so.name) @@ -1400,7 +1400,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(dn.items[0].returned_qty, 0) self.assertEqual(dn.per_billed, 100) - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice + from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice dn1 = create_delivery_note(is_return=1, return_against=dn.name, qty=-3) si1 = make_sales_invoice(dn1.name) @@ -1569,7 +1569,7 @@ class TestDeliveryNote(ERPNextTestSuite): def reserved_qty_check(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note from erpnext.stock.stock_balance import get_reserved_qty dont_reserve_qty = frappe.db.get_single_value( @@ -1776,7 +1776,7 @@ class TestDeliveryNote(ERPNextTestSuite): def test_internal_transfer_for_non_stock_item(self): from erpnext.selling.doctype.customer.test_customer import create_internal_customer - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note item = make_item(properties={"is_stock_item": 0}).name warehouse = "_Test Warehouse - _TC" @@ -1965,7 +1965,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(sle_data.stock_value_difference, 200.0 * -1) def test_sales_return_batch_no_for_batched_item_in_dn(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item_code = make_item( "Test Batched Item for Sales Return 11", @@ -1994,7 +1994,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(batch_no, returned_batch_no) def test_partial_sales_return_batch_no_for_batched_item_in_dn(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item_code = make_item( "Test Partial Batched Item for Sales Return 11", @@ -2041,7 +2041,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(sabb_qty, 2) def test_sales_return_serial_no_for_serial_item_in_dn(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item_code = make_item( "Test Serial Item for Sales Return 11", @@ -2190,7 +2190,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(sn.warranty_period, 100) def test_batch_return_dn(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item_code = make_item( "Test Batch Return DN Item 1", @@ -2231,7 +2231,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(stock_value_difference, 100.0 * 5) def test_delivery_note_return_valuation_without_use_serial_batch_field(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return batch_item = make_item( "_Test Delivery Note Return Valuation Batch Item", @@ -2351,7 +2351,7 @@ class TestDeliveryNote(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_delivery_note_return_valuation_with_use_serial_batch_field(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return batch_item = make_item( "_Test Delivery Note Return Valuation WITH Batch Item", @@ -2561,7 +2561,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertTrue(row.serial_no) def test_delivery_note_return_for_batch_item_with_different_warehouse(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse batch_item = make_item( @@ -2631,7 +2631,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(d.incoming_rate, batch_no_valuation[d.batch_no]) def test_delivery_note_per_billed_after_return(self): - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note so = make_sales_order(qty=2) dn = make_delivery_note(so.name) @@ -2699,7 +2699,7 @@ class TestDeliveryNote(ERPNextTestSuite): def test_sales_return_for_product_bundle(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return from erpnext.stock.doctype.item.test_item import make_item rm_items = [] @@ -3150,7 +3150,7 @@ class TestDeliveryNote(ERPNextTestSuite): def test_sdbnb_skip_for_dn_against_sales_invoice(self): """Test that DN items with against_sales_invoice reference skips SDBNB account assignment.""" - from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + from erpnext.accounts.doctype.sales_invoice.mapper import ( make_delivery_note as make_dn_from_si, ) diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.js b/erpnext/stock/doctype/delivery_trip/delivery_trip.js index 61c6743054f..9eb5b1f83c3 100755 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.js +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.js @@ -54,7 +54,7 @@ frappe.ui.form.on("Delivery Trip", { __("Delivery Note"), () => { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_delivery_trip", + method: "erpnext.stock.doctype.delivery_note.mapper.make_delivery_trip", source_doctype: "Delivery Note", target: frm, date_field: "posting_date", diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 3a054abf722..655d781126c 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -364,7 +364,7 @@ class TestInventoryDimension(ERPNextTestSuite): def test_inter_transfer_return_against_inventory_dimension(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt data = prepare_data_for_internal_transfer() diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py index 0a2119af3ec..2be54c82036 100644 --- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py @@ -11,8 +11,8 @@ from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, ) from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record -from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( EmptyStockReconciliationItemsError, diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 7fa2e0a2548..6c44cec46b4 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -1130,10 +1130,10 @@ class TestLandedCostVoucher(ERPNextTestSuite): make_stock_transfer_entry, ) from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as make_stock_entry_for_wo, ) + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index e0bbff4cbda..0e48296323b 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -258,7 +258,7 @@ frappe.ui.form.on("Material Request", { get_items_from_sales_order: function (frm) { erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_material_request", + method: "erpnext.selling.doctype.sales_order.mapper.make_material_request", source_doctype: "Sales Order", target: frm, setters: { @@ -411,7 +411,7 @@ frappe.ui.form.on("Material Request", { make_purchase_order: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order", + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", frm: frm, run_link_triggers: true, }); @@ -419,7 +419,7 @@ frappe.ui.form.on("Material Request", { make_request_for_quotation: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation", + method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation", frm: frm, run_link_triggers: true, }); @@ -427,14 +427,14 @@ frappe.ui.form.on("Material Request", { make_supplier_quotation: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_supplier_quotation", + method: "erpnext.stock.doctype.material_request.mapper.make_supplier_quotation", frm: frm, }); }, make_stock_entry: function (frm) { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_stock_entry", + method: "erpnext.stock.doctype.material_request.mapper.make_stock_entry", frm: frm, }); }, @@ -461,7 +461,7 @@ frappe.ui.form.on("Material Request", { ], (values) => { frappe.call({ - method: "erpnext.stock.doctype.material_request.material_request.make_in_transit_stock_entry", + method: "erpnext.stock.doctype.material_request.mapper.make_in_transit_stock_entry", args: { source_name: frm.doc.name, in_transit_warehouse: values.in_transit_warehouse, @@ -481,7 +481,7 @@ frappe.ui.form.on("Material Request", { create_pick_list: (frm) => { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.material_request.create_pick_list", + method: "erpnext.stock.doctype.material_request.mapper.create_pick_list", frm: frm, }); }, diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 0a99e19662d..f2b8b1856d2 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -21,15 +21,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty from .mapper import ( - create_pick_list, get_items_based_on_default_supplier, - make_in_transit_stock_entry, - make_purchase_order, - make_purchase_order_based_on_supplier, - make_request_for_quotation, - make_stock_entry, - make_supplier_quotation, - set_missing_values, ) form_grid_templates = {"items": "templates/form_grid/material_request_grid.html"} diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index c25a6ecd62d..b1d20e698e0 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -10,12 +10,14 @@ from frappe.utils import flt, today from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.stock.doctype.item.test_item import create_item -from erpnext.stock.doctype.material_request.material_request import ( +from erpnext.stock.doctype.material_request.mapper import ( create_pick_list, make_in_transit_stock_entry, make_purchase_order, make_stock_entry, make_supplier_quotation, +) +from erpnext.stock.doctype.material_request.material_request import ( raise_work_orders, ) from erpnext.stock.doctype.stock_entry.stock_entry import make_stock_in_entry @@ -980,7 +982,7 @@ class TestMaterialRequest(ERPNextTestSuite): from frappe.utils import add_to_date, today from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle - from erpnext.selling.doctype.sales_order.sales_order import make_material_request + from erpnext.selling.doctype.sales_order.mapper import make_material_request from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order sub_item_a = "_Test Bundle ItemA" @@ -1019,7 +1021,7 @@ class TestMaterialRequest(ERPNextTestSuite): """Test for pick list mapped doc qty from partially received Material Request Transfer""" import json - from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry + from erpnext.stock.doctype.pick_list.mapper import create_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry new_item = create_item("_Test Pick List Item", is_stock_item=1) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index e7b22d04033..8189343a820 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -5,7 +5,7 @@ import frappe from frappe.utils import add_to_date, nowdate -from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note +from erpnext.selling.doctype.sales_order.mapper import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries @@ -190,7 +190,7 @@ class TestPackedItem(ERPNextTestSuite): self.assertEqual(sent_item.qty, -1 * returned_item.qty) def test_returning_full_bundles(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item_list = [ { @@ -219,7 +219,7 @@ class TestPackedItem(ERPNextTestSuite): self.assertReturns(dn.packed_items, dn_ret.packed_items) def test_returning_partial_bundles(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return item_list = [ { @@ -256,7 +256,7 @@ class TestPackedItem(ERPNextTestSuite): self.assertReturns(expected_returns, dn_ret.packed_items) def test_returning_partial_bundle_qty(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return so = make_sales_order(item_code=self.bundle, warehouse=self.warehouse, qty=2) diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.js b/erpnext/stock/doctype/packing_slip/packing_slip.js index 682631f1b74..45f84a39598 100644 --- a/erpnext/stock/doctype/packing_slip/packing_slip.js +++ b/erpnext/stock/doctype/packing_slip/packing_slip.js @@ -35,7 +35,7 @@ frappe.ui.form.on("Packing Slip", { if (frm.doc.delivery_note) { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip", + method: "erpnext.stock.doctype.delivery_note.mapper.make_packing_slip", source_name: frm.doc.delivery_note, target_doc: frm, freeze: true, diff --git a/erpnext/stock/doctype/packing_slip/test_packing_slip.py b/erpnext/stock/doctype/packing_slip/test_packing_slip.py index 19e6c976edc..55a51f847e3 100644 --- a/erpnext/stock/doctype/packing_slip/test_packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/test_packing_slip.py @@ -5,7 +5,7 @@ import frappe from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle -from erpnext.stock.doctype.delivery_note.delivery_note import make_packing_slip +from erpnext.stock.doctype.delivery_note.mapper import make_packing_slip from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import make_item from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index bf22310271a..cb7a1d7af22 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -10,7 +10,7 @@ from frappe.model.document import Document from frappe.model.mapper import map_child_doc from frappe.utils import flt, get_link_to_form -from erpnext.selling.doctype.sales_order.sales_order import ( +from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note as create_delivery_note_from_sales_order, ) diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 750466a4a40..ee83a303791 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -211,7 +211,7 @@ frappe.ui.form.on("Pick List", { } frm.clear_table("locations"); erpnext.utils.map_current_doc({ - method: "erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", + method: "erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", target: frm, source_name: frm.doc.work_order, }); @@ -223,7 +223,7 @@ frappe.ui.form.on("Pick List", { }, material_request: (frm) => { erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.material_request.material_request.create_pick_list", + method: "erpnext.stock.doctype.material_request.mapper.create_pick_list", target: frm, source_name: frm.doc.material_request, }); @@ -234,13 +234,13 @@ frappe.ui.form.on("Pick List", { }, create_delivery_note: (frm) => { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.pick_list.pick_list.create_delivery_note", + method: "erpnext.stock.doctype.pick_list.mapper.create_delivery_note", frm: frm, }); }, create_stock_entry: (frm) => { frappe - .xcall("erpnext.stock.doctype.pick_list.pick_list.create_stock_entry", { + .xcall("erpnext.stock.doctype.pick_list.mapper.create_stock_entry", { pick_list: frm.doc, }) .then((stock_entry) => { @@ -262,7 +262,7 @@ frappe.ui.form.on("Pick List", { }; frm.get_items_btn = frm.add_custom_button(__("Get Items"), () => { erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", + method: "erpnext.selling.doctype.sales_order.mapper.create_pick_list", source_doctype: "Sales Order", target: frm, setters: { diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 910e0211867..535a20ec0f3 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -26,11 +26,7 @@ from erpnext.stock.serial_batch_bundle import ( from erpnext.utilities.transaction_base import TransactionBase from .mapper import ( - create_delivery_note, - create_dn_for_pick_lists, - create_stock_entry, stock_entry_exists, - validate_item_locations, ) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 85a45f1686b..4e424aa7585 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -5,11 +5,11 @@ import frappe from frappe import _dict from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle -from erpnext.selling.doctype.sales_order.sales_order import create_pick_list +from erpnext.selling.doctype.sales_order.mapper import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle -from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists +from erpnext.stock.doctype.pick_list.mapper import create_delivery_note, create_dn_for_pick_lists from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_batch_from_bundle, @@ -1052,7 +1052,8 @@ class TestPickList(ERPNextTestSuite): def test_pick_list_warehouse_for_work_order(self): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list, make_work_order + from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list + from erpnext.manufacturing.doctype.work_order.work_order import make_work_order from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse # Create Warehouses for Work Order @@ -1536,7 +1537,7 @@ class TestPickList(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_multiple_pick_lists_delivery_note(self): - from erpnext.stock.doctype.pick_list.pick_list import create_dn_for_pick_lists + from erpnext.stock.doctype.pick_list.mapper import create_dn_for_pick_lists item_code = make_item().name warehouse = "_Test Warehouse - _TC" @@ -1745,7 +1746,7 @@ class TestPickList(ERPNextTestSuite): pick_list = frappe.new_doc("Pick List") map_docs( - "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", + "erpnext.selling.doctype.sales_order.mapper.create_pick_list", dumps([sales_order1.name, sales_order2.name, sales_order3.name]), pick_list, ) diff --git a/erpnext/stock/doctype/purchase_receipt/mapper.py b/erpnext/stock/doctype/purchase_receipt/mapper.py index efbe5e73d88..9ea7371554a 100644 --- a/erpnext/stock/doctype/purchase_receipt/mapper.py +++ b/erpnext/stock/doctype/purchase_receipt/mapper.py @@ -11,7 +11,7 @@ from frappe.query_builder.functions import Abs, Sum from frappe.utils import flt from erpnext.controllers.accounts_controller import merge_taxes -from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction +from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_transaction from erpnext.stock.serial_batch_bundle import ( SerialBatchCreation, get_batches_from_bundle, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 4e959229e15..6524bd30265 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -45,7 +45,7 @@ frappe.ui.form.on("Purchase Receipt", { __("Debit Note"), function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice", frm: cur_frm, }); }, @@ -59,7 +59,7 @@ frappe.ui.form.on("Purchase Receipt", { __("Delivery Note"), function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_inter_company_delivery_note", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_inter_company_delivery_note", frm: cur_frm, }); }, @@ -124,7 +124,7 @@ frappe.ui.form.on("Purchase Receipt", { }); } erpnext.utils.map_current_doc({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt", + method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt", source_doctype: "Purchase Invoice", target: frm, setters: { @@ -223,7 +223,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend }); } erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt", + method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_receipt", source_doctype: "Purchase Order", target: me.frm, setters: { @@ -282,7 +282,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend make_purchase_invoice() { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice", frm: cur_frm, }); } @@ -309,7 +309,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend function (values) { if (values.return_for_rejected_warehouse) { frappe.call({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_return_against_rejected_warehouse", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_return_against_rejected_warehouse", args: { source_name: cur_frm.doc.name, }, @@ -439,14 +439,14 @@ frappe.ui.form.on("Purchase Receipt Item", { cur_frm.cscript._make_purchase_return = function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_return", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_return", frm: cur_frm, }); }; cur_frm.cscript["Make Stock Entry"] = function () { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_stock_entry", + method: "erpnext.stock.doctype.purchase_receipt.mapper.make_stock_entry", frm: cur_frm, }); }; diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 710eb63c6ab..dadf7e405df 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -17,14 +17,6 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation -from .mapper import ( - make_inter_company_delivery_note, - make_purchase_invoice, - make_purchase_return, - make_purchase_return_against_rejected_warehouse, - make_stock_entry, -) - form_grid_templates = {"items": "templates/form_grid/item_grid.html"} diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 6f217b98674..ad6d95c7976 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -14,8 +14,8 @@ from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.item.test_item import create_item, make_item -from erpnext.stock.doctype.material_request.material_request import make_purchase_order -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice +from erpnext.stock.doctype.material_request.mapper import make_purchase_order +from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( SerialNoDuplicateError, SerialNoExistsInFutureTransactionError, @@ -707,10 +707,10 @@ class TestPurchaseReceipt(ERPNextTestSuite): 2. PO -> PI 3. PO -> PR2. """ - from erpnext.buying.doctype.purchase_order.purchase_order import ( + from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_invoice as make_purchase_invoice_from_po, ) - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order frappe.flags.print_test_messages = False @@ -861,7 +861,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): pr.cancel() def test_purchase_return_with_submitted_asset(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return pr = make_purchase_receipt(item_code="Test Asset Item", qty=1) @@ -1010,7 +1010,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): pr1.cancel() def test_stock_transfer_from_purchase_receipt(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() @@ -1052,7 +1052,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): pr.cancel() def test_lcv_for_internal_transfer(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( make_landed_cost_voucher, @@ -1148,7 +1148,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertTrue(new_inward_sabb[0] == inward_sabb[0]) def test_stock_transfer_from_purchase_receipt_with_valuation(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, @@ -1348,7 +1348,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertAlmostEqual(pr.per_billed, 50.0, places=2) def test_purchase_receipt_with_exchange_rate_difference(self): - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( + from erpnext.accounts.doctype.purchase_invoice.mapper import ( make_purchase_receipt as create_purchase_receipt, ) from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import ( @@ -1455,7 +1455,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(gle.credit, 50) def test_backdated_transaction_for_internal_transfer(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() @@ -1543,7 +1543,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_receipt( self, ): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() @@ -1653,7 +1653,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import ( make_purchase_invoice as make_purchase_invoice_for_si, ) - from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + from erpnext.accounts.doctype.sales_invoice.mapper import ( make_inter_company_purchase_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice @@ -1881,7 +1881,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) # Step 4: Create Internal Purchase Receipt - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt pr = make_inter_company_purchase_receipt(dn.name) pr.set_posting_time = 1 @@ -1913,7 +1913,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) def test_internal_pr_gl_entries(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( @@ -2029,7 +2029,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) # Step 4: Create Internal Purchase Receipt - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt pr = make_inter_company_purchase_receipt(dn.name) pr.inter_company_reference = "" @@ -2079,7 +2079,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) # Step 3: Create Purchase Return for 2 qty - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return pr_return = make_purchase_return(pr.name) pr_return.items[0].qty = 2 * -1 @@ -2098,7 +2098,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(abs(data["stock_value_difference"]), 400.00) def test_return_from_rejected_warehouse(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return_against_rejected_warehouse, ) @@ -2678,8 +2678,8 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) def test_pr_billed_amount_against_return_entry(self): - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_invoice as make_pi_from_pr, ) @@ -2845,7 +2845,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): def test_internal_transfer_with_serial_batch_items_and_their_valuation(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() @@ -2982,7 +2982,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): def test_internal_transfer_with_serial_batch_items_without_use_serial_batch_fields(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) @@ -3200,7 +3200,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(row.incoming_rate, 0) def test_purchase_return_from_accepted_and_rejected_warehouse(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return, ) @@ -3278,7 +3278,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(batch.expiry_date, getdate(add_days(today(), 5))) def test_purchase_return_from_rejected_warehouse(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return_against_rejected_warehouse, ) @@ -3318,7 +3318,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): create_purchase_order, make_pr_against_po, ) - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice stock_rbnb = "Stock Received But Not Billed - _TC" stock_in_hand = "Stock In Hand - _TC" @@ -3468,7 +3468,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): create_purchase_order, make_pr_against_po, ) - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice stock_rbnb = "Stock Received But Not Billed - _TC" stock_in_hand = "Stock In Hand - _TC" @@ -3637,7 +3637,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(pr.status, "Completed") def test_internal_transfer_for_batch_items_with_cancel(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) @@ -3752,7 +3752,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) def test_internal_transfer_for_batch_items_with_cancel_use_serial_batch_fields(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) @@ -3947,7 +3947,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(pr.items[0].conversion_factor, 1.0) def test_purchase_receipt_return_valuation_without_use_serial_batch_field(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return batch_item = make_item( "_Test Purchase Receipt Return Valuation Batch Item", @@ -4051,7 +4051,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(incoming_rate, 0) def test_purchase_receipt_return_valuation_with_use_serial_batch_field(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return + from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return batch_item = make_item( "_Test Purchase Receipt Return Valuation With Batch Item", @@ -4260,7 +4260,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "allow_existing_serial_no", 1) def test_seral_no_return_validation(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return, ) @@ -4292,7 +4292,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): sn_return.submit() def test_batch_no_return_validation(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return, ) @@ -4325,10 +4325,10 @@ class TestPurchaseReceipt(ERPNextTestSuite): batch_return.submit() def test_pr_status_based_on_invoices_with_update_stock(self): - from erpnext.buying.doctype.purchase_order.purchase_order import ( + from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_invoice as _make_purchase_invoice, ) - from erpnext.buying.doctype.purchase_order.purchase_order import ( + from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_receipt as _make_purchase_receipt, ) from erpnext.buying.doctype.purchase_order.test_purchase_order import ( @@ -4431,7 +4431,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, repost_doc.save) def test_internal_pr_qty_change_only_single_batch(self): - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() @@ -4948,10 +4948,10 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) @ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 100}) def test_set_lcv_from_pi_created_against_po(self): - from erpnext.buying.doctype.purchase_order.purchase_order import ( + from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_invoice as make_pi_against_po, ) - from erpnext.buying.doctype.purchase_order.purchase_order import ( + from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_receipt as make_pr_against_po, ) from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order @@ -4981,10 +4981,10 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(row.amount_difference_with_purchase_invoice, amt_diff) def test_purchase_return_with_and_without_return_against_rejected_qty(self): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return as _make_purchase_return, ) - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + from erpnext.stock.doctype.purchase_receipt.mapper import ( make_purchase_return_against_rejected_warehouse, ) @@ -5223,7 +5223,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): """ To test inter branch transaction incoming rate calculation with lcv after item reposting """ - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py index 0ee89f62ee8..045df06880f 100644 --- a/erpnext/stock/doctype/shipment/test_shipment.py +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -5,7 +5,7 @@ from datetime import date, timedelta import frappe -from erpnext.stock.doctype.delivery_note.delivery_note import make_shipment +from erpnext.stock.doctype.delivery_note.mapper import make_shipment from erpnext.tests.utils import ERPNextTestSuite diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 3e5a8a10a8d..e8c341a3c3b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -384,7 +384,7 @@ frappe.ui.form.on("Stock Entry", { async (data) => { if (frm.doc.work_order) { let stock_entry = await frappe.xcall( - "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", + "erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", { work_order_id: frm.doc.work_order, purpose: "Disassemble", @@ -424,7 +424,7 @@ frappe.ui.form.on("Stock Entry", { __("Purchase Invoice"), function () { erpnext.utils.map_current_doc({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_stock_entry", + method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_stock_entry", source_doctype: "Purchase Invoice", target: frm, date_field: "posting_date", @@ -449,7 +449,7 @@ frappe.ui.form.on("Stock Entry", { ]; const depends_on_condition = "eval:doc.material_request_type==='Customer Provided'"; const d = erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_stock_entry", + method: "erpnext.stock.doctype.material_request.mapper.make_stock_entry", source_doctype: "Material Request", target: frm, date_field: "schedule_date", diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index bb40f47765a..ed4286f2fb5 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -13,7 +13,7 @@ from erpnext.stock.doctype.item.test_item import ( make_item_variant, set_item_variant_settings, ) -from erpnext.stock.doctype.material_request.material_request import ( +from erpnext.stock.doctype.material_request.mapper import ( make_in_transit_stock_entry, ) from erpnext.stock.doctype.material_request.test_material_request import ( @@ -830,7 +830,7 @@ class TestStockEntry(ERPNextTestSuite): frappe.db.set_single_value("Stock Settings", "stock_frozen_upto_days", 0) def test_work_order(self): - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) @@ -868,7 +868,7 @@ class TestStockEntry(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1}) def test_work_order_manufacture_with_material_consumption(self): - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) @@ -947,7 +947,7 @@ class TestStockEntry(ERPNextTestSuite): work_order.insert() work_order.submit() - from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry + from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) stock_entry.insert() @@ -990,7 +990,7 @@ class TestStockEntry(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, ste.submit) def test_quality_check_for_secondary_item(self): - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) @@ -1433,7 +1433,7 @@ class TestStockEntry(ERPNextTestSuite): def test_mapped_stock_entry(self): "Check if rate and stock details are populated in mapped SE given warehouse." - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_stock_entry + from erpnext.stock.doctype.purchase_receipt.mapper import make_stock_entry from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt item_code = "_TestMappedItem" @@ -2397,7 +2397,7 @@ class TestStockEntry(ERPNextTestSuite): ) def test_validation_as_per_bom_with_continuous_raw_material_consumption(self): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as _make_stock_entry + from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as _make_stock_entry from erpnext.manufacturing.doctype.work_order.work_order import make_work_order fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name @@ -2788,7 +2788,7 @@ class TestStockEntryCoverage(ERPNextTestSuite): def test_get_available_materials_tracks_transferred_qty(self): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import ( @@ -2833,7 +2833,7 @@ class TestStockEntryCoverage(ERPNextTestSuite): def test_get_available_materials_reduces_qty_after_consumption(self): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import ( diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index a92e6401be4..f63b7de01c2 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1494,7 +1494,7 @@ def create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_l def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list): - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order dns = [] diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py index d11f33992ea..cc125ea3b8e 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py @@ -6,7 +6,7 @@ from random import randint import frappe from frappe.utils import today -from erpnext.selling.doctype.sales_order.sales_order import create_pick_list, make_delivery_note +from erpnext.selling.doctype.sales_order.mapper import create_pick_list, make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry @@ -525,9 +525,9 @@ class TestStockReservationEntry(ERPNextTestSuite): }, ) def test_stock_reservation_from_purchase_receipt(self) -> None: - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt - from erpnext.selling.doctype.sales_order.sales_order import make_material_request - from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt + from erpnext.selling.doctype.sales_order.mapper import make_material_request + from erpnext.stock.doctype.material_request.mapper import make_purchase_order items_details = create_items() create_material_receipt(items_details, self.warehouse, qty=10) diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index 7eadf125d0e..c1026eb9b65 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -78,7 +78,7 @@ class TestGetItemDetail(ERPNextTestSuite): so = make_sales_order(item_code=item.item_code, qty=2, rate=75) - from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note dn = make_delivery_note(so.name) diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js index 20481791e38..eb3938430f0 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js @@ -99,7 +99,7 @@ frappe.ui.form.on("Subcontracting Inward Order", { if (frm.doc.sales_order) { erpnext.utils.map_current_doc({ - method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order", + method: "erpnext.selling.doctype.sales_order.mapper.make_subcontracting_inward_order", source_name: frm.doc.sales_order, target_doc: frm, freeze: true, diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py index 9a45a49be5e..6bdbaf20333 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -3,8 +3,8 @@ import frappe -from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_stock_entry_from_wo -from erpnext.selling.doctype.sales_order.sales_order import make_subcontracting_inward_order +from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_stock_entry_from_wo +from erpnext.selling.doctype.sales_order.mapper import make_subcontracting_inward_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -368,7 +368,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()).submit() scio.reload() - from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice si = make_sales_invoice(so.name) self.assertEqual(si.items[-1].item_code, "Self RM") diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js index 76f1cc52094..3f9ad433ca6 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js @@ -145,7 +145,7 @@ frappe.ui.form.on("Subcontracting Order", { if (frm.doc.purchase_order) { erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.purchase_order.purchase_order.make_subcontracting_order", + method: "erpnext.buying.doctype.purchase_order.mapper.make_subcontracting_order", source_name: frm.doc.purchase_order, target_doc: frm, freeze: true, diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index f0803733d53..346debf2a93 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -7,7 +7,7 @@ from collections import defaultdict import frappe from frappe.utils import flt -from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_subcontracting_order +from erpnext.buying.doctype.purchase_order.mapper import get_mapped_subcontracting_order from erpnext.controllers.subcontracting_controller import ( get_materials_from_supplier, make_rm_stock_entry, @@ -624,7 +624,7 @@ class TestSubcontractingOrder(ERPNextTestSuite): self.assertEqual(ordered_qty + 10, new_ordered_qty) def test_requested_qty_for_subcontracting_order(self): - from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.material_request.mapper import make_purchase_order from erpnext.stock.doctype.material_request.test_material_request import make_material_request requested_qty = frappe.db.get_value( diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 0c2a10705c4..b8b357627e5 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -68,7 +68,7 @@ frappe.ui.form.on("Subcontracting Receipt", { __("Purchase Receipt"), () => { frappe.model.open_mapped_doc({ - method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_purchase_receipt", + method: "erpnext.subcontracting.doctype.subcontracting_receipt.mapper.make_purchase_receipt", frm: frm, freeze: true, freeze_message: __("Creating Purchase Receipt ..."), @@ -85,7 +85,7 @@ frappe.ui.form.on("Subcontracting Receipt", { () => { const make_standard_return = () => { frappe.model.open_mapped_doc({ - method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return", + method: "erpnext.subcontracting.doctype.subcontracting_receipt.mapper.make_subcontract_return", frm: frm, }); }; @@ -109,7 +109,7 @@ frappe.ui.form.on("Subcontracting Receipt", { function (values) { if (values.return_for_rejected_warehouse) { frappe.call({ - method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return_against_rejected_warehouse", + method: "erpnext.subcontracting.doctype.subcontracting_receipt.mapper.make_subcontract_return_against_rejected_warehouse", args: { source_name: frm.doc.name, }, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index b5948dff305..21f0dc30c5a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -21,8 +21,6 @@ from erpnext.stock.stock_ledger import get_valuation_rate from .mapper import ( make_purchase_receipt, - make_subcontract_return, - make_subcontract_return_against_rejected_warehouse, ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 95264201c44..94ee0946c6b 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -1315,7 +1315,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite): def test_subcontract_return_from_rejected_warehouse(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse - from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( + from erpnext.subcontracting.doctype.subcontracting_receipt.mapper import ( make_subcontract_return_against_rejected_warehouse, ) diff --git a/erpnext/templates/includes/rfq.js b/erpnext/templates/includes/rfq.js index cc998a90030..4570a7445ff 100644 --- a/erpnext/templates/includes/rfq.js +++ b/erpnext/templates/includes/rfq.js @@ -76,7 +76,7 @@ rfq = class rfq { frappe.freeze(); frappe.call({ type: "POST", - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.create_supplier_quotation", + method: "erpnext.buying.doctype.request_for_quotation.mapper.create_supplier_quotation", args: { doc: doc }, From d8760b76a8286630f284c98d0972392abe45c4fa Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 3 Jun 2026 12:40:46 +0530 Subject: [PATCH 58/65] refactor(sales_invoice): drop loyalty delegation shims, call LoyaltyService directly The make_/delete_/apply_loyalty_points methods on SalesInvoice only existed as an inheritance surface for POSInvoice (self.X()). Route all callers through LoyaltyService(doc).X() directly, consistent with how related-doc cases already worked, and remove the three forwarding methods. --- .../doctype/pos_invoice/pos_invoice.py | 15 ++++++++------- .../doctype/sales_invoice/sales_invoice.py | 18 +++--------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 5bc32ff68df..f556275b2f5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -17,6 +17,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( get_mode_of_payment_info, update_multi_mode_option, ) +from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyService from erpnext.accounts.party import get_due_date, get_party_account from erpnext.controllers.queries import item_query as _item_query from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice @@ -241,13 +242,13 @@ class POSInvoice(SalesInvoice): def on_submit(self): # create the loyalty point ledger entry if the customer is enrolled in any loyalty program if not self.is_return and self.loyalty_program: - self.make_loyalty_point_entry() + LoyaltyService(self).make_loyalty_point_entry() elif self.is_return and self.return_against and self.loyalty_program: against_psi_doc = frappe.get_doc("POS Invoice", self.return_against) - against_psi_doc.delete_loyalty_point_entry() - against_psi_doc.make_loyalty_point_entry() + LoyaltyService(against_psi_doc).delete_loyalty_point_entry() + LoyaltyService(against_psi_doc).make_loyalty_point_entry() if self.redeem_loyalty_points and self.loyalty_points: - self.apply_loyalty_points() + LoyaltyService(self).apply_loyalty_points() self.check_phone_payments() self.set_status(update=True) self.make_bundle_for_sales_purchase_return() @@ -288,11 +289,11 @@ class POSInvoice(SalesInvoice): # run on cancel method of selling controller super(SalesInvoice, self).on_cancel() if not self.is_return and self.loyalty_program: - self.delete_loyalty_point_entry() + LoyaltyService(self).delete_loyalty_point_entry() elif self.is_return and self.return_against and self.loyalty_program: against_psi_doc = frappe.get_doc("POS Invoice", self.return_against) - against_psi_doc.delete_loyalty_point_entry() - against_psi_doc.make_loyalty_point_entry() + LoyaltyService(against_psi_doc).delete_loyalty_point_entry() + LoyaltyService(against_psi_doc).make_loyalty_point_entry() self.db_set("status", "Cancelled") diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 5d6ec41c856..5e81cc3184a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -469,13 +469,13 @@ class SalesInvoice(SellingController): and self.loyalty_program and not self.dont_create_loyalty_points ): - self.make_loyalty_point_entry() + LoyaltyService(self).make_loyalty_point_entry() elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) LoyaltyService(against_si_doc).delete_loyalty_point_entry() LoyaltyService(against_si_doc).make_loyalty_point_entry() if self.redeem_loyalty_points and not self.is_consolidated and self.loyalty_points: - self.apply_loyalty_points() + LoyaltyService(self).apply_loyalty_points() self.process_common_party_accounting() self.update_billed_qty_in_scio() @@ -530,7 +530,7 @@ class SalesInvoice(SellingController): self.update_project() if not self.is_return and not self.is_consolidated and self.loyalty_program: - self.delete_loyalty_point_entry() + LoyaltyService(self).delete_loyalty_point_entry() elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) LoyaltyService(against_si_doc).delete_loyalty_point_entry() @@ -1108,18 +1108,6 @@ class SalesInvoice(SellingController): self.validate_for_repost() self.repost_accounting_entries() - # Called by POS Invoice - def make_loyalty_point_entry(self): - LoyaltyService(self).make_loyalty_point_entry() - - # Called by POS Invoice - def delete_loyalty_point_entry(self): - LoyaltyService(self).delete_loyalty_point_entry() - - # Called by POS Invoice - def apply_loyalty_points(self): - LoyaltyService(self).apply_loyalty_points() - def set_status(self, update=False, status=None, update_modified=True): StatusService(self).set_status(update, status, update_modified) From cfed16ab6c3d7d3f1d78a68d3fb2b95aa01a3108 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 3 Jun 2026 12:45:58 +0530 Subject: [PATCH 59/65] refactor(gl): give SI and PI their own precision-loss GL entry method Remove the doctype-branching make_precision_loss_gl_entry from exchange_gain_loss.py (and its accounts_controller wrapper); add a dedicated method to each of SalesInvoiceGLComposer and PurchaseInvoiceGLComposer. The SI variant now passes 'Sales Invoice' as the round-off voucher type (output-equivalent) and the throwaway return value no longer shadows the gettext _ helper. --- .../purchase_invoice/services/gl_composer.py | 31 ++++++++++++++++++- .../sales_invoice/services/gl_composer.py | 31 ++++++++++++++++++- .../accounts/services/exchange_gain_loss.py | 29 ----------------- erpnext/controllers/accounts_controller.py | 5 --- 4 files changed, 60 insertions(+), 36 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index d295814ff9a..786cfd61273 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -32,7 +32,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): self.make_supplier_gl_entry(gl_entries) self.make_item_gl_entries(gl_entries) - doc.make_precision_loss_gl_entry(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) @@ -48,6 +48,35 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): doc.set_gl_entry_for_purchase_expense(gl_entries) return gl_entries + def make_precision_loss_gl_entry(self, gl_entries): + doc = self.doc + ( + round_off_account, + round_off_cost_center, + _round_off_for_opening, + ) = get_round_off_account_and_cost_center( + doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + precision_loss = doc.get("base_net_total") - flt( + doc.get("net_total") * doc.conversion_rate, doc.precision("net_total") + ) + + if precision_loss: + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": doc.supplier, + "credit": precision_loss, + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else doc.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + def make_supplier_gl_entry(self, gl_entries): doc = self.doc grand_total = ( diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index afde7472717..8eb1b82a4ae 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -39,7 +39,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): if not (doc.is_return and disable_sdbnb_in_sr): self.stock_delivered_but_not_billed_gl_entries(gl_entries) - doc.make_precision_loss_gl_entry(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) tax_service.make_discount_gl_entries(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, doc) @@ -56,6 +56,35 @@ class SalesInvoiceGLComposer(BaseGLComposer): doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) return gl_entries + def make_precision_loss_gl_entry(self, gl_entries): + doc = self.doc + ( + round_off_account, + round_off_cost_center, + _round_off_for_opening, + ) = get_round_off_account_and_cost_center( + doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + precision_loss = doc.get("base_net_total") - flt( + doc.get("net_total") * doc.conversion_rate, doc.precision("net_total") + ) + + if precision_loss: + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": doc.customer, + "debit": precision_loss, + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else doc.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + def stock_delivered_but_not_billed_gl_entries(self, gl_entries): doc = self.doc if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)): diff --git a/erpnext/accounts/services/exchange_gain_loss.py b/erpnext/accounts/services/exchange_gain_loss.py index b7ed77bc664..fa3af053a18 100644 --- a/erpnext/accounts/services/exchange_gain_loss.py +++ b/erpnext/accounts/services/exchange_gain_loss.py @@ -8,38 +8,9 @@ from frappe import _, qb from frappe.utils import flt, get_link_to_form from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions -from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision -def make_precision_loss_gl_entry(doc, gl_entries: list) -> None: - round_off_account, round_off_cost_center, _ = get_round_off_account_and_cost_center( - doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center - ) - - precision_loss = doc.get("base_net_total") - flt( - doc.get("net_total") * doc.conversion_rate, doc.precision("net_total") - ) - - credit_or_debit = "credit" if doc.doctype == "Purchase Invoice" else "debit" - against = doc.supplier if doc.doctype == "Purchase Invoice" else doc.customer - - if precision_loss: - gl_entries.append( - doc.get_gl_dict( - { - "account": round_off_account, - "against": against, - credit_or_debit: precision_loss, - "cost_center": round_off_cost_center - if doc.use_company_roundoff_cost_center - else doc.cost_center or round_off_cost_center, - "remarks": _("Net total calculation precision loss"), - } - ) - ) - - def gain_loss_journal_already_booked( gain_loss_account: str, exc_gain_loss: float, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 563d9f516dd..7ed819167ea 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1043,11 +1043,6 @@ class AccountsController(TransactionBase): set_advance_gain_or_loss(self) - def make_precision_loss_gl_entry(self, gl_entries): - from erpnext.accounts.services.exchange_gain_loss import make_precision_loss_gl_entry - - make_precision_loss_gl_entry(self, gl_entries) - def gain_loss_journal_already_booked( self, gain_loss_account, exc_gain_loss, ref2_dt, ref2_dn, ref2_detail_no ) -> bool: From e0c285e27ec1d9be4cc42adedc9552e0ce7a8627 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 3 Jun 2026 13:00:50 +0530 Subject: [PATCH 60/65] refactor(gl): move make_discount_gl_entries onto SalesInvoiceGLComposer It is Sales-Invoice-specific GL assembly and was the only TaxService method called by the composer. Move it to SalesInvoiceGLComposer (verbatim), call it as self.make_discount_gl_entries, drop the now-unused composer-level TaxService local and the orphaned get_account_currency import in taxes.py. --- .../sales_invoice/services/gl_composer.py | 78 ++++++++++++++++++- erpnext/accounts/services/taxes.py | 76 ------------------ 2 files changed, 76 insertions(+), 78 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index 8eb1b82a4ae..9b5d8e2f187 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -24,7 +24,6 @@ class SalesInvoiceGLComposer(BaseGLComposer): from erpnext.accounts.general_ledger import merge_similar_entries doc = self.doc - tax_service = TaxService(doc) gl_entries = [] self.make_customer_gl_entry(gl_entries) @@ -40,7 +39,7 @@ class SalesInvoiceGLComposer(BaseGLComposer): self.stock_delivered_but_not_billed_gl_entries(gl_entries) self.make_precision_loss_gl_entry(gl_entries) - tax_service.make_discount_gl_entries(gl_entries) + self.make_discount_gl_entries(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, doc) @@ -85,6 +84,81 @@ class SalesInvoiceGLComposer(BaseGLComposer): ) ) + def make_discount_gl_entries(self, gl_entries): + doc = self.doc + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + if enable_discount_accounting: + for item in doc.get("items"): + if item.get("discount_amount") and item.get("discount_account"): + discount_amount = item.discount_amount * item.qty + income_account = ( + item.income_account + if (not item.enable_deferred_revenue or doc.is_return) + else item.deferred_revenue_account + ) + + account_currency = get_account_currency(item.discount_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": item.discount_account, + "against": doc.customer, + "debit": flt( + discount_amount * doc.get("conversion_rate"), + item.precision("discount_amount"), + ), + "debit_in_transaction_currency": flt( + discount_amount, item.precision("discount_amount") + ), + "cost_center": item.cost_center, + "project": item.project, + }, + account_currency, + item=item, + ) + ) + + account_currency = get_account_currency(income_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": income_account, + "against": doc.customer, + "credit": flt( + discount_amount * doc.get("conversion_rate"), + item.precision("discount_amount"), + ), + "credit_in_transaction_currency": flt( + discount_amount, item.precision("discount_amount") + ), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + if ( + (enable_discount_accounting or doc.get("is_cash_or_non_trade_discount")) + and doc.get("additional_discount_account") + and doc.get("discount_amount") + ): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.additional_discount_account, + "against": doc.customer, + "debit": doc.base_discount_amount, + "cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company), + }, + item=doc, + ) + ) + def stock_delivered_but_not_billed_gl_entries(self, gl_entries): doc = self.doc if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)): diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py index 19e9843248b..2705bf4ea73 100644 --- a/erpnext/accounts/services/taxes.py +++ b/erpnext/accounts/services/taxes.py @@ -10,7 +10,6 @@ from frappe import _, throw from frappe.utils import cint, flt, parse_json import erpnext -from erpnext.accounts.utils import get_account_currency from erpnext.stock.get_item_details import ( NOT_APPLICABLE_TAX, ItemDetailsCtx, @@ -177,81 +176,6 @@ class TaxService: return amount, base_amount - def make_discount_gl_entries(self, gl_entries: list) -> None: - doc = self.doc - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) - - if enable_discount_accounting: - for item in doc.get("items"): - if item.get("discount_amount") and item.get("discount_account"): - discount_amount = item.discount_amount * item.qty - income_account = ( - item.income_account - if (not item.enable_deferred_revenue or doc.is_return) - else item.deferred_revenue_account - ) - - account_currency = get_account_currency(item.discount_account) - gl_entries.append( - doc.get_gl_dict( - { - "account": item.discount_account, - "against": doc.customer, - "debit": flt( - discount_amount * doc.get("conversion_rate"), - item.precision("discount_amount"), - ), - "debit_in_transaction_currency": flt( - discount_amount, item.precision("discount_amount") - ), - "cost_center": item.cost_center, - "project": item.project, - }, - account_currency, - item=item, - ) - ) - - account_currency = get_account_currency(income_account) - gl_entries.append( - doc.get_gl_dict( - { - "account": income_account, - "against": doc.customer, - "credit": flt( - discount_amount * doc.get("conversion_rate"), - item.precision("discount_amount"), - ), - "credit_in_transaction_currency": flt( - discount_amount, item.precision("discount_amount") - ), - "cost_center": item.cost_center, - "project": item.project or doc.project, - }, - account_currency, - item=item, - ) - ) - - if ( - (enable_discount_accounting or doc.get("is_cash_or_non_trade_discount")) - and doc.get("additional_discount_account") - and doc.get("discount_amount") - ): - gl_entries.append( - doc.get_gl_dict( - { - "account": doc.additional_discount_account, - "against": doc.customer, - "debit": doc.base_discount_amount, - "cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company), - }, - item=doc, - ) - ) - def get_tax_rate(account_head: str) -> dict: return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) From 092d8f771cd29015d375c646549d1a8f5ff9e709 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 3 Jun 2026 15:50:06 +0530 Subject: [PATCH 61/65] fix: update references to relocated mapper functions and POS wrapper After moving mapping functions into per-doctype mapper.py modules and POS logic into POSService, several call sites still referenced the old locations, breaking import/collection in CI: - bulk_transaction: import mapper modules for make_* transitions - test_purchase_order / test_purchase_receipt / test_stock_entry: import make_purchase_receipt, make_purchase_invoice, make_inter_company_purchase_receipt and make_stock_entry from their mapper modules - order.html: point portal API URL to purchase_order.mapper - sales_invoice: add validate_full_payment delegating wrapper (called by POSInvoice) --- .../doctype/sales_invoice/sales_invoice.py | 4 ++++ .../purchase_order/test_purchase_order.py | 2 +- .../purchase_receipt/test_purchase_receipt.py | 8 +++++--- .../doctype/stock_entry/test_stock_entry.py | 8 ++++---- erpnext/templates/pages/order.html | 2 +- erpnext/utilities/bulk_transaction.py | 16 ++++++++-------- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 5e81cc3184a..600002fcfe4 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -691,6 +691,10 @@ class SalesInvoice(SellingController): def clear_unallocated_mode_of_payments(self): POSService(self).clear_unallocated_mode_of_payments() + # Called by POS Invoice + def validate_full_payment(self): + POSService(self).validate_full_payment() + def get_company_abbr(self): return frappe.db.get_value("Company", self.company, "abbr") diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 51ec360f8bf..fe406d6ad3a 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -105,7 +105,7 @@ class TestPurchaseOrder(ERPNextTestSuite): Regression test for #55246: the mapper dropped rows once received_qty >= qty, ignoring the configured tolerance. """ - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt # 50% tolerance — 10 ordered allows up to 15 received frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 50) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 4c08150605c..5f54cab0d2f 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1055,7 +1055,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): """ Party-derived fields on DN (from Customer) must not leak into the mapped PR. """ - from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note prepare_data_for_internal_transfer() @@ -1339,7 +1339,8 @@ class TestPurchaseReceipt(ERPNextTestSuite): - Create PI from PO and submit - Create PR from PO and submit """ - from erpnext.buying.doctype.purchase_order import purchase_order, test_purchase_order + from erpnext.buying.doctype.purchase_order import mapper as purchase_order + from erpnext.buying.doctype.purchase_order import test_purchase_order po = test_purchase_order.create_purchase_order() @@ -1360,7 +1361,8 @@ class TestPurchaseReceipt(ERPNextTestSuite): - Create partial PI from PO and submit - Create PR from PO and submit """ - from erpnext.buying.doctype.purchase_order import purchase_order, test_purchase_order + from erpnext.buying.doctype.purchase_order import mapper as purchase_order + from erpnext.buying.doctype.purchase_order import test_purchase_order po = test_purchase_order.create_purchase_order() diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index c0e6b8f2382..f8a5d6bda32 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2890,10 +2890,10 @@ class TestStockEntryCoverage(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"}) def test_validate_fg_resets_invalid_serial_no_on_manufacture(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom - from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record fg_item = "_FG Serial No Item" rm_item = "RM for serial item" @@ -2930,10 +2930,10 @@ class TestStockEntryCoverage(ERPNextTestSuite): @ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"}) def test_validate_fg_resets_invalid_batch_no_on_manufacture(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom - from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record - from erpnext.manufacturing.doctype.work_order.work_order import ( + from erpnext.manufacturing.doctype.work_order.mapper import ( make_stock_entry as _make_stock_entry, ) + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.stock.serial_batch_bundle import get_batches_from_bundle fg_item = "_FG Batch No Item" diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html index 5563a58b730..853c2566969 100644 --- a/erpnext/templates/pages/order.html +++ b/erpnext/templates/pages/order.html @@ -23,7 +23,7 @@