feat(buying): track supplier quotation order status (#58572)

This commit is contained in:
Mihir Kandoi
2026-09-01 13:48:29 +05:30
committed by GitHub
parent db52e465d0
commit e2f24eea15
17 changed files with 427 additions and 39 deletions

View File

@@ -36,7 +36,9 @@ class ChildItemUpdater:
def update(self, trans_items: str | list) -> None: def update(self, trans_items: str | list) -> None:
"""Process item additions, edits, and deletions from trans_items JSON.""" """Process item additions, edits, and deletions from trans_items JSON."""
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items from erpnext.buying.doctype.supplier_quotation.mapper import (
get_ordered_items as get_ordered_supplier_quotation_items,
)
from erpnext.selling.doctype.quotation.mapper import get_ordered_items from erpnext.selling.doctype.quotation.mapper import get_ordered_items
data = frappe.parse_json(trans_items) data = frappe.parse_json(trans_items)
@@ -52,7 +54,7 @@ class ChildItemUpdater:
self.parent, data, self._transacted_stock_qty self.parent, data, self._transacted_stock_qty
) )
elif self.parent_doctype == "Supplier Quotation": elif self.parent_doctype == "Supplier Quotation":
self._transacted_stock_qty = get_purchased_items(self.parent.name) self._transacted_stock_qty = get_ordered_supplier_quotation_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children( items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty self.parent, data, self._transacted_stock_qty
) )

View File

@@ -535,7 +535,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
}, },
get_query_filters: { get_query_filters: {
docstatus: 1, docstatus: 1,
status: ["not in", ["Stopped", "Expired"]], status: ["not in", ["Stopped", "Ordered", "Expired"]],
}, },
allow_child_item_selection: true, allow_child_item_selection: true,
child_fieldname: "items", child_fieldname: "items",

View File

@@ -178,7 +178,15 @@ class PurchaseOrder(BuyingController):
"global_allowance_field": "over_order_allowance", "global_allowance_field": "over_order_allowance",
"global_allowance_doctype": "Buying Settings", "global_allowance_doctype": "Buying Settings",
"item_allowance_field": "over_order_allowance", "item_allowance_field": "over_order_allowance",
} },
{
"source_dt": "Purchase Order Item",
"target_dt": "Supplier Quotation Item",
"join_field": "supplier_quotation_item",
"target_field": "ordered_qty",
"target_ref_field": "stock_qty",
"source_field": "stock_qty",
},
] ]
def onload(self): def onload(self):
@@ -250,6 +258,7 @@ class PurchaseOrder(BuyingController):
["conversion_factor", "="], ["conversion_factor", "="],
], ],
"is_child_table": True, "is_child_table": True,
"allow_duplicate_prev_row_id": True,
}, },
"Material Request": { "Material Request": {
"ref_dn_field": "material_request", "ref_dn_field": "material_request",
@@ -408,6 +417,19 @@ class PurchaseOrder(BuyingController):
def is_item_closable(self, item): def is_item_closable(self, item):
return flt(item.received_qty) < flt(item.qty) or super().is_item_closable(item) return flt(item.received_qty) < flt(item.qty) or super().is_item_closable(item)
def update_prevdoc_status(self):
super().update_prevdoc_status()
for supplier_quotation in {item.supplier_quotation for item in self.items}:
if not supplier_quotation:
continue
doc = frappe.get_doc("Supplier Quotation", supplier_quotation)
if doc.docstatus.is_cancelled():
frappe.throw(_("Supplier Quotation {0} is cancelled").format(supplier_quotation))
doc.set_status(update=True)
def on_submit(self): def on_submit(self):
super().on_submit() super().on_submit()

View File

@@ -18,6 +18,7 @@ def make_purchase_order(
if args is None: if args is None:
args = {} args = {}
args = frappe.parse_json(args) args = frappe.parse_json(args)
ordered_items = get_ordered_items(source_name)
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item") mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
@@ -27,7 +28,9 @@ def make_purchase_order(
target.run_method("calculate_taxes_and_totals") target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent): def update_item(obj, target, source_parent):
target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
def select_item(d): def select_item(d):
filtered_items = args.get("filtered_children", []) filtered_items = args.get("filtered_children", [])
@@ -55,8 +58,9 @@ def make_purchase_order(
["sales_order", "sales_order"], ["sales_order", "sales_order"],
], ],
"postprocess": update_item, "postprocess": update_item,
# no qty tracking between the two, so dedupe on the row reference alone "condition": lambda item: item.name not in mapped_items
"condition": lambda d: d.name not in mapped_items and select_item(d), and (item.stock_qty > ordered_items.get(item.name, 0.0) or item.qty == 0)
and select_item(item),
}, },
"Purchase Taxes and Charges": { "Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges", "doctype": "Purchase Taxes and Charges",
@@ -112,3 +116,14 @@ def make_quotation(source_name: str, target_doc: str | dict | Document | None =
) )
return doclist return doclist
def get_ordered_items(supplier_quotation: str) -> frappe._dict:
return frappe._dict(
frappe.get_all(
"Supplier Quotation Item",
{"docstatus": 1, "parent": supplier_quotation, "ordered_qty": (">", 0)},
["name", "ordered_qty"],
as_list=True,
)
)

View File

@@ -34,7 +34,10 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
if (this.frm.doc.__islocal && !this.frm.doc.valid_till) { if (this.frm.doc.__islocal && !this.frm.doc.valid_till) {
this.frm.set_value("valid_till", frappe.datetime.add_months(this.frm.doc.transaction_date, 1)); this.frm.set_value("valid_till", frappe.datetime.add_months(this.frm.doc.transaction_date, 1));
} }
if (this.frm.doc.docstatus === 1) { if (
this.frm.doc.docstatus === 1 &&
!["Stopped", "Ordered", "Expired"].includes(this.frm.doc.status)
) {
this.frm.add_custom_button( this.frm.add_custom_button(
__("Purchase Order"), __("Purchase Order"),
this.make_purchase_order.bind(this), this.make_purchase_order.bind(this),

View File

@@ -767,7 +767,7 @@
"no_copy": 1, "no_copy": 1,
"oldfieldname": "status", "oldfieldname": "status",
"oldfieldtype": "Select", "oldfieldtype": "Select",
"options": "\nDraft\nSubmitted\nStopped\nCancelled\nExpired", "options": "\nDraft\nSubmitted\nPartially Ordered\nOrdered\nStopped\nCancelled\nExpired",
"print_hide": 1, "print_hide": 1,
"read_only": 1, "read_only": 1,
"reqd": 1, "reqd": 1,
@@ -948,7 +948,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-08-21 23:11:50.274992", "modified": "2026-08-30 16:30:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Supplier Quotation", "name": "Supplier Quotation",

View File

@@ -6,10 +6,13 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import getdate, nowdate from frappe.utils import getdate, nowdate
from pypika.terms import ExistsCriterion
from erpnext.buying.utils import validate_for_items from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController from erpnext.controllers.buying_controller import BuyingController
from .mapper import get_ordered_items
form_grid_templates = {"items": "templates/form_grid/item_grid.html"} form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -86,7 +89,9 @@ class SupplierQuotation(BuyingController):
shipping_address: DF.Link | None shipping_address: DF.Link | None
shipping_address_display: DF.TextEditor | None shipping_address_display: DF.TextEditor | None
shipping_rule: DF.Link | None shipping_rule: DF.Link | None
status: DF.Literal["", "Draft", "Submitted", "Stopped", "Cancelled", "Expired"] status: DF.Literal[
"", "Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"
]
supplier: DF.Link supplier: DF.Link
supplier_address: DF.Link | None supplier_address: DF.Link | None
supplier_name: DF.Data | None supplier_name: DF.Data | None
@@ -112,13 +117,17 @@ class SupplierQuotation(BuyingController):
def validate(self): def validate(self):
super().validate() super().validate()
self.set_status()
if not self.status: if not self.status:
self.status = "Draft" self.status = "Draft"
from erpnext.controllers.status_updater import validate_status from erpnext.controllers.status_updater import validate_status
validate_status(self.status, ["Draft", "Submitted", "Stopped", "Cancelled"]) validate_status(
self.status,
["Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"],
)
validate_for_items(self) validate_for_items(self)
self.validate_with_previous_doc() self.validate_with_previous_doc()
@@ -126,11 +135,11 @@ class SupplierQuotation(BuyingController):
self.validate_valid_till() self.validate_valid_till()
def on_submit(self): def on_submit(self):
self.db_set("status", "Submitted") self.set_status(update=True)
self.update_rfq_supplier_status(1) self.update_rfq_supplier_status(1)
def on_cancel(self): def on_cancel(self):
self.db_set("status", "Cancelled") self.set_status(update=True)
self.update_rfq_supplier_status(0) self.update_rfq_supplier_status(0)
def on_trash(self): def on_trash(self):
@@ -166,6 +175,24 @@ class SupplierQuotation(BuyingController):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till Date cannot be before Transaction Date")) frappe.throw(_("Valid till Date cannot be before Transaction Date"))
def get_ordered_status(self):
ordered_items = get_ordered_items(self.name)
if not ordered_items:
return "Submitted"
for row in self.items:
if row.name not in ordered_items or row.stock_qty > ordered_items[row.name]:
return "Partially Ordered"
return "Ordered"
def is_fully_ordered(self):
return self.get_ordered_status() == "Ordered"
def is_partially_ordered(self):
return self.get_ordered_status() == "Partially Ordered"
def update_rfq_supplier_status(self, include_me): def update_rfq_supplier_status(self, include_me):
from frappe.query_builder.functions import Count from frappe.query_builder.functions import Count
@@ -243,27 +270,29 @@ def get_list_context(context=None):
def set_expired_status(): def set_expired_status():
# Only submitted quotations past their validity should be expired supplier_quotation = frappe.qb.DocType("Supplier Quotation")
frappe.db.set_value( purchase_order = frappe.qb.DocType("Purchase Order")
"Supplier Quotation", purchase_order_item = frappe.qb.DocType("Purchase Order Item")
{
"docstatus": 1,
"status": ["not in", ["Cancelled", "Stopped"]],
"valid_till": ["<", nowdate()],
},
"status",
"Expired",
update_modified=True,
)
purchase_order_against_quotation = (
def get_purchased_items(supplier_quotation: str): frappe.qb.from_(purchase_order)
return frappe._dict( .from_(purchase_order_item)
frappe.get_all( .select(purchase_order.name)
"Purchase Order Item", .where(
filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, (purchase_order_item.docstatus == 1)
fields=["supplier_quotation_item", {"SUM": "stock_qty"}], & (purchase_order.docstatus == 1)
group_by="supplier_quotation_item", & (purchase_order_item.parent == purchase_order.name)
as_list=1, & (purchase_order_item.supplier_quotation == supplier_quotation.name)
) )
) )
(
frappe.qb.update(supplier_quotation)
.set(supplier_quotation.status, "Expired")
.where(
(supplier_quotation.docstatus == 1)
& (supplier_quotation.status.notin(["Expired", "Stopped"]))
& (supplier_quotation.valid_till < nowdate())
& ExistsCriterion(purchase_order_against_quotation).negate()
)
).run()

View File

@@ -1,10 +1,12 @@
frappe.listview_settings["Supplier Quotation"] = { frappe.listview_settings["Supplier Quotation"] = {
add_fields: ["supplier", "base_grand_total", "status", "company", "currency"], add_fields: ["supplier", "base_grand_total", "status", "company", "currency"],
get_indicator: function (doc) { get_indicator: function (doc) {
if (doc.status === "Ordered") { if (doc.status === "Submitted") {
return [__("Submitted"), "blue", "status,=,Submitted"];
} else if (doc.status === "Partially Ordered") {
return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"];
} else if (doc.status === "Ordered") {
return [__("Ordered"), "green", "status,=,Ordered"]; return [__("Ordered"), "green", "status,=,Ordered"];
} else if (doc.status === "Rejected") {
return [__("Lost"), "gray", "status,=,Lost"];
} else if (doc.status === "Expired") { } else if (doc.status === "Expired") {
return [__("Expired"), "gray", "status,=,Expired"]; return [__("Expired"), "gray", "status,=,Expired"];
} }

View File

@@ -15,6 +15,7 @@ from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation imp
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import set_expired_status from erpnext.buying.doctype.supplier_quotation.supplier_quotation import set_expired_status
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.patches.v16_0.set_supplier_quotation_order_status import execute as set_order_status
from erpnext.tests.assertions import assert_raises_with_savepoint from erpnext.tests.assertions import assert_raises_with_savepoint
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
@@ -23,6 +24,23 @@ class TestPurchaseOrder(ERPNextTestSuite):
def setUp(self): def setUp(self):
self.load_test_records("Supplier Quotation") self.load_test_records("Supplier Quotation")
def make_order(self, supplier_quotation, qty):
purchase_order = make_purchase_order(supplier_quotation.name)
purchase_order.naming_series = "_T-Purchase Order-"
purchase_order.items[0].qty = qty
purchase_order.items[0].schedule_date = add_days(today(), 1)
purchase_order.insert()
purchase_order.submit()
return purchase_order
def update_order_qty(self, purchase_order, qty):
item = purchase_order.items[0]
items = json.dumps(
[{"item_code": item.item_code, "rate": item.rate, "qty": qty, "docname": item.name}]
)
update_child_qty_rate("Purchase Order", items, purchase_order.name)
purchase_order.reload()
def test_valid_till_before_transaction_date_rejected(self): def test_valid_till_before_transaction_date_rejected(self):
rfq = make_request_for_quotation() rfq = make_request_for_quotation()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier) sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
@@ -44,6 +62,14 @@ class TestPurchaseOrder(ERPNextTestSuite):
valid.insert() valid.insert()
valid.submit() valid.submit()
partially_ordered = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[1].supplier)
partially_ordered.valid_till = add_days(today(), 10)
partially_ordered.items[0].qty = 10
partially_ordered.insert()
partially_ordered.submit()
partial_order = self.make_order(partially_ordered, 4)
partially_ordered.db_set("valid_till", add_days(today(), -2))
# A past-validity draft must not be expired - "Expired" applies to submitted quotations only # A past-validity draft must not be expired - "Expired" applies to submitted quotations only
draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier) draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
draft.transaction_date = add_days(today(), -10) draft.transaction_date = add_days(today(), -10)
@@ -54,8 +80,24 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(frappe.db.get_value("Supplier Quotation", expired.name, "status"), "Expired") self.assertEqual(frappe.db.get_value("Supplier Quotation", expired.name, "status"), "Expired")
self.assertEqual(frappe.db.get_value("Supplier Quotation", valid.name, "status"), "Submitted") self.assertEqual(frappe.db.get_value("Supplier Quotation", valid.name, "status"), "Submitted")
self.assertEqual(
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
"Partially Ordered",
)
self.assertEqual(frappe.db.get_value("Supplier Quotation", draft.name, "status"), "Draft") self.assertEqual(frappe.db.get_value("Supplier Quotation", draft.name, "status"), "Draft")
partial_order.cancel()
self.assertEqual(
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
"Submitted",
)
set_expired_status()
self.assertEqual(
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
"Expired",
)
def test_submit_and_cancel_updates_rfq_quote_status(self): def test_submit_and_cancel_updates_rfq_quote_status(self):
rfq = make_request_for_quotation() rfq = make_request_for_quotation()
supplier_row = rfq.suppliers[0] supplier_row = rfq.suppliers[0]
@@ -73,6 +115,150 @@ class TestPurchaseOrder(ERPNextTestSuite):
"Pending", "Pending",
) )
def test_purchase_order_updates_order_status(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.submit()
self.assertEqual(supplier_quotation.status, "Submitted")
partial_order = self.make_order(supplier_quotation, 4)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 4)
self.update_order_qty(partial_order, 10)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 10)
self.update_order_qty(partial_order, 4)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
complete_order = self.make_order(supplier_quotation, 6)
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
complete_order.cancel()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
partial_order.cancel()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Submitted")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 0)
def test_purchase_order_maps_remaining_quotation_qty(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.submit()
self.make_order(supplier_quotation, 4)
purchase_order = make_purchase_order(supplier_quotation.name)
self.assertEqual(purchase_order.items[0].qty, 6)
purchase_order.items[0].schedule_date = add_days(today(), 1)
purchase_order.submit()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
self.assertEqual(make_purchase_order(supplier_quotation.name).items, [])
def test_purchase_order_skips_fully_ordered_quotation_rows(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.append("items", {"item_code": "_Test Item 2", "qty": 3, "rate": 100})
supplier_quotation.submit()
first_order = make_purchase_order(
supplier_quotation.name,
args={"filtered_children": [supplier_quotation.items[0].name]},
)
first_order.items[0].schedule_date = add_days(today(), 1)
first_order.submit()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
second_order = make_purchase_order(supplier_quotation.name)
self.assertEqual(len(second_order.items), 1)
self.assertEqual(second_order.items[0].item_code, "_Test Item 2")
self.assertEqual(second_order.items[0].qty, 3)
second_order.items[0].schedule_date = add_days(today(), 1)
second_order.submit()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Ordered")
def test_purchase_order_cannot_exceed_supplier_quotation_qty(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.items[0].qty = 5
supplier_quotation.submit()
first_order = make_purchase_order(supplier_quotation.name)
second_order = make_purchase_order(supplier_quotation.name)
for purchase_order in (first_order, second_order):
purchase_order.items[0].schedule_date = add_days(today(), 1)
first_order.submit()
self.assertRaises(frappe.ValidationError, second_order.submit)
def test_removing_purchase_order_item_updates_quotation_status(self):
first_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
first_quotation.submit()
second_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
second_item = second_quotation.items[0]
second_item.item_code = second_item.item_name = "_Test Item 2"
second_item.stock_uom = second_item.uom = frappe.db.get_value("Item", "_Test Item 2", "stock_uom")
second_item.conversion_factor = 1
second_quotation.submit()
purchase_order = make_purchase_order(first_quotation.name)
purchase_order = make_purchase_order(second_quotation.name, purchase_order)
purchase_order.naming_series = "_T-Purchase Order-"
for item in purchase_order.items:
item.schedule_date = add_days(today(), 1)
purchase_order.insert()
purchase_order.submit()
first_quotation.reload()
second_quotation.reload()
self.assertEqual(first_quotation.status, "Ordered")
self.assertEqual(second_quotation.status, "Ordered")
remaining_item = next(
item for item in purchase_order.items if item.supplier_quotation == second_quotation.name
)
update_child_qty_rate(
"Purchase Order",
json.dumps(
[
{
"item_code": remaining_item.item_code,
"rate": remaining_item.rate,
"qty": remaining_item.qty,
"docname": remaining_item.name,
}
]
),
purchase_order.name,
)
first_quotation.reload()
second_quotation.reload()
self.assertEqual(first_quotation.status, "Submitted")
self.assertEqual(second_quotation.status, "Ordered")
def test_order_status_patch_updates_existing_quotation(self):
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
supplier_quotation.submit()
self.make_order(supplier_quotation, 4)
supplier_quotation.db_set("status", "Submitted")
frappe.db.set_value("Supplier Quotation Item", supplier_quotation.items[0].name, "ordered_qty", 0)
set_order_status()
supplier_quotation.reload()
self.assertEqual(supplier_quotation.status, "Partially Ordered")
self.assertEqual(supplier_quotation.items[0].ordered_qty, 4)
def test_update_child_supplier_quotation_add_item(self): def test_update_child_supplier_quotation_add_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit() sq.submit()

View File

@@ -230,6 +230,17 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"default": "0",
"fieldname": "ordered_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Ordered Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1,
"reqd": 1
},
{ {
"fieldname": "base_price_list_rate", "fieldname": "base_price_list_rate",
"fieldtype": "Currency", "fieldtype": "Currency",
@@ -615,7 +626,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-08-07 17:31:31.732720", "modified": "2026-08-31 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Supplier Quotation Item", "name": "Supplier Quotation Item",

View File

@@ -44,6 +44,7 @@ class SupplierQuotationItem(Document):
material_request_item: DF.Data | None material_request_item: DF.Data | None
net_amount: DF.Currency net_amount: DF.Currency
net_rate: DF.Currency net_rate: DF.Currency
ordered_qty: DF.Float
page_break: DF.Check page_break: DF.Check
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data

View File

@@ -96,6 +96,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
], ],
default: "Submitted", default: "Submitted",
}, },
{
fieldname: "order_status",
label: __("Order Status"),
fieldtype: "Select",
options: [
{ label: "", value: "" },
{ label: __("Not Ordered"), value: "Not Ordered" },
{ label: __("Partially Ordered"), value: "Partially Ordered" },
{ label: __("Ordered"), value: "Ordered" },
],
},
{ {
fieldtype: "Check", fieldtype: "Check",
label: __("Include Expired"), label: __("Include Expired"),

View File

@@ -52,6 +52,7 @@ def get_data(filters):
sq_item.request_for_quotation, sq_item.request_for_quotation,
sq_item.lead_time_days, sq_item.lead_time_days,
sq.supplier.as_("supplier_name"), sq.supplier.as_("supplier_name"),
sq.status.as_("supplier_quotation_status"),
sq.valid_till, sq.valid_till,
) )
.where( .where(
@@ -82,6 +83,11 @@ def get_data(filters):
if filters.get("supplier"): if filters.get("supplier"):
query = query.where(sq.supplier.isin(filters.get("supplier"))) query = query.where(sq.supplier.isin(filters.get("supplier")))
if filters.get("order_status") == "Not Ordered":
query = query.where(sq.status.notin(["Partially Ordered", "Ordered"]))
elif filters.get("order_status"):
query = query.where(sq.status == filters.get("order_status"))
if not filters.get("include_expired"): if not filters.get("include_expired"):
query = query.where(sq.status != "Expired") query = query.where(sq.status != "Expired")
@@ -109,6 +115,7 @@ def prepare_data(supplier_quotation_data, filters):
else data.get("item_code"), # leave blank if group by field else data.get("item_code"), # leave blank if group by field
"supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"), "supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"),
"quotation": data.get("parent"), "quotation": data.get("parent"),
"order_status": get_order_status(data.get("supplier_quotation_status")),
"qty": data.get("qty"), "qty": data.get("qty"),
"price": flt(data.get("amount"), float_precision), "price": flt(data.get("amount"), float_precision),
"uom": data.get("uom"), "uom": data.get("uom"),
@@ -164,6 +171,10 @@ def prepare_data(supplier_quotation_data, filters):
return out, chart_data return out, chart_data
def get_order_status(status):
return status if status in ("Partially Ordered", "Ordered") else "Not Ordered"
def prepare_chart_data(suppliers, qty_list, supplier_qty_price_map): def prepare_chart_data(suppliers, qty_list, supplier_qty_price_map):
data_points_map = {} data_points_map = {}
qty_list.sort() qty_list.sort()
@@ -265,6 +276,12 @@ def get_columns(filters):
"options": "Supplier Quotation", "options": "Supplier Quotation",
"width": 200, "width": 200,
}, },
{
"fieldname": "order_status",
"label": _("Order Status"),
"fieldtype": "Data",
"width": 130,
},
{"fieldname": "valid_till", "label": _("Valid Till"), "fieldtype": "Date", "width": 100}, {"fieldname": "valid_till", "label": _("Valid Till"), "fieldtype": "Date", "width": 100},
{ {
"fieldname": "lead_time_days", "fieldname": "lead_time_days",

View File

@@ -2,7 +2,9 @@
# See license.txt # See license.txt
import frappe import frappe
from frappe.utils import add_days, today
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
@@ -38,6 +40,15 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
filters.update(extra) filters.update(extra)
return execute(filters)[1] return execute(filters)[1]
def make_order(self, supplier_quotation, qty):
purchase_order = make_purchase_order(supplier_quotation.name)
purchase_order.naming_series = "_T-Purchase Order-"
purchase_order.items[0].qty = qty
purchase_order.items[0].schedule_date = add_days(today(), 1)
purchase_order.insert()
purchase_order.submit()
return purchase_order
def test_no_filters_returns_empty(self): def test_no_filters_returns_empty(self):
self.assertEqual(execute(None)[1], []) self.assertEqual(execute(None)[1], [])
@@ -83,3 +94,34 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
both = names(status="") both = names(status="")
self.assertIn(draft.name, both) self.assertIn(draft.name, both)
self.assertIn(submitted.name, both) self.assertIn(submitted.name, both)
def test_order_status_and_filter(self):
supplier_quotation = self.make_quotation("_Test Supplier", qty=10, rate=100)
def get_order_status():
return next(
row["order_status"]
for row in self.run_report(item_code=ITEM)
if row["quotation"] == supplier_quotation.name
)
def quotations_with_status(order_status):
return {row["quotation"] for row in self.run_report(item_code=ITEM, order_status=order_status)}
self.assertEqual(get_order_status(), "Not Ordered")
self.assertIn(supplier_quotation.name, quotations_with_status("Not Ordered"))
partial_order = self.make_order(supplier_quotation, qty=4)
self.assertEqual(get_order_status(), "Partially Ordered")
self.assertIn(supplier_quotation.name, quotations_with_status("Partially Ordered"))
self.assertNotIn(supplier_quotation.name, quotations_with_status("Ordered"))
complete_order = self.make_order(supplier_quotation, qty=6)
self.assertEqual(get_order_status(), "Ordered")
self.assertIn(supplier_quotation.name, quotations_with_status("Ordered"))
complete_order.cancel()
self.assertEqual(get_order_status(), "Partially Ordered")
partial_order.cancel()
self.assertEqual(get_order_status(), "Not Ordered")

View File

@@ -42,6 +42,14 @@ status_map = {
["Ordered", "is_fully_ordered"], ["Ordered", "is_fully_ordered"],
["Cancelled", "eval:self.docstatus==2"], ["Cancelled", "eval:self.docstatus==2"],
], ],
"Supplier Quotation": [
["Draft", None],
["Submitted", "eval:self.docstatus==1"],
["Stopped", "eval:self.status=='Stopped'"],
["Partially Ordered", "is_partially_ordered"],
["Ordered", "is_fully_ordered"],
["Cancelled", "eval:self.docstatus==2"],
],
"Sales Order": [ "Sales Order": [
["Draft", None], ["Draft", None],
[ [
@@ -537,6 +545,7 @@ class StatusUpdater(Document):
if args["source_dt"] != "Pick List Item" and args["target_dt"] not in [ if args["source_dt"] != "Pick List Item" and args["target_dt"] not in [
"Quotation Item", "Quotation Item",
"Supplier Quotation Item",
"Packed Item", "Packed Item",
]: ]:
if args.get("target_dt") == "Material Request Item": if args.get("target_dt") == "Material Request Item":

View File

@@ -520,3 +520,4 @@ erpnext.patches.v16_0.add_batch_split_stock_entry_type
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.set_secondary_item_valuation_type erpnext.patches.v16_0.set_secondary_item_valuation_type
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.set_supplier_quotation_order_status

View File

@@ -0,0 +1,37 @@
import frappe
from frappe.query_builder.functions import Sum
def execute():
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
ordered_items = (
frappe.qb.from_(purchase_order_item)
.select(
purchase_order_item.supplier_quotation_item,
purchase_order_item.supplier_quotation,
Sum(purchase_order_item.stock_qty).as_("ordered_qty"),
)
.where(
(purchase_order_item.docstatus == 1)
& purchase_order_item.supplier_quotation_item.isnotnull()
& (purchase_order_item.supplier_quotation_item != "")
& purchase_order_item.supplier_quotation.isnotnull()
& (purchase_order_item.supplier_quotation != "")
)
.groupby(
purchase_order_item.supplier_quotation_item,
purchase_order_item.supplier_quotation,
)
).run(as_dict=True)
frappe.db.bulk_update(
"Supplier Quotation Item",
{item.supplier_quotation_item: {"ordered_qty": item.ordered_qty} for item in ordered_items},
update_modified=False,
)
for supplier_quotation in {item.supplier_quotation for item in ordered_items}:
frappe.get_doc("Supplier Quotation", supplier_quotation).set_status(
update=True, update_modified=False
)