mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
fix(stock): repair duplicated purchase receipt billing
(cherry picked from commit 8b7e04eae1)
This commit is contained in:
@@ -498,3 +498,4 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
get_billed_amount_against_po,
|
||||
get_billed_amount_against_pr,
|
||||
get_purchase_receipts_against_po_details,
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
purchase_order_items = get_affected_purchase_order_items()
|
||||
if not purchase_order_items:
|
||||
return
|
||||
|
||||
updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items)
|
||||
for purchase_receipt in set(updated_purchase_receipts):
|
||||
update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt))
|
||||
|
||||
|
||||
def get_affected_purchase_order_items() -> list[str]:
|
||||
purchase_order_items = get_candidate_purchase_order_items()
|
||||
if not purchase_order_items:
|
||||
return []
|
||||
|
||||
purchase_receipt_items = get_purchase_receipts_against_po_details(purchase_order_items)
|
||||
direct_billed_amounts = get_billed_amount_against_pr([item.name for item in purchase_receipt_items])
|
||||
po_billed_amounts = get_billed_amount_against_po(purchase_order_items)
|
||||
|
||||
current_billed_amounts = defaultdict(float)
|
||||
available_billed_amounts = defaultdict(float)
|
||||
for purchase_order_item, billed_details in po_billed_amounts.items():
|
||||
available_billed_amounts[purchase_order_item] = flt(billed_details["billed_amt"])
|
||||
|
||||
for item in purchase_receipt_items:
|
||||
current_billed_amounts[item.purchase_order_item] += flt(item.billed_amt)
|
||||
available_billed_amounts[item.purchase_order_item] += flt(direct_billed_amounts.get(item.name))
|
||||
|
||||
precision = frappe.get_precision("Purchase Receipt Item", "billed_amt") or 2
|
||||
return [
|
||||
purchase_order_item
|
||||
for purchase_order_item in purchase_order_items
|
||||
if flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_amt")) > 0
|
||||
and flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_qty")) > 0
|
||||
and flt(
|
||||
current_billed_amounts[purchase_order_item] - available_billed_amounts[purchase_order_item],
|
||||
precision,
|
||||
)
|
||||
> 0
|
||||
]
|
||||
|
||||
|
||||
def get_candidate_purchase_order_items() -> list[str]:
|
||||
purchase_receipt = frappe.qb.DocType("Purchase Receipt")
|
||||
purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
|
||||
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
|
||||
purchase_order_items_with_multiple_receipts = (
|
||||
frappe.qb.from_(purchase_receipt_item)
|
||||
.inner_join(purchase_receipt)
|
||||
.on(purchase_receipt_item.parent == purchase_receipt.name)
|
||||
.select(purchase_receipt_item.purchase_order_item)
|
||||
.where(
|
||||
(purchase_receipt.docstatus == 1)
|
||||
& (purchase_receipt.is_return == 0)
|
||||
& purchase_receipt_item.purchase_order_item.isnotnull()
|
||||
)
|
||||
.groupby(purchase_receipt_item.purchase_order_item)
|
||||
.having(Count(purchase_receipt_item.name) > 1)
|
||||
)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(purchase_invoice_item)
|
||||
.inner_join(purchase_invoice)
|
||||
.on(purchase_invoice_item.parent == purchase_invoice.name)
|
||||
.select(purchase_invoice_item.po_detail)
|
||||
.distinct()
|
||||
.where(
|
||||
(purchase_invoice.docstatus == 1)
|
||||
& (purchase_invoice.update_stock == 0)
|
||||
& purchase_invoice_item.pr_detail.isnull()
|
||||
& purchase_invoice_item.po_detail.isin(purchase_order_items_with_multiple_receipts)
|
||||
)
|
||||
).run(pluck=True)
|
||||
@@ -1164,6 +1164,15 @@ def update_billed_amount_based_on_po(po_details, update_modified=True, pr_doc=No
|
||||
billed_amt_against_pr = flt(flt(billed_amt_against_po) * flt(pr_item.qty)) / flt(
|
||||
billed_qty_against_po
|
||||
)
|
||||
|
||||
# Deduct the amount and qty consumed by this PR so that the next PR
|
||||
# against the same PO Item does not get billed for the same amount again.
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = (
|
||||
billed_amt_against_po - billed_amt_against_pr
|
||||
)
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = (
|
||||
billed_qty_against_po - pr_item.qty
|
||||
)
|
||||
else:
|
||||
pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr
|
||||
if pending_to_bill <= billed_amt_against_po:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, cint, cstr, flt, get_datetime, getdate, nowtime, today
|
||||
from pypika import functions as fn
|
||||
@@ -762,6 +764,104 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
po.reload()
|
||||
po.cancel()
|
||||
|
||||
def test_pr_billing_status_for_po_invoice_across_multiple_receipts(self):
|
||||
"""When a Purchase Invoice is raised directly from a PO and the invoiced qty
|
||||
spans more than one Purchase Receipt, the billed amount must be split between
|
||||
the receipts (FIFO), not duplicated. A receipt with no amount left to consume
|
||||
must not show as fully billed / Completed.
|
||||
|
||||
Flow:
|
||||
1. PO (Qty: 10, Rate: 500) -> PI for Qty 5 (Amount 2500)
|
||||
2. PO -> PR1 (Qty 3) -> gets 1500 billed (fully billed)
|
||||
3. PO -> PR2 (Qty 3) -> gets the remaining 1000 billed (partly billed)
|
||||
"""
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order 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.test_purchase_order import create_purchase_order
|
||||
|
||||
# Qty: 10, Rate: 500
|
||||
po = create_purchase_order()
|
||||
|
||||
pi = make_purchase_invoice_from_po(po.name)
|
||||
pi.get("items")[0].qty = 5
|
||||
pi.submit()
|
||||
|
||||
pr1 = make_purchase_receipt(po.name)
|
||||
pr1.posting_date = today()
|
||||
pr1.posting_time = "08:00"
|
||||
pr1.get("items")[0].received_qty = 3
|
||||
pr1.get("items")[0].qty = 3
|
||||
pr1.submit()
|
||||
|
||||
pr2 = make_purchase_receipt(po.name)
|
||||
pr2.posting_date = today()
|
||||
pr2.posting_time = "10:00"
|
||||
pr2.get("items")[0].received_qty = 3
|
||||
pr2.get("items")[0].qty = 3
|
||||
pr2.submit()
|
||||
|
||||
# PR1 consumes 3 * 500 = 1500 out of the 2500 invoiced -> fully billed.
|
||||
pr1.load_from_db()
|
||||
self.assertEqual(pr1.get("items")[0].billed_amt, 1500)
|
||||
self.assertEqual(pr1.per_billed, 100)
|
||||
self.assertEqual(pr1.status, "Completed")
|
||||
|
||||
# PR2 must only get the remaining 1000 (not 1500 again) -> partly billed.
|
||||
pr2.load_from_db()
|
||||
self.assertEqual(pr2.get("items")[0].billed_amt, 1000)
|
||||
self.assertEqual(flt(pr2.per_billed, 2), 66.67)
|
||||
self.assertEqual(pr2.status, "Partly Billed")
|
||||
|
||||
from erpnext.patches.v16_0 import recalculate_purchase_receipt_billing_status
|
||||
|
||||
purchase_order_item = po.items[0].name
|
||||
with patch.object(
|
||||
recalculate_purchase_receipt_billing_status,
|
||||
"get_candidate_purchase_order_items",
|
||||
return_value=[purchase_order_item],
|
||||
):
|
||||
self.assertEqual(
|
||||
recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(), []
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Purchase Receipt Item",
|
||||
pr2.items[0].name,
|
||||
"billed_amt",
|
||||
1500,
|
||||
update_modified=False,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Purchase Receipt",
|
||||
pr2.name,
|
||||
{"per_billed": 100, "status": "Completed"},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(),
|
||||
[purchase_order_item],
|
||||
)
|
||||
recalculate_purchase_receipt_billing_status.execute()
|
||||
self.assertEqual(
|
||||
recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(), []
|
||||
)
|
||||
|
||||
pr2.load_from_db()
|
||||
self.assertEqual(pr2.get("items")[0].billed_amt, 1000)
|
||||
self.assertEqual(flt(pr2.per_billed, 2), 66.67)
|
||||
self.assertEqual(pr2.status, "Partly Billed")
|
||||
|
||||
pr2.cancel()
|
||||
pr1.reload()
|
||||
pr1.cancel()
|
||||
pi.reload()
|
||||
pi.cancel()
|
||||
po.reload()
|
||||
po.cancel()
|
||||
|
||||
def test_serial_no_against_purchase_receipt(self):
|
||||
item_code = "Test Manual Created Serial No"
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
|
||||
Reference in New Issue
Block a user