mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-01 23:53:21 +00:00
feat(buying): track supplier quotation order status (#58572)
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
37
erpnext/patches/v16_0/set_supplier_quotation_order_status.py
Normal file
37
erpnext/patches/v16_0/set_supplier_quotation_order_status.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user