diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py index 7d0f7286e83..142a1d591fc 100644 --- a/erpnext/accounts/services/child_item_update.py +++ b/erpnext/accounts/services/child_item_update.py @@ -36,7 +36,9 @@ class ChildItemUpdater: def update(self, trans_items: str | list) -> None: """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 data = frappe.parse_json(trans_items) @@ -52,7 +54,7 @@ class ChildItemUpdater: self.parent, data, self._transacted_stock_qty ) 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( self.parent, data, self._transacted_stock_qty ) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index e8dcea1883e..63c8265469f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -535,7 +535,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( }, get_query_filters: { docstatus: 1, - status: ["not in", ["Stopped", "Expired"]], + status: ["not in", ["Stopped", "Ordered", "Expired"]], }, allow_child_item_selection: true, child_fieldname: "items", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 70382b8bb15..4d1a9f4a06e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -178,7 +178,15 @@ class PurchaseOrder(BuyingController): "global_allowance_field": "over_order_allowance", "global_allowance_doctype": "Buying Settings", "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): @@ -250,6 +258,7 @@ class PurchaseOrder(BuyingController): ["conversion_factor", "="], ], "is_child_table": True, + "allow_duplicate_prev_row_id": True, }, "Material Request": { "ref_dn_field": "material_request", @@ -408,6 +417,19 @@ class PurchaseOrder(BuyingController): def is_item_closable(self, 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): super().on_submit() diff --git a/erpnext/buying/doctype/supplier_quotation/mapper.py b/erpnext/buying/doctype/supplier_quotation/mapper.py index bf76e73e218..5abaee55ce9 100644 --- a/erpnext/buying/doctype/supplier_quotation/mapper.py +++ b/erpnext/buying/doctype/supplier_quotation/mapper.py @@ -18,6 +18,7 @@ def make_purchase_order( if args is None: args = {} args = frappe.parse_json(args) + ordered_items = get_ordered_items(source_name) 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") 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): filtered_items = args.get("filtered_children", []) @@ -55,8 +58,9 @@ def make_purchase_order( ["sales_order", "sales_order"], ], "postprocess": update_item, - # no qty tracking between the two, so dedupe on the row reference alone - "condition": lambda d: d.name not in mapped_items and select_item(d), + "condition": lambda item: item.name not in mapped_items + and (item.stock_qty > ordered_items.get(item.name, 0.0) or item.qty == 0) + and select_item(item), }, "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 + + +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, + ) + ) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index d35be93b209..666822da2b8 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -34,7 +34,10 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e 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)); } - 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( __("Purchase Order"), this.make_purchase_order.bind(this), diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 5a31f1a4484..d8c0e5bca46 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -767,7 +767,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "\nDraft\nSubmitted\nStopped\nCancelled\nExpired", + "options": "\nDraft\nSubmitted\nPartially Ordered\nOrdered\nStopped\nCancelled\nExpired", "print_hide": 1, "read_only": 1, "reqd": 1, @@ -948,7 +948,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-08-21 23:11:50.274992", + "modified": "2026-08-30 16:30:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index ec7d95737bb..f17f94225bd 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -6,10 +6,13 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.utils import getdate, nowdate +from pypika.terms import ExistsCriterion from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController +from .mapper import get_ordered_items + form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -86,7 +89,9 @@ class SupplierQuotation(BuyingController): shipping_address: DF.Link | None shipping_address_display: DF.TextEditor | 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_address: DF.Link | None supplier_name: DF.Data | None @@ -112,13 +117,17 @@ class SupplierQuotation(BuyingController): def validate(self): super().validate() + self.set_status() if not self.status: self.status = "Draft" 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) self.validate_with_previous_doc() @@ -126,11 +135,11 @@ class SupplierQuotation(BuyingController): self.validate_valid_till() def on_submit(self): - self.db_set("status", "Submitted") + self.set_status(update=True) self.update_rfq_supplier_status(1) def on_cancel(self): - self.db_set("status", "Cancelled") + self.set_status(update=True) self.update_rfq_supplier_status(0) def on_trash(self): @@ -166,6 +175,24 @@ class SupplierQuotation(BuyingController): if self.valid_till and getdate(self.valid_till) < getdate(self.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): from frappe.query_builder.functions import Count @@ -243,27 +270,29 @@ def get_list_context(context=None): def set_expired_status(): - # Only submitted quotations past their validity should be expired - frappe.db.set_value( - "Supplier Quotation", - { - "docstatus": 1, - "status": ["not in", ["Cancelled", "Stopped"]], - "valid_till": ["<", nowdate()], - }, - "status", - "Expired", - update_modified=True, - ) + supplier_quotation = frappe.qb.DocType("Supplier Quotation") + purchase_order = frappe.qb.DocType("Purchase Order") + purchase_order_item = frappe.qb.DocType("Purchase Order Item") - -def get_purchased_items(supplier_quotation: str): - return frappe._dict( - frappe.get_all( - "Purchase Order Item", - filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, - fields=["supplier_quotation_item", {"SUM": "stock_qty"}], - group_by="supplier_quotation_item", - as_list=1, + purchase_order_against_quotation = ( + frappe.qb.from_(purchase_order) + .from_(purchase_order_item) + .select(purchase_order.name) + .where( + (purchase_order_item.docstatus == 1) + & (purchase_order.docstatus == 1) + & (purchase_order_item.parent == purchase_order.name) + & (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() diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js index 1a2a514a680..965e7a31a9a 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js @@ -1,10 +1,12 @@ frappe.listview_settings["Supplier Quotation"] = { add_fields: ["supplier", "base_grand_total", "status", "company", "currency"], 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"]; - } else if (doc.status === "Rejected") { - return [__("Lost"), "gray", "status,=,Lost"]; } else if (doc.status === "Expired") { return [__("Expired"), "gray", "status,=,Expired"]; } diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index b973b0d558c..642127b6ba7 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -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.supplier_quotation import set_expired_status 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.utils import ERPNextTestSuite @@ -23,6 +24,23 @@ class TestPurchaseOrder(ERPNextTestSuite): def setUp(self): 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): rfq = make_request_for_quotation() sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier) @@ -44,6 +62,14 @@ class TestPurchaseOrder(ERPNextTestSuite): valid.insert() 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 draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier) 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", 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") + 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): rfq = make_request_for_quotation() supplier_row = rfq.suppliers[0] @@ -73,6 +115,150 @@ class TestPurchaseOrder(ERPNextTestSuite): "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): sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) sq.submit() diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json index b11da04f94c..abe000aae13 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json @@ -230,6 +230,17 @@ "print_hide": 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", "fieldtype": "Currency", @@ -615,7 +626,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-31 12:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation Item", diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py index ac313cbf182..fcced7970c8 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py @@ -44,6 +44,7 @@ class SupplierQuotationItem(Document): material_request_item: DF.Data | None net_amount: DF.Currency net_rate: DF.Currency + ordered_qty: DF.Float page_break: DF.Check parent: DF.Data parentfield: DF.Data diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js index 5073459636e..13f62e2fe1c 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js @@ -96,6 +96,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = { ], 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", label: __("Include Expired"), diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py index c673d45290a..d383913affe 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py @@ -52,6 +52,7 @@ def get_data(filters): sq_item.request_for_quotation, sq_item.lead_time_days, sq.supplier.as_("supplier_name"), + sq.status.as_("supplier_quotation_status"), sq.valid_till, ) .where( @@ -82,6 +83,11 @@ def get_data(filters): if 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"): 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 "supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"), "quotation": data.get("parent"), + "order_status": get_order_status(data.get("supplier_quotation_status")), "qty": data.get("qty"), "price": flt(data.get("amount"), float_precision), "uom": data.get("uom"), @@ -164,6 +171,10 @@ def prepare_data(supplier_quotation_data, filters): 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): data_points_map = {} qty_list.sort() @@ -265,6 +276,12 @@ def get_columns(filters): "options": "Supplier Quotation", "width": 200, }, + { + "fieldname": "order_status", + "label": _("Order Status"), + "fieldtype": "Data", + "width": 130, + }, {"fieldname": "valid_till", "label": _("Valid Till"), "fieldtype": "Date", "width": 100}, { "fieldname": "lead_time_days", diff --git a/erpnext/buying/report/supplier_quotation_comparison/test_supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/test_supplier_quotation_comparison.py index 66a232980c2..17b59fdecf8 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/test_supplier_quotation_comparison.py +++ b/erpnext/buying/report/supplier_quotation_comparison/test_supplier_quotation_comparison.py @@ -2,7 +2,9 @@ # See license.txt 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.tests.utils import ERPNextTestSuite @@ -38,6 +40,15 @@ class TestSupplierQuotationComparison(ERPNextTestSuite): filters.update(extra) 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): self.assertEqual(execute(None)[1], []) @@ -83,3 +94,34 @@ class TestSupplierQuotationComparison(ERPNextTestSuite): both = names(status="") self.assertIn(draft.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") diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index f487c56cc34..ea4336aa163 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -42,6 +42,14 @@ status_map = { ["Ordered", "is_fully_ordered"], ["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": [ ["Draft", None], [ @@ -537,6 +545,7 @@ class StatusUpdater(Document): if args["source_dt"] != "Pick List Item" and args["target_dt"] not in [ "Quotation Item", + "Supplier Quotation Item", "Packed Item", ]: if args.get("target_dt") == "Material Request Item": diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c0c2f910f22..9e994f08745 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -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.set_secondary_item_valuation_type erpnext.patches.v16_0.append_fieldname_to_pos_search_fields +erpnext.patches.v16_0.set_supplier_quotation_order_status diff --git a/erpnext/patches/v16_0/set_supplier_quotation_order_status.py b/erpnext/patches/v16_0/set_supplier_quotation_order_status.py new file mode 100644 index 00000000000..63f97f8b115 --- /dev/null +++ b/erpnext/patches/v16_0/set_supplier_quotation_order_status.py @@ -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 + )