From c2e472b03c3e2bae93ebc2de97b21cdacce332dd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 7 Jun 2026 10:06:09 +0530 Subject: [PATCH] refactor(accounts): extract Purchase Invoice BillingStatusService Move PR billing sync and provisional-entry cancellation into accounts/doctype/purchase_invoice/services/billing_status.py: - update_billing_status_in_pr, get_pr_details_billed_amt and cancel_provisional_entries move into the service (internal-only; on_submit/on_cancel and make_gl_entries repointed) - the service imports the shared allocation helpers from purchase_receipt/services/billing_status.py (PR owns the shared buying billing logic) - also repoints the validate_expense_account call in validate_for_repost missed in the ExpenseAccountService commit No behaviour change. --- .../purchase_invoice/purchase_invoice.py | 94 ++---------------- .../services/billing_status.py | 95 +++++++++++++++++++ 2 files changed, 101 insertions(+), 88 deletions(-) create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/billing_status.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8110288ce39..df4c5d65d04 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -3,13 +3,13 @@ import frappe -from frappe import _, qb, throw +from frappe import _, throw from frappe.model.document import Document -from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date +from erpnext.accounts.doctype.purchase_invoice.services.billing_status import BillingStatusService from erpnext.accounts.doctype.purchase_invoice.services.expense_account import ExpenseAccountService from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( validate_docs_for_deferred_accounting, @@ -34,9 +34,6 @@ from erpnext.accounts.party import get_due_date, get_party_account from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.controllers.buying_controller import BuyingController -from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( - update_billed_amount_based_on_po, -) class WarehouseMissingError(frappe.ValidationError): @@ -600,7 +597,7 @@ class PurchaseInvoice(BuyingController): def validate_for_repost(self): self.validate_write_off_account() self.validate_write_off_cost_center() - self.validate_expense_account() + ExpenseAccountService(self).validate_expense_account() validate_docs_for_voucher_types(["Purchase Invoice"]) validate_docs_for_deferred_accounting([], [self.name]) @@ -629,7 +626,7 @@ class PurchaseInvoice(BuyingController): self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt") self.update_billing_status_for_zero_amount_refdoc("Purchase Order") - self.update_billing_status_in_pr() + BillingStatusService(self).update_billing_status_in_pr() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO @@ -680,32 +677,10 @@ class PurchaseInvoice(BuyingController): self.make_exchange_gain_loss_journal() elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) - self.cancel_provisional_entries() + BillingStatusService(self).cancel_provisional_entries() self.update_supplier_outstanding(update_outstanding) - def cancel_provisional_entries(self): - rows = set() - purchase_receipts = set() - for d in self.items: - if d.purchase_receipt: - purchase_receipts.add(d.purchase_receipt) - rows.add(d.name) - - if rows: - # cancel gl entries - gle = qb.DocType("GL Entry") - gle_update_query = ( - qb.update(gle) - .set(gle.is_cancelled, 1) - .where( - (gle.voucher_type == "Purchase Receipt") - & (gle.voucher_no.isin(purchase_receipts)) - & (gle.voucher_detail_no.isin(rows)) - ) - ) - gle_update_query.run() - def update_supplier_outstanding(self, update_outstanding): if update_outstanding == "No": update_voucher_outstanding( @@ -755,7 +730,7 @@ class PurchaseInvoice(BuyingController): self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt") self.update_billing_status_for_zero_amount_refdoc("Purchase Order") - self.update_billing_status_in_pr() + BillingStatusService(self).update_billing_status_in_pr() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO @@ -838,63 +813,6 @@ class PurchaseInvoice(BuyingController): ) ) - def update_billing_status_in_pr(self, update_modified=True): - if self.is_return and not self.update_billed_amount_in_purchase_receipt: - return - - updated_pr = [] - po_details = [] - - pr_details_billed_amt = self.get_pr_details_billed_amt() - - for d in self.get("items"): - if d.pr_detail: - frappe.db.set_value( - "Purchase Receipt Item", - d.pr_detail, - "billed_amt", - flt(pr_details_billed_amt.get(d.pr_detail)), - update_modified=update_modified, - ) - updated_pr.append(d.purchase_receipt) - elif d.po_detail: - po_details.append(d.po_detail) - - if po_details: - updated_pr += update_billed_amount_based_on_po(po_details, update_modified) - - adjust_incoming_rate = frappe.db.get_single_value( - "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" - ) - - for pr in set(updated_pr): - from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( - update_billing_percentage, - ) - - pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr) - update_billing_percentage( - pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate - ) - - def get_pr_details_billed_amt(self): - # Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice - - pr_details_billed_amt = {} - pr_details = [d.get("pr_detail") for d in self.get("items") if d.get("pr_detail")] - if pr_details: - doctype = frappe.qb.DocType("Purchase Invoice Item") - query = ( - frappe.qb.from_(doctype) - .select(doctype.pr_detail, Sum(doctype.amount)) - .where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1) - .groupby(doctype.pr_detail) - ) - - pr_details_billed_amt = frappe._dict(query.run(as_list=1)) - - return pr_details_billed_amt - def on_recurring(self, reference_doc, auto_repeat_doc): self.due_date = None diff --git a/erpnext/accounts/doctype/purchase_invoice/services/billing_status.py b/erpnext/accounts/doctype/purchase_invoice/services/billing_status.py new file mode 100644 index 00000000000..24ecece8887 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/billing_status.py @@ -0,0 +1,95 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Purchase Receipt billing sync and provisional-entry cancellation for Purchase Invoice.""" + +import frappe +from frappe import qb +from frappe.query_builder.functions import Sum +from frappe.utils import flt + +from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( + update_billed_amount_based_on_po, + update_billing_percentage, +) + + +class BillingStatusService: + def __init__(self, doc): + self.doc = doc + + def update_billing_status_in_pr(self, update_modified: bool = True) -> None: + doc = self.doc + if doc.is_return and not doc.update_billed_amount_in_purchase_receipt: + return + + updated_pr = [] + po_details = [] + + pr_details_billed_amt = self.get_pr_details_billed_amt() + + for d in doc.get("items"): + if d.pr_detail: + frappe.db.set_value( + "Purchase Receipt Item", + d.pr_detail, + "billed_amt", + flt(pr_details_billed_amt.get(d.pr_detail)), + update_modified=update_modified, + ) + updated_pr.append(d.purchase_receipt) + elif d.po_detail: + po_details.append(d.po_detail) + + if po_details: + updated_pr += update_billed_amount_based_on_po(po_details, update_modified) + + adjust_incoming_rate = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + for pr in set(updated_pr): + pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr) + update_billing_percentage( + pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate + ) + + def get_pr_details_billed_amt(self) -> dict: + # Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice + + pr_details_billed_amt = {} + pr_details = [d.get("pr_detail") for d in self.doc.get("items") if d.get("pr_detail")] + if pr_details: + doctype = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(doctype) + .select(doctype.pr_detail, Sum(doctype.amount)) + .where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1) + .groupby(doctype.pr_detail) + ) + + pr_details_billed_amt = frappe._dict(query.run(as_list=1)) + + return pr_details_billed_amt + + def cancel_provisional_entries(self) -> None: + rows = set() + purchase_receipts = set() + for d in self.doc.items: + if d.purchase_receipt: + purchase_receipts.add(d.purchase_receipt) + rows.add(d.name) + + if rows: + # cancel gl entries + gle = qb.DocType("GL Entry") + gle_update_query = ( + qb.update(gle) + .set(gle.is_cancelled, 1) + .where( + (gle.voucher_type == "Purchase Receipt") + & (gle.voucher_no.isin(purchase_receipts)) + & (gle.voucher_detail_no.isin(rows)) + ) + ) + gle_update_query.run()