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:
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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