From d1765e85aa357e28638f10c0bc81286450cdbc4a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 7 Jun 2026 09:53:37 +0530 Subject: [PATCH] refactor(stock): extract Purchase Receipt BillingStatusService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move PR↔PI billed-amount allocation into stock/doctype/purchase_receipt/services/billing_status.py. Purchase Receipt owns the shared buying billing logic; Purchase Invoice imports from the service module: - update_billing_status stays as a controller delegator (called by Purchase Invoice flows and v13 patches) - the module-function family moves verbatim: update_billed_amount_based_on_po, update_billing_percentage, get_billed_amount_against_pr/_po, get_purchase_receipts_against_po_details, get_billed_qty_amount_against_purchase_receipt/_order, adjust_incoming_rate_for_pr, get_item_wise_returned_qty - imports repointed in purchase_invoice.py (top-level + lazy) and patches/v15_0/recalculate_amount_difference_field.py No behaviour change. --- .../purchase_invoice/purchase_invoice.py | 6 +- .../recalculate_amount_difference_field.py | 2 +- .../purchase_receipt/purchase_receipt.py | 371 +---------------- .../services/billing_status.py | 391 ++++++++++++++++++ 4 files changed, 398 insertions(+), 372 deletions(-) create mode 100644 erpnext/stock/doctype/purchase_receipt/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 71981673b8e..a34ff1485f5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -35,7 +35,7 @@ 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.controllers.accounts_controller import validate_account_head from erpnext.controllers.buying_controller import BuyingController -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( +from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( update_billed_amount_based_on_po, ) @@ -1024,7 +1024,9 @@ class PurchaseInvoice(BuyingController): ) for pr in set(updated_pr): - from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage + 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( diff --git a/erpnext/patches/v15_0/recalculate_amount_difference_field.py b/erpnext/patches/v15_0/recalculate_amount_difference_field.py index d7b56c206d2..622bea6120e 100644 --- a/erpnext/patches/v15_0/recalculate_amount_difference_field.py +++ b/erpnext/patches/v15_0/recalculate_amount_difference_field.py @@ -3,7 +3,7 @@ from frappe.query_builder.functions import Sum from frappe.utils import flt, getdate from erpnext.accounts.utils import get_fiscal_year -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import adjust_incoming_rate_for_pr +from erpnext.stock.doctype.purchase_receipt.services.billing_status import adjust_incoming_rate_for_pr def execute(): diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 0ec7cfbac16..1905027c2e8 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -6,14 +6,13 @@ import frappe from frappe import _, throw from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document -from frappe.query_builder.functions import CombineDatetime from frappe.utils import cint, flt, get_datetime, getdate, nowdate -from pypika import functions as fn 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.controllers.buying_controller import BuyingController +from erpnext.stock.doctype.purchase_receipt.services.billing_status import BillingStatusService from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -564,20 +563,7 @@ class PurchaseReceipt(BuyingController): clear_doctype_notifications(self) def update_billing_status(self, update_modified=True): - updated_pr = [self.name] - po_details = [] - for d in self.get("items"): - if d.get("purchase_invoice") and d.get("purchase_invoice_item"): - d.db_set("billed_amt", d.amount, update_modified=update_modified) - elif d.purchase_order_item: - po_details.append(d.purchase_order_item) - - if po_details: - updated_pr += update_billed_amount_based_on_po(po_details, update_modified, self) - - for pr in set(updated_pr): - pr_doc = self if (pr == self.name) else frappe.get_lazy_doc("Purchase Receipt", pr) - update_billing_percentage(pr_doc, update_modified=update_modified) + BillingStatusService(self).update_billing_status(update_modified) def reserve_stock(self): self.reserve_stock_for_sales_order() @@ -712,359 +698,6 @@ def get_stock_value_difference(voucher_no, voucher_detail_no, warehouse): ) -def update_billed_amount_based_on_po(po_details, update_modified=True, pr_doc=None): - po_billed_amt_details = get_billed_amount_against_po(po_details) - - # Get all Purchase Receipt Item rows against the Purchase Order Items - pr_details = get_purchase_receipts_against_po_details(po_details) - - pr_items = [pr_detail.name for pr_detail in pr_details] - pr_items_billed_amount = get_billed_amount_against_pr(pr_items) - - updated_pr = [] - for pr_item in pr_details: - billed_amt_against_po, billed_qty_against_po = 0, 0 - if billed_details := po_billed_amt_details.get(pr_item.purchase_order_item): - billed_amt_against_po = flt(billed_details["billed_amt"]) - billed_qty_against_po = flt(billed_details["billed_qty"]) - - # Get billed amount directly against Purchase Receipt - billed_amt_against_pr = flt(pr_items_billed_amount.get(pr_item.name, 0)) - - # Distribute billed amount directly against PO between PRs based on FIFO - if billed_amt_against_po and billed_amt_against_pr < pr_item.amount: - if not billed_amt_against_pr and billed_qty_against_po and billed_qty_against_po > pr_item.qty: - billed_amt_against_pr = flt(flt(billed_amt_against_po) * flt(pr_item.qty)) / flt( - billed_qty_against_po - ) - else: - pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr - if pending_to_bill <= billed_amt_against_po: - billed_amt_against_pr += pending_to_bill - billed_amt_against_po -= pending_to_bill - else: - billed_amt_against_pr += billed_amt_against_po - billed_amt_against_po = 0 - - po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = billed_amt_against_po - - if pr_item.billed_amt != billed_amt_against_pr: - # update existing doc if possible - if pr_doc and pr_item.parent == pr_doc.name: - pr_item = next((item for item in pr_doc.items if item.name == pr_item.name), None) - pr_item.db_set("billed_amt", billed_amt_against_pr, update_modified=update_modified) - - else: - frappe.db.set_value( - "Purchase Receipt Item", - pr_item.name, - "billed_amt", - billed_amt_against_pr, - update_modified=update_modified, - ) - - updated_pr.append(pr_item.parent) - - return updated_pr - - -def get_purchase_receipts_against_po_details(po_details): - # Get Purchase Receipts against Purchase Order Items - - purchase_receipt = frappe.qb.DocType("Purchase Receipt") - purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item") - - query = ( - frappe.qb.from_(purchase_receipt) - .inner_join(purchase_receipt_item) - .on(purchase_receipt.name == purchase_receipt_item.parent) - .select( - purchase_receipt_item.name, - purchase_receipt_item.qty, - purchase_receipt_item.parent, - purchase_receipt_item.amount, - purchase_receipt_item.billed_amt, - purchase_receipt_item.purchase_order_item, - ) - .where( - (purchase_receipt_item.purchase_order_item.isin(po_details)) - & (purchase_receipt.docstatus == 1) - & (purchase_receipt.is_return == 0) - ) - .orderby(CombineDatetime(purchase_receipt.posting_date, purchase_receipt.posting_time)) - .orderby(purchase_receipt.name) - ) - - return query.run(as_dict=True) - - -def get_billed_amount_against_pr(pr_items): - # Get billed amount directly against Purchase Receipt - - if not pr_items: - return {} - - purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") - - query = ( - frappe.qb.from_(purchase_invoice_item) - .select(fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), purchase_invoice_item.pr_detail) - .where((purchase_invoice_item.pr_detail.isin(pr_items)) & (purchase_invoice_item.docstatus == 1)) - .groupby(purchase_invoice_item.pr_detail) - ).run(as_dict=1) - - return {d.pr_detail: flt(d.billed_amt) for d in query} - - -def get_billed_amount_against_po(po_items): - # Get billed amount directly against Purchase Order - if not po_items: - return {} - - purchase_invoice = frappe.qb.DocType("Purchase Invoice") - purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") - - query = ( - frappe.qb.from_(purchase_invoice_item) - .inner_join(purchase_invoice) - .on(purchase_invoice_item.parent == purchase_invoice.name) - .select( - fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), - fn.Sum(purchase_invoice_item.qty).as_("qty"), - purchase_invoice_item.po_detail, - ) - .where( - (purchase_invoice_item.po_detail.isin(po_items)) - & (purchase_invoice.docstatus == 1) - & (purchase_invoice_item.pr_detail.isnull()) - & (purchase_invoice.update_stock == 0) - ) - .groupby(purchase_invoice_item.po_detail) - ).run(as_dict=1) - - return {d.po_detail: {"billed_amt": flt(d.billed_amt), "billed_qty": flt(d.qty)} for d in query} - - -def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False): - # Update Billing % based on pending accepted qty - buying_settings = frappe.get_single("Buying Settings") - over_billing_allowance, role_allowed_to_over_bill = frappe.get_single_value( - "Accounts Settings", ["over_billing_allowance", "role_allowed_to_over_bill"] - ) - - total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0 - item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) - billed_qty_amt = frappe._dict() - - if adjust_incoming_rate: - billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc) - billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc) - - for item in pr_doc.items: - returned_qty = flt(item_wise_returned_qty.get(item.name)) - returned_amount = flt(returned_qty) * flt(item.rate) - pending_amount = flt(item.amount) - returned_amount - if buying_settings.bill_for_rejected_quantity_in_purchase_invoice: - pending_amount = flt(item.amount) - - total_billable_amount = abs(flt(item.amount)) - if pending_amount > 0: - total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt - - total_amount += total_billable_amount - total_billed_amount += abs(flt(item.billed_amt)) - - if pr_doc.get("is_return") and not total_amount and total_billed_amount: - total_amount = total_billed_amount - - amount = item.amount - if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): - amount += flt(item.rejected_qty * item.rate, item.precision("amount")) - - if adjust_incoming_rate: - adjusted_amt = 0.0 - - if ( - item.billed_amt is not None - and item.amount is not None - and ( - billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item) - ) - ): - qty = None - if billed_qty_amt.get(item.name): - qty = billed_qty_amt.get(item.name).get("qty") - - if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item): - if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]: - qty = item.qty - else: - qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"] - - billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty - - billed_amt = item.billed_amt - if billed_qty_amt.get(item.name): - billed_amt = flt(billed_qty_amt.get(item.name).get("amount")) - elif billed_qty_amt_based_on_po.get(item.purchase_order_item): - total_billed_qty = ( - billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty - ) - - if total_billed_qty: - billed_amt = flt( - flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount")) - * (qty / total_billed_qty) - ) - else: - billed_amt = 0.0 - - # Reduce billed amount based on PO for next iterations - billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt - - if qty: - adjusted_amt = ( - flt(billed_amt / qty) - (flt(item.rate) * flt(pr_doc.conversion_rate)) - ) * item.qty - - adjusted_amt = flt(adjusted_amt, item.precision("amount")) - pi_landed_cost_amount += adjusted_amt - item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) - elif amount and item.billed_amt > amount: - per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100 - if ( - per_over_billed > over_billing_allowance - and role_allowed_to_over_bill not in frappe.get_roles() - ): - frappe.throw( - _("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format( - item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance - ) - ) - - if pi_landed_cost_amount < 0: - total_billed_amount += abs(pi_landed_cost_amount) - - percent_billed = round(100 * (total_billed_amount / (total_amount or 1)), 6) - pr_doc.db_set("per_billed", percent_billed) - - if update_modified: - pr_doc.set_status(update=True) - pr_doc.notify_update() - - if adjust_incoming_rate: - adjust_incoming_rate_for_pr(pr_doc) - - -def get_billed_qty_amount_against_purchase_receipt(pr_doc): - pr_names = [d.name for d in pr_doc.items] - parent_table = frappe.qb.DocType("Purchase Invoice") - table = frappe.qb.DocType("Purchase Invoice Item") - query = ( - frappe.qb.from_(parent_table) - .inner_join(table) - .on(parent_table.name == table.parent) - .select( - table.pr_detail, - fn.Sum(table.base_net_amount).as_("amount"), - fn.Sum(table.qty).as_("qty"), - ) - .where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1)) - .groupby(table.pr_detail) - ) - invoice_data = query.run(as_dict=1) - - if not invoice_data: - return frappe._dict() - - billed_qty_amt = frappe._dict() - - for row in invoice_data: - if row.pr_detail not in billed_qty_amt: - billed_qty_amt[row.pr_detail] = {"amount": 0, "qty": 0} - - billed_qty_amt[row.pr_detail]["amount"] += flt(row.amount) - billed_qty_amt[row.pr_detail]["qty"] += flt(row.qty) - - return billed_qty_amt - - -def get_billed_qty_amount_against_purchase_order(pr_doc): - po_names = list( - set( - [ - d.purchase_order_item - for d in pr_doc.items - if d.purchase_order_item and not d.purchase_invoice_item - ] - ) - ) - - invoice_data_po_based = frappe._dict() - if po_names: - parent_table = frappe.qb.DocType("Purchase Invoice") - table = frappe.qb.DocType("Purchase Invoice Item") - - query = ( - frappe.qb.from_(parent_table) - .inner_join(table) - .on(parent_table.name == table.parent) - .select( - table.po_detail, - fn.Sum(table.qty).as_("qty"), - fn.Sum(table.base_net_amount).as_("amount"), - ) - .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull())) - .groupby(table.po_detail) - ) - - invoice_data = query.run(as_dict=1) - if not invoice_data: - return frappe._dict() - - for row in invoice_data: - if row.po_detail not in invoice_data_po_based: - invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0} - - invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount) - invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty) - - return invoice_data_po_based - - -def adjust_incoming_rate_for_pr(doc): - doc.update_valuation_rate(reset_outgoing_rate=False) - - for item in doc.get("items"): - item.db_update() - - if doc.doctype == "Purchase Receipt": - doc.enable_recalculate_rate_in_sles() - - doc.repost_future_sle_and_gle(force=True) - - -def get_item_wise_returned_qty(pr_doc): - items = [d.name for d in pr_doc.items] - - return frappe._dict( - frappe.get_all( - "Purchase Receipt", - fields=[ - "`tabPurchase Receipt Item`.purchase_receipt_item", - {"SUM": [{"ABS": "`tabPurchase Receipt Item`.qty"}], "as": "qty"}, - ], - filters=[ - ["Purchase Receipt", "docstatus", "=", 1], - ["Purchase Receipt", "is_return", "=", 1], - ["Purchase Receipt Item", "purchase_receipt_item", "in", items], - ], - group_by="`tabPurchase Receipt Item`.purchase_receipt_item", - as_list=1, - ) - ) - - @frappe.whitelist() def update_purchase_receipt_status(docname: str, status: str): pr = frappe.get_lazy_doc("Purchase Receipt", docname, check_permission="submit") diff --git a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py new file mode 100644 index 00000000000..519a6c9fc9e --- /dev/null +++ b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py @@ -0,0 +1,391 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Billing status tracking and PR↔PI billed-amount allocation for Purchase Receipt. + +Purchase Invoice imports the module-level allocation helpers from here — +Purchase Receipt owns the shared buying billing logic. +""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import CombineDatetime +from frappe.utils import flt +from pypika import functions as fn + + +class BillingStatusService: + def __init__(self, doc): + self.doc = doc + + def update_billing_status(self, update_modified: bool = True) -> None: + doc = self.doc + updated_pr = [doc.name] + po_details = [] + for d in doc.get("items"): + if d.get("purchase_invoice") and d.get("purchase_invoice_item"): + d.db_set("billed_amt", d.amount, update_modified=update_modified) + elif d.purchase_order_item: + po_details.append(d.purchase_order_item) + + if po_details: + updated_pr += update_billed_amount_based_on_po(po_details, update_modified, doc) + + for pr in set(updated_pr): + pr_doc = doc if (pr == doc.name) else frappe.get_lazy_doc("Purchase Receipt", pr) + update_billing_percentage(pr_doc, update_modified=update_modified) + + +def update_billed_amount_based_on_po(po_details: list, update_modified: bool = True, pr_doc=None) -> list: + po_billed_amt_details = get_billed_amount_against_po(po_details) + + # Get all Purchase Receipt Item rows against the Purchase Order Items + pr_details = get_purchase_receipts_against_po_details(po_details) + + pr_items = [pr_detail.name for pr_detail in pr_details] + pr_items_billed_amount = get_billed_amount_against_pr(pr_items) + + updated_pr = [] + for pr_item in pr_details: + billed_amt_against_po, billed_qty_against_po = 0, 0 + if billed_details := po_billed_amt_details.get(pr_item.purchase_order_item): + billed_amt_against_po = flt(billed_details["billed_amt"]) + billed_qty_against_po = flt(billed_details["billed_qty"]) + + # Get billed amount directly against Purchase Receipt + billed_amt_against_pr = flt(pr_items_billed_amount.get(pr_item.name, 0)) + + # Distribute billed amount directly against PO between PRs based on FIFO + if billed_amt_against_po and billed_amt_against_pr < pr_item.amount: + if not billed_amt_against_pr and billed_qty_against_po and billed_qty_against_po > pr_item.qty: + billed_amt_against_pr = flt(flt(billed_amt_against_po) * flt(pr_item.qty)) / flt( + billed_qty_against_po + ) + else: + pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr + if pending_to_bill <= billed_amt_against_po: + billed_amt_against_pr += pending_to_bill + billed_amt_against_po -= pending_to_bill + else: + billed_amt_against_pr += billed_amt_against_po + billed_amt_against_po = 0 + + po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = billed_amt_against_po + + if pr_item.billed_amt != billed_amt_against_pr: + # update existing doc if possible + if pr_doc and pr_item.parent == pr_doc.name: + pr_item = next((item for item in pr_doc.items if item.name == pr_item.name), None) + pr_item.db_set("billed_amt", billed_amt_against_pr, update_modified=update_modified) + + else: + frappe.db.set_value( + "Purchase Receipt Item", + pr_item.name, + "billed_amt", + billed_amt_against_pr, + update_modified=update_modified, + ) + + updated_pr.append(pr_item.parent) + + return updated_pr + + +def get_purchase_receipts_against_po_details(po_details: list) -> list[dict]: + # Get Purchase Receipts against Purchase Order Items + + purchase_receipt = frappe.qb.DocType("Purchase Receipt") + purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item") + + query = ( + frappe.qb.from_(purchase_receipt) + .inner_join(purchase_receipt_item) + .on(purchase_receipt.name == purchase_receipt_item.parent) + .select( + purchase_receipt_item.name, + purchase_receipt_item.qty, + purchase_receipt_item.parent, + purchase_receipt_item.amount, + purchase_receipt_item.billed_amt, + purchase_receipt_item.purchase_order_item, + ) + .where( + (purchase_receipt_item.purchase_order_item.isin(po_details)) + & (purchase_receipt.docstatus == 1) + & (purchase_receipt.is_return == 0) + ) + .orderby(CombineDatetime(purchase_receipt.posting_date, purchase_receipt.posting_time)) + .orderby(purchase_receipt.name) + ) + + return query.run(as_dict=True) + + +def get_billed_amount_against_pr(pr_items: list) -> dict: + # Get billed amount directly against Purchase Receipt + + if not pr_items: + return {} + + purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") + + query = ( + frappe.qb.from_(purchase_invoice_item) + .select(fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), purchase_invoice_item.pr_detail) + .where((purchase_invoice_item.pr_detail.isin(pr_items)) & (purchase_invoice_item.docstatus == 1)) + .groupby(purchase_invoice_item.pr_detail) + ).run(as_dict=1) + + return {d.pr_detail: flt(d.billed_amt) for d in query} + + +def get_billed_amount_against_po(po_items: list) -> dict: + # Get billed amount directly against Purchase Order + if not po_items: + return {} + + purchase_invoice = frappe.qb.DocType("Purchase Invoice") + purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") + + query = ( + frappe.qb.from_(purchase_invoice_item) + .inner_join(purchase_invoice) + .on(purchase_invoice_item.parent == purchase_invoice.name) + .select( + fn.Sum(purchase_invoice_item.amount).as_("billed_amt"), + fn.Sum(purchase_invoice_item.qty).as_("qty"), + purchase_invoice_item.po_detail, + ) + .where( + (purchase_invoice_item.po_detail.isin(po_items)) + & (purchase_invoice.docstatus == 1) + & (purchase_invoice_item.pr_detail.isnull()) + & (purchase_invoice.update_stock == 0) + ) + .groupby(purchase_invoice_item.po_detail) + ).run(as_dict=1) + + return {d.po_detail: {"billed_amt": flt(d.billed_amt), "billed_qty": flt(d.qty)} for d in query} + + +def update_billing_percentage( + pr_doc, update_modified: bool = True, adjust_incoming_rate: bool = False +) -> None: + # Update Billing % based on pending accepted qty + buying_settings = frappe.get_single("Buying Settings") + over_billing_allowance, role_allowed_to_over_bill = frappe.get_single_value( + "Accounts Settings", ["over_billing_allowance", "role_allowed_to_over_bill"] + ) + + total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0 + item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) + billed_qty_amt = frappe._dict() + + if adjust_incoming_rate: + billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc) + billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc) + + for item in pr_doc.items: + returned_qty = flt(item_wise_returned_qty.get(item.name)) + returned_amount = flt(returned_qty) * flt(item.rate) + pending_amount = flt(item.amount) - returned_amount + if buying_settings.bill_for_rejected_quantity_in_purchase_invoice: + pending_amount = flt(item.amount) + + total_billable_amount = abs(flt(item.amount)) + if pending_amount > 0: + total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt + + total_amount += total_billable_amount + total_billed_amount += abs(flt(item.billed_amt)) + + if pr_doc.get("is_return") and not total_amount and total_billed_amount: + total_amount = total_billed_amount + + amount = item.amount + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + amount += flt(item.rejected_qty * item.rate, item.precision("amount")) + + if adjust_incoming_rate: + adjusted_amt = 0.0 + + if ( + item.billed_amt is not None + and item.amount is not None + and ( + billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item) + ) + ): + qty = None + if billed_qty_amt.get(item.name): + qty = billed_qty_amt.get(item.name).get("qty") + + if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item): + if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]: + qty = item.qty + else: + qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"] + + billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty + + billed_amt = item.billed_amt + if billed_qty_amt.get(item.name): + billed_amt = flt(billed_qty_amt.get(item.name).get("amount")) + elif billed_qty_amt_based_on_po.get(item.purchase_order_item): + total_billed_qty = ( + billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty + ) + + if total_billed_qty: + billed_amt = flt( + flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount")) + * (qty / total_billed_qty) + ) + else: + billed_amt = 0.0 + + # Reduce billed amount based on PO for next iterations + billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt + + if qty: + adjusted_amt = ( + flt(billed_amt / qty) - (flt(item.rate) * flt(pr_doc.conversion_rate)) + ) * item.qty + + adjusted_amt = flt(adjusted_amt, item.precision("amount")) + pi_landed_cost_amount += adjusted_amt + item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) + elif amount and item.billed_amt > amount: + per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100 + if ( + per_over_billed > over_billing_allowance + and role_allowed_to_over_bill not in frappe.get_roles() + ): + frappe.throw( + _("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format( + item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance + ) + ) + + if pi_landed_cost_amount < 0: + total_billed_amount += abs(pi_landed_cost_amount) + + percent_billed = round(100 * (total_billed_amount / (total_amount or 1)), 6) + pr_doc.db_set("per_billed", percent_billed) + + if update_modified: + pr_doc.set_status(update=True) + pr_doc.notify_update() + + if adjust_incoming_rate: + adjust_incoming_rate_for_pr(pr_doc) + + +def get_billed_qty_amount_against_purchase_receipt(pr_doc) -> dict: + pr_names = [d.name for d in pr_doc.items] + parent_table = frappe.qb.DocType("Purchase Invoice") + table = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(parent_table) + .inner_join(table) + .on(parent_table.name == table.parent) + .select( + table.pr_detail, + fn.Sum(table.base_net_amount).as_("amount"), + fn.Sum(table.qty).as_("qty"), + ) + .where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1)) + .groupby(table.pr_detail) + ) + invoice_data = query.run(as_dict=1) + + if not invoice_data: + return frappe._dict() + + billed_qty_amt = frappe._dict() + + for row in invoice_data: + if row.pr_detail not in billed_qty_amt: + billed_qty_amt[row.pr_detail] = {"amount": 0, "qty": 0} + + billed_qty_amt[row.pr_detail]["amount"] += flt(row.amount) + billed_qty_amt[row.pr_detail]["qty"] += flt(row.qty) + + return billed_qty_amt + + +def get_billed_qty_amount_against_purchase_order(pr_doc) -> dict: + po_names = list( + set( + [ + d.purchase_order_item + for d in pr_doc.items + if d.purchase_order_item and not d.purchase_invoice_item + ] + ) + ) + + invoice_data_po_based = frappe._dict() + if po_names: + parent_table = frappe.qb.DocType("Purchase Invoice") + table = frappe.qb.DocType("Purchase Invoice Item") + + query = ( + frappe.qb.from_(parent_table) + .inner_join(table) + .on(parent_table.name == table.parent) + .select( + table.po_detail, + fn.Sum(table.qty).as_("qty"), + fn.Sum(table.base_net_amount).as_("amount"), + ) + .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull())) + .groupby(table.po_detail) + ) + + invoice_data = query.run(as_dict=1) + if not invoice_data: + return frappe._dict() + + for row in invoice_data: + if row.po_detail not in invoice_data_po_based: + invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0} + + invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount) + invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty) + + return invoice_data_po_based + + +def adjust_incoming_rate_for_pr(doc) -> None: + doc.update_valuation_rate(reset_outgoing_rate=False) + + for item in doc.get("items"): + item.db_update() + + if doc.doctype == "Purchase Receipt": + doc.enable_recalculate_rate_in_sles() + + doc.repost_future_sle_and_gle(force=True) + + +def get_item_wise_returned_qty(pr_doc) -> dict: + items = [d.name for d in pr_doc.items] + + return frappe._dict( + frappe.get_all( + "Purchase Receipt", + fields=[ + "`tabPurchase Receipt Item`.purchase_receipt_item", + {"SUM": [{"ABS": "`tabPurchase Receipt Item`.qty"}], "as": "qty"}, + ], + filters=[ + ["Purchase Receipt", "docstatus", "=", 1], + ["Purchase Receipt", "is_return", "=", 1], + ["Purchase Receipt Item", "purchase_receipt_item", "in", items], + ], + group_by="`tabPurchase Receipt Item`.purchase_receipt_item", + as_list=1, + ) + )