mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-26 14:57:06 +00:00
Compare commits
1 Commits
fix/receip
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2c60e098 |
@@ -296,14 +296,7 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
from erpnext.accounts.services.billing_validation import BillingValidationService
|
||||
|
||||
receipt_billing_basis = (
|
||||
"qty"
|
||||
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate")
|
||||
else "amount"
|
||||
)
|
||||
BillingValidationService(self).validate_multiple_billing(
|
||||
"Purchase Receipt", "pr_detail", receipt_billing_basis
|
||||
)
|
||||
BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
self.validate_exchange_rate_with_purchase_receipt()
|
||||
|
||||
@@ -9,7 +9,6 @@ from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
|
||||
get_purchase_receipts_against_po_details,
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
@@ -55,20 +54,6 @@ class BillingStatusService:
|
||||
pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate
|
||||
)
|
||||
|
||||
if adjust_incoming_rate:
|
||||
self.update_billing_status_in_receipts_on_po_lines(set(updated_pr), update_modified)
|
||||
|
||||
def update_billing_status_in_receipts_on_po_lines(self, updated_pr: set, update_modified: bool) -> None:
|
||||
"""Order invoices are spread over every receipt on the line, so billing by qty can shift on any of them."""
|
||||
po_details = list({d.po_detail for d in self.doc.get("items") if d.po_detail})
|
||||
if not po_details:
|
||||
return
|
||||
|
||||
receipts = {pr_item.parent for pr_item in get_purchase_receipts_against_po_details(po_details)}
|
||||
for pr in receipts - updated_pr:
|
||||
pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr)
|
||||
update_billing_percentage(pr_doc, update_modified=update_modified)
|
||||
|
||||
def get_pr_details_billed_amt(self) -> dict:
|
||||
# Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice
|
||||
|
||||
|
||||
@@ -3787,30 +3787,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"maintain_same_rate": 0,
|
||||
"set_landed_cost_based_on_purchase_invoice_rate": 1,
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 0,
|
||||
},
|
||||
)
|
||||
def test_receipt_over_billing_by_qty_when_landed_cost_follows_invoice_rate(self):
|
||||
pr = make_purchase_receipt(qty=100, rate=50)
|
||||
for qty in (25, 75):
|
||||
pi = create_purchase_invoice_from_receipt(pr.name)
|
||||
pi.items[0].qty = qty
|
||||
pi.items[0].rate = 200
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.status, "Completed")
|
||||
|
||||
extra_invoice = frappe.copy_doc(pi)
|
||||
extra_invoice.items[0].qty = 100
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Cannot overbill", extra_invoice.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_po_is_blocked(self):
|
||||
service_item = create_item(
|
||||
|
||||
@@ -49,7 +49,7 @@ class BillingValidationService:
|
||||
overbilled_items.append(row)
|
||||
|
||||
if overbilled_items:
|
||||
self.throw_overbill_exception(overbilled_items, precision, based_on)
|
||||
self.throw_overbill_exception(overbilled_items, precision)
|
||||
|
||||
if is_overbilling_allowed and total_overbilled_amt > 0.1:
|
||||
frappe.msgprint(
|
||||
@@ -92,9 +92,7 @@ class BillingValidationService:
|
||||
|
||||
ref_wise_billed_amount.setdefault(
|
||||
key,
|
||||
frappe._dict(
|
||||
item_code=item.item_code, uom=item.get("uom"), billed_amt=0.0, ref_amt=ref_amt, rows=[]
|
||||
),
|
||||
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
|
||||
@@ -133,7 +131,7 @@ class BillingValidationService:
|
||||
).run()
|
||||
)
|
||||
|
||||
def throw_overbill_exception(self, overbilled_items: list, precision: int, based_on: str) -> None:
|
||||
def throw_overbill_exception(self, overbilled_items: list, precision: int) -> None:
|
||||
message = (
|
||||
_("<p>Cannot overbill for the following Items:</p>")
|
||||
+ "<ul>"
|
||||
@@ -141,7 +139,9 @@ class BillingValidationService:
|
||||
_("<li>Item {0} in row(s) {1} billed more than {2}</li>").format(
|
||||
frappe.bold(item.item_code),
|
||||
", ".join(str(x) for x in item.rows),
|
||||
frappe.bold(self.get_formatted_limit(item, precision, based_on)),
|
||||
frappe.bold(
|
||||
fmt_money(item.max_allowed_amt, precision=precision, currency=self.doc.currency)
|
||||
),
|
||||
)
|
||||
for item in overbilled_items
|
||||
)
|
||||
@@ -149,9 +149,3 @@ class BillingValidationService:
|
||||
)
|
||||
message += _("<p>To allow over-billing, please set allowance in Accounts Settings.</p>")
|
||||
frappe.throw(message)
|
||||
|
||||
def get_formatted_limit(self, item: frappe._dict, precision: int, based_on: str) -> str:
|
||||
if based_on == "qty":
|
||||
return f"{flt(item.max_allowed_amt, precision)} {item.uom}"
|
||||
|
||||
return fmt_money(item.max_allowed_amt, precision=precision, currency=self.doc.currency)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"has_unit_price_items",
|
||||
"amended_from",
|
||||
"revision_of",
|
||||
"is_latest_revision",
|
||||
"currency_and_price_list",
|
||||
"currency",
|
||||
"conversion_rate",
|
||||
@@ -227,6 +228,16 @@
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_latest_revision",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Latest Revision",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
@@ -1169,7 +1180,7 @@
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-24 14:30:00.000000",
|
||||
"modified": "2026-09-26 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
|
||||
@@ -79,6 +79,7 @@ class Quotation(SellingController):
|
||||
in_words: DF.Data | None
|
||||
incoterm: DF.Link | None
|
||||
is_active: DF.Check
|
||||
is_latest_revision: DF.Check
|
||||
item_wise_tax_details: DF.Table[ItemWiseTaxDetail]
|
||||
items: DF.Table[QuotationItem]
|
||||
language: DF.Link | None
|
||||
@@ -384,6 +385,7 @@ class Quotation(SellingController):
|
||||
self.update_opportunity("Quotation")
|
||||
self.update_lead()
|
||||
self.deactivate_other_versions()
|
||||
self.update_latest_revision()
|
||||
|
||||
def deactivate_other_versions(self):
|
||||
if not (self.revision_of and self.is_active):
|
||||
@@ -403,9 +405,31 @@ class Quotation(SellingController):
|
||||
return bool(self.get_other_versions(VERSIONS_TO_SET_AS_LOST))
|
||||
|
||||
def update_other_versions(self, filters: dict, values: dict):
|
||||
names = [version.name for version in self.get_other_versions(filters)]
|
||||
frappe.db.bulk_update("Quotation", {name: values for name in names})
|
||||
for name in names:
|
||||
self.update_versions({version.name: values for version in self.get_other_versions(filters)})
|
||||
|
||||
def update_latest_revision(self):
|
||||
versions = self.get_other_versions({})
|
||||
if not (versions or self.is_latest_revision):
|
||||
return
|
||||
|
||||
if self.docstatus == 1:
|
||||
versions.append(self)
|
||||
|
||||
latest = max(versions, key=get_version_order).name if len(versions) > 1 else None
|
||||
self.update_versions(
|
||||
{
|
||||
version.name: {"is_latest_revision": int(version.name == latest)}
|
||||
for version in versions
|
||||
if version.name != self.name
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
self.db_set("is_latest_revision", int(self.name == latest), update_modified=False)
|
||||
|
||||
@staticmethod
|
||||
def update_versions(updates: dict[str, dict], update_modified: bool = True):
|
||||
frappe.db.bulk_update("Quotation", updates, update_modified=update_modified)
|
||||
for name in updates:
|
||||
frappe.clear_document_cache("Quotation", name)
|
||||
|
||||
@property
|
||||
@@ -413,12 +437,8 @@ class Quotation(SellingController):
|
||||
return not self.get_newer_versions()
|
||||
|
||||
def get_newer_versions(self) -> list[frappe._dict]:
|
||||
own_order = (getdate(self.transaction_date), get_datetime(self.creation))
|
||||
return [
|
||||
version
|
||||
for version in self.get_other_versions({})
|
||||
if (version.transaction_date, version.creation) > own_order
|
||||
]
|
||||
own_order = get_version_order(self)
|
||||
return [version for version in self.get_other_versions({}) if get_version_order(version) > own_order]
|
||||
|
||||
def validate_can_be_revised(self):
|
||||
if self.status in ("Lost", "Ordered"):
|
||||
@@ -443,6 +463,7 @@ class Quotation(SellingController):
|
||||
self.set_status(update=True)
|
||||
self.update_opportunity("Open")
|
||||
self.update_lead()
|
||||
self.update_latest_revision()
|
||||
|
||||
def carry_forward_communication(self):
|
||||
from erpnext.crm.utils import copy_comments, link_communications
|
||||
@@ -485,6 +506,10 @@ class Quotation(SellingController):
|
||||
return rows_with_alternatives
|
||||
|
||||
|
||||
def get_version_order(version) -> tuple:
|
||||
return (getdate(version.transaction_date), get_datetime(version.creation))
|
||||
|
||||
|
||||
def get_list_context(context=None):
|
||||
from erpnext.controllers.website_list_for_contact import get_list_context
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ frappe.listview_settings["Quotation"] = {
|
||||
"currency",
|
||||
"valid_till",
|
||||
"is_active",
|
||||
"is_latest_revision",
|
||||
],
|
||||
|
||||
onload: function (listview) {
|
||||
@@ -38,6 +39,8 @@ frappe.listview_settings["Quotation"] = {
|
||||
return [__("Lost"), "gray", "status,=,Lost"];
|
||||
} else if (doc.docstatus === 1 && !doc.is_active) {
|
||||
return [__("Inactive"), "red", "is_active,=,0"];
|
||||
} else if (doc.status === "Open" && doc.is_latest_revision) {
|
||||
return [__("Latest"), "orange", "is_latest_revision,=,1"];
|
||||
} else if (doc.status === "Open") {
|
||||
return [__("Open"), "orange", "status,=,Open"];
|
||||
} else if (doc.status === "Partially Ordered") {
|
||||
|
||||
@@ -539,6 +539,29 @@ class TestQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(revision.items[0].rate, 250)
|
||||
self.assertEqual(revision.items[0].prevdoc_docname, opportunity.name)
|
||||
|
||||
def test_latest_revision_is_flagged(self):
|
||||
quotation = make_quotation()
|
||||
self.assertEqual(quotation.is_latest_revision, 0)
|
||||
|
||||
first_revision = make_revision(quotation.name)
|
||||
first_revision.insert()
|
||||
first_revision.submit()
|
||||
second_revision = make_revision(first_revision.name)
|
||||
second_revision.insert()
|
||||
second_revision.submit()
|
||||
|
||||
self.assertEqual(self.get_latest_revision_flags(quotation), [0, 0, 1])
|
||||
|
||||
second_revision.cancel()
|
||||
|
||||
self.assertEqual(self.get_latest_revision_flags(quotation), [0, 1, 0])
|
||||
|
||||
def get_latest_revision_flags(self, quotation):
|
||||
return [
|
||||
frappe.db.get_value("Quotation", name, "is_latest_revision")
|
||||
for name in (quotation.name, f"{quotation.name}-R1", f"{quotation.name}-R2")
|
||||
]
|
||||
|
||||
def test_submitting_a_revision_deactivates_other_versions(self):
|
||||
quotation = make_quotation()
|
||||
first_revision = make_revision(quotation.name)
|
||||
|
||||
@@ -115,7 +115,6 @@ def get_purchase_receipts_against_po_details(po_details: list) -> list[dict]:
|
||||
.select(
|
||||
purchase_receipt_item.name,
|
||||
purchase_receipt_item.qty,
|
||||
purchase_receipt_item.rejected_qty,
|
||||
purchase_receipt_item.parent,
|
||||
purchase_receipt_item.amount,
|
||||
purchase_receipt_item.billed_amt,
|
||||
@@ -183,42 +182,28 @@ def get_billed_amount_against_po(po_items: list) -> dict:
|
||||
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")
|
||||
bill_for_rejected = buying_settings.bill_for_rejected_quantity_in_purchase_invoice
|
||||
items = [item for item in pr_doc.items if not item.closed] or pr_doc.items
|
||||
|
||||
if buying_settings.set_landed_cost_based_on_purchase_invoice_rate:
|
||||
percent_billed = get_percent_billed_by_qty(pr_doc, items, bill_for_rejected)
|
||||
else:
|
||||
percent_billed = get_percent_billed_by_amount(pr_doc, items, bill_for_rejected)
|
||||
|
||||
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:
|
||||
set_amount_difference_with_purchase_invoice(pr_doc, items)
|
||||
adjust_incoming_rate_for_pr(pr_doc)
|
||||
|
||||
|
||||
def get_percent_billed_by_amount(pr_doc, items: list, bill_for_rejected: bool) -> float:
|
||||
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 = 0, 0
|
||||
item_wise_returned_qty = get_item_wise_returned_qty([item.name for item in pr_doc.items])
|
||||
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()
|
||||
|
||||
for item in items:
|
||||
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 [item for item in pr_doc.items if not item.closed] or 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
|
||||
|
||||
# When rejected qty is billable, its value is part of the billable base too
|
||||
rejected_amount = 0.0
|
||||
if bill_for_rejected:
|
||||
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
|
||||
rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount"))
|
||||
pending_amount = flt(item.amount) + rejected_amount
|
||||
|
||||
@@ -234,7 +219,54 @@ def get_percent_billed_by_amount(pr_doc, items: list, bill_for_rejected: bool) -
|
||||
|
||||
amount = flt(item.amount) + rejected_amount
|
||||
|
||||
if amount and item.billed_amt > 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) * item.qty - flt(item.base_net_amount)
|
||||
|
||||
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
|
||||
@@ -246,138 +278,22 @@ def get_percent_billed_by_amount(pr_doc, items: list, bill_for_rejected: bool) -
|
||||
)
|
||||
)
|
||||
|
||||
return round(100 * (total_billed_amount / (total_amount or 1)), 6)
|
||||
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_percent_billed_by_qty(pr_doc, items: list, bill_for_rejected: bool) -> float:
|
||||
"""Share of each row's qty that is invoiced, weighted by the row's value, or by qty when no row has one."""
|
||||
billable_qty = get_billable_qty_by_row(pr_doc, items, bill_for_rejected)
|
||||
invoiced_qty = get_invoiced_qty(pr_doc, bill_for_rejected)
|
||||
weigh_by_value = any(flt(item.rate) for item in items)
|
||||
|
||||
total_weight, billed_weight = 0.0, 0.0
|
||||
for item in items:
|
||||
qty = billable_qty[item.name]
|
||||
if not qty:
|
||||
continue
|
||||
|
||||
weight = abs(qty * flt(item.rate)) if weigh_by_value else abs(qty)
|
||||
total_weight += weight
|
||||
billed_weight += weight * min(flt(invoiced_qty.get(item.name)) / qty, 1)
|
||||
|
||||
return round(100 * (billed_weight / (total_weight or 1)), 6)
|
||||
|
||||
|
||||
def get_billable_qty_by_row(pr_doc, items: list, bill_for_rejected: bool) -> dict:
|
||||
"""Qty left to bill per row; a receipt returned in full is measured against what it received."""
|
||||
returned_qty = get_item_wise_returned_qty([item.name for item in pr_doc.items])
|
||||
billable_qty = {
|
||||
item.name: get_billable_qty(item, returned_qty.get(item.name), bill_for_rejected) for item in items
|
||||
}
|
||||
if any(qty > 0 for qty in billable_qty.values()):
|
||||
return billable_qty
|
||||
|
||||
return {item.name: flt(item.qty) for item in items}
|
||||
|
||||
|
||||
def get_billable_qty(item, returned_qty: float | None, bill_for_rejected: bool) -> float:
|
||||
if bill_for_rejected:
|
||||
return flt(item.qty) + flt(item.rejected_qty)
|
||||
|
||||
return flt(item.qty) - flt(returned_qty)
|
||||
|
||||
|
||||
def get_invoiced_qty(pr_doc, bill_for_rejected: bool) -> dict:
|
||||
"""Invoiced qty per Purchase Receipt Item, with Purchase Order invoices spread across receipts."""
|
||||
billed = get_billed_qty_amount_against_purchase_receipt([item.name for item in pr_doc.items])
|
||||
invoiced_qty = {pr_detail: row["qty"] for pr_detail, row in billed.items()}
|
||||
|
||||
po_details = [item.purchase_order_item for item in pr_doc.items if item.purchase_order_item]
|
||||
if po_details:
|
||||
invoiced_qty.update(get_invoiced_qty_based_on_po(po_details, bill_for_rejected))
|
||||
|
||||
for item in pr_doc.items:
|
||||
if item.purchase_invoice_item:
|
||||
invoiced_qty[item.name] = flt(item.qty)
|
||||
|
||||
return invoiced_qty
|
||||
|
||||
|
||||
def get_invoiced_qty_based_on_po(po_details: list, bill_for_rejected: bool) -> dict:
|
||||
"""Fill receipts FIFO with the qty invoiced directly against the Purchase Order."""
|
||||
po_billed = get_billed_amount_against_po(po_details)
|
||||
pending_po_qty = {po_detail: row["billed_qty"] for po_detail, row in po_billed.items()}
|
||||
|
||||
pr_items = get_purchase_receipts_against_po_details(po_details)
|
||||
pr_item_names = [pr_item.name for pr_item in pr_items]
|
||||
billed_against_pr = get_billed_qty_amount_against_purchase_receipt(pr_item_names)
|
||||
returned_qty = get_item_wise_returned_qty(pr_item_names)
|
||||
|
||||
invoiced_qty = {}
|
||||
for pr_item in pr_items:
|
||||
direct_qty = flt(billed_against_pr.get(pr_item.name, {}).get("qty"))
|
||||
billable_qty = get_billable_qty(pr_item, returned_qty.get(pr_item.name), bill_for_rejected)
|
||||
available_qty = flt(pending_po_qty.get(pr_item.purchase_order_item))
|
||||
qty_from_po = max(min(billable_qty - direct_qty, available_qty), 0)
|
||||
|
||||
pending_po_qty[pr_item.purchase_order_item] = available_qty - qty_from_po
|
||||
invoiced_qty[pr_item.name] = direct_qty + qty_from_po
|
||||
|
||||
return invoiced_qty
|
||||
|
||||
|
||||
def set_amount_difference_with_purchase_invoice(pr_doc, items: list) -> None:
|
||||
billed_qty_amt = get_billed_qty_amount_against_purchase_receipt([item.name for item in pr_doc.items])
|
||||
billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc)
|
||||
|
||||
for item in items:
|
||||
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) * item.qty - flt(item.base_net_amount)
|
||||
|
||||
adjusted_amt = flt(adjusted_amt, item.precision("amount"))
|
||||
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
|
||||
|
||||
|
||||
def get_billed_qty_amount_against_purchase_receipt(pr_names: list) -> dict:
|
||||
if not pr_names:
|
||||
return frappe._dict()
|
||||
|
||||
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 = (
|
||||
@@ -389,11 +305,7 @@ def get_billed_qty_amount_against_purchase_receipt(pr_names: list) -> dict:
|
||||
fn.Sum(table.base_net_amount).as_("amount"),
|
||||
fn.Sum(table.qty).as_("qty"),
|
||||
)
|
||||
.where(
|
||||
(table.pr_detail.isin(pr_names))
|
||||
& (table.docstatus == 1)
|
||||
& ((parent_table.is_return == 0) | (parent_table.update_billed_amount_in_purchase_receipt == 1))
|
||||
)
|
||||
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
|
||||
.groupby(table.pr_detail)
|
||||
)
|
||||
invoice_data = query.run(as_dict=1)
|
||||
@@ -468,7 +380,9 @@ def adjust_incoming_rate_for_pr(doc) -> None:
|
||||
doc.repost_future_sle_and_gle(force=True)
|
||||
|
||||
|
||||
def get_item_wise_returned_qty(items: list) -> dict:
|
||||
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",
|
||||
|
||||
@@ -1117,123 +1117,6 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
po.reload()
|
||||
po.cancel()
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
|
||||
)
|
||||
def test_per_billed_by_qty_when_landed_cost_follows_invoice_rate(self):
|
||||
pr = make_purchase_receipt(qty=100, rate=50)
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.items[0].qty = 25
|
||||
pi.items[0].rate = 200
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.per_billed, 25)
|
||||
self.assertEqual(pr.status, "Partly Billed")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
|
||||
)
|
||||
def test_po_invoice_qty_spread_fifo_when_landed_cost_follows_invoice_rate(self):
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
po = create_purchase_order(qty=100, rate=50)
|
||||
receipts = make_receipts_against_order(po.name, ((60, "08:00"), (40, "10:00")))
|
||||
make_invoice_against_order(po.name, qty=70, rate=40)
|
||||
|
||||
for pr in receipts:
|
||||
pr.reload()
|
||||
|
||||
self.assertEqual(receipts[0].per_billed, 100)
|
||||
self.assertEqual(receipts[1].per_billed, 25)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"maintain_same_rate": 0,
|
||||
"set_landed_cost_based_on_purchase_invoice_rate": 1,
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 0,
|
||||
},
|
||||
)
|
||||
def test_fully_returned_receipt_skipped_in_po_invoice_split(self):
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
|
||||
|
||||
po = create_purchase_order(qty=100, rate=50)
|
||||
receipts = make_receipts_against_order(po.name, ((60, "08:00"), (40, "10:00")))
|
||||
make_purchase_return(receipts[0].name).submit()
|
||||
make_invoice_against_order(po.name, qty=40, rate=50)
|
||||
|
||||
for pr in receipts:
|
||||
pr.reload()
|
||||
|
||||
self.assertEqual(receipts[0].per_billed, 0)
|
||||
self.assertEqual(receipts[1].per_billed, 100)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"maintain_same_rate": 0,
|
||||
"set_landed_cost_based_on_purchase_invoice_rate": 1,
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 0,
|
||||
},
|
||||
)
|
||||
def test_fully_returned_row_left_out_of_qty_billing(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
|
||||
|
||||
pr = make_purchase_receipt(qty=10, rate=50, do_not_save=True)
|
||||
pr.append("items", pr.items[0].as_dict(no_default_fields=True))
|
||||
pr.submit()
|
||||
returned_row, invoiced_row = pr.items
|
||||
|
||||
pr_return = make_purchase_return(pr.name)
|
||||
pr_return.set(
|
||||
"items", [row for row in pr_return.items if row.purchase_receipt_item == returned_row.name]
|
||||
)
|
||||
pr_return.submit()
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.set("items", [row for row in pi.items if row.pr_detail == invoiced_row.name])
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.per_billed, 100)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
|
||||
)
|
||||
def test_zero_rate_receipt_billed_by_qty(self):
|
||||
pr = make_purchase_receipt(item_code="_Test Non Stock Item", qty=10, rate=0)
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.items[0].rate = 5
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.per_billed, 100)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
|
||||
)
|
||||
def test_non_updating_debit_note_kept_out_of_qty_billing(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note
|
||||
|
||||
pr = make_purchase_receipt(qty=100, rate=50)
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.items[0].qty = 50
|
||||
pi.submit()
|
||||
|
||||
debit_note = make_debit_note(pi.name)
|
||||
debit_note.items[0].qty = -20
|
||||
debit_note.update_billed_amount_in_purchase_receipt = 0
|
||||
debit_note.submit()
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.items[0].qty = 50
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.per_billed, 100)
|
||||
|
||||
def test_serial_no_against_purchase_receipt(self):
|
||||
item_code = "Test Manual Created Serial No"
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
@@ -7888,31 +7771,6 @@ def get_items(**args):
|
||||
]
|
||||
|
||||
|
||||
def make_receipts_against_order(purchase_order: str, receipts: tuple) -> list:
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt as make_receipt_from_order
|
||||
|
||||
receipt_docs = []
|
||||
for qty, posting_time in receipts:
|
||||
pr = make_receipt_from_order(purchase_order)
|
||||
pr.set_posting_time = 1
|
||||
pr.posting_time = posting_time
|
||||
pr.items[0].received_qty = qty
|
||||
pr.items[0].qty = qty
|
||||
pr.submit()
|
||||
receipt_docs.append(pr)
|
||||
|
||||
return receipt_docs
|
||||
|
||||
|
||||
def make_invoice_against_order(purchase_order: str, qty: float, rate: float) -> None:
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice as make_invoice_from_order
|
||||
|
||||
pi = make_invoice_from_order(purchase_order)
|
||||
pi.items[0].qty = qty
|
||||
pi.items[0].rate = rate
|
||||
pi.submit()
|
||||
|
||||
|
||||
def make_purchase_receipt(**args):
|
||||
frappe.db.set_single_value("Buying Settings", "allow_multiple_items", 1)
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
|
||||
Reference in New Issue
Block a user