Compare commits

..

1 Commits

Author SHA1 Message Date
Mihir Kandoi
4a2c60e098 feat(selling): show the latest quotation revision as latest (#59485) 2026-09-26 13:41:48 +00:00
10 changed files with 155 additions and 373 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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(

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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") {

View File

@@ -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)

View File

@@ -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",

View File

@@ -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")