feat: Unit Price Contract

(cherry picked from commit c1e4e7af28)

# Conflicts:
#	erpnext/controllers/accounts_controller.py
#	erpnext/selling/doctype/quotation/quotation.json
#	erpnext/selling/doctype/sales_order/sales_order.py
#	erpnext/selling/doctype/selling_settings/selling_settings.json
This commit is contained in:
marination
2025-02-28 23:04:29 +01:00
committed by Mergify
parent 8d1e855dc8
commit 33366fce6c
9 changed files with 188 additions and 17 deletions

View File

@@ -1259,8 +1259,17 @@ class AccountsController(TransactionBase):
)
def validate_qty_is_not_zero(self):
<<<<<<< HEAD
if self.doctype == "Purchase Receipt":
return
=======
if self.flags.allow_zero_qty:
return
for item in self.items:
if self.doctype == "Purchase Receipt" and item.rejected_qty:
continue
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
for item in self.items:
if not flt(item.qty):

View File

@@ -35,12 +35,20 @@ frappe.ui.form.on("Quotation", {
},
};
});
frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
});
},
refresh: function (frm) {
frm.trigger("set_label");
frm.trigger("set_dynamic_field_label");
if (frm.doc.docstatus === 0) {
frm.trigger("set_unit_price_items_note");
}
let sbb_field = frm.get_docfield("packed_items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
@@ -64,6 +72,16 @@ frappe.ui.form.on("Quotation", {
set_label: function (frm) {
frm.fields_dict.customer_address.set_label(__(frm.doc.quotation_to + " Address"));
},
set_unit_price_items_note: function (frm) {
if (frm.doc.has_unit_price_items) {
frm.dashboard.set_headline_alert(
__("The Quotation contains Unit Price Items with 0 Qty."),
"yellow",
true
);
}
},
});
erpnext.selling.QuotationController = class QuotationController extends erpnext.selling.SellingController {

View File

@@ -21,6 +21,7 @@
"column_break1",
"order_type",
"company",
"has_unit_price_items",
"amended_from",
"currency_and_price_list",
"currency",
@@ -1084,13 +1085,24 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items"
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
<<<<<<< HEAD
"modified": "2024-11-26 12:43:29.293637",
=======
"modified": "2025-02-28 18:52:44.063265",
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",
@@ -1181,6 +1193,7 @@
"role": "Maintenance User"
}
],
"row_format": "Dynamic",
"search_fields": "status,transaction_date,party_name,order_type",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -19,8 +19,6 @@ class Quotation(SellingController):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule
from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail
from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import (
@@ -32,6 +30,7 @@ class Quotation(SellingController):
QuotationLostReasonDetail,
)
from erpnext.stock.doctype.packed_item.packed_item import PackedItem
from frappe.types import DF
additional_discount_percentage: DF.Float
address_display: DF.SmallText | None
@@ -67,6 +66,7 @@ class Quotation(SellingController):
enq_det: DF.Text | None
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -126,6 +126,10 @@ class Quotation(SellingController):
self.indicator_color = "gray"
self.indicator_title = "Expired"
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
self.set_status()
@@ -157,6 +161,17 @@ class Quotation(SellingController):
if not row.is_alternative and row.name in items_with_alternatives:
row.has_alternative_item = 1
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the SO has unit price items.
"""
if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_quotation"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def get_ordered_status(self):
status = "Open"
ordered_items = frappe._dict(
@@ -411,11 +426,14 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If selections: Simple row: Map if adequate qty
"""
# has_unit_price_items = 0 is accepted as the qty uncertain for some items
has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items")
balance_qty = item.qty - ordered_items.get(item.item_code, 0.0)
if balance_qty <= 0:
if balance_qty <= 0 and not has_unit_price_items:
return False
has_qty = balance_qty
has_qty = balance_qty or has_unit_price_items
if not selected_rows:
return not item.is_alternative

View File

@@ -23,7 +23,16 @@ frappe.ui.form.on("Sales Order", {
// formatter for material request item
frm.set_indicator_formatter("item_code", function (doc) {
return doc.stock_qty <= doc.delivered_qty ? "green" : "orange";
let color;
if (!doc.qty && frm.doc.has_unit_price_items) {
color = "yellow";
} else if (doc.stock_qty <= doc.delivered_qty) {
color = "green";
} else {
color = "orange";
}
return color;
});
frm.set_query("bom_no", "items", function (doc, cdt, cdn) {
@@ -97,6 +106,8 @@ frappe.ui.form.on("Sales Order", {
}
if (frm.doc.docstatus === 0) {
frm.trigger("set_unit_price_items_note");
if (frm.doc.is_internal_customer) {
frm.events.get_items_from_internal_purchase_order(frm);
}
@@ -528,6 +539,16 @@ frappe.ui.form.on("Sales Order", {
};
frappe.set_route("query-report", "Reserved Stock");
},
set_unit_price_items_note: function (frm) {
if (frm.doc.has_unit_price_items && !frm.is_new()) {
frm.dashboard.set_headline_alert(
__("The Sales Order contains Unit Price Items with 0 Qty."),
"yellow",
true
);
}
},
});
frappe.ui.form.on("Sales Order Item", {

View File

@@ -25,6 +25,7 @@
"po_date",
"company",
"skip_delivery_note",
"has_unit_price_items",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -1649,13 +1650,20 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items"
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2025-02-06 16:02:20.320877",
"modified": "2025-02-28 18:52:01.932669",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1724,6 +1732,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "status,transaction_date,customer,customer_name, territory,order_type,company",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -52,16 +52,13 @@ class SalesOrder(SellingController):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule
from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail
from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import (
SalesTaxesandCharges,
)
from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import SalesTaxesandCharges
from erpnext.selling.doctype.sales_order_item.sales_order_item import SalesOrderItem
from erpnext.selling.doctype.sales_team.sales_team import SalesTeam
from erpnext.stock.doctype.packed_item.packed_item import PackedItem
from frappe.types import DF
additional_discount_percentage: DF.Float
address_display: DF.SmallText | None
@@ -100,9 +97,7 @@ class SalesOrder(SellingController):
customer_group: DF.Link | None
customer_name: DF.Data | None
delivery_date: DF.Date | None
delivery_status: DF.Literal[
"Not Delivered", "Fully Delivered", "Partly Delivered", "Closed", "Not Applicable"
]
delivery_status: DF.Literal["Not Delivered", "Fully Delivered", "Partly Delivered", "Closed", "Not Applicable"]
disable_rounded_total: DF.Check
discount_amount: DF.Currency
dispatch_address: DF.SmallText | None
@@ -110,6 +105,7 @@ class SalesOrder(SellingController):
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -152,6 +148,7 @@ class SalesOrder(SellingController):
shipping_address_name: DF.Link | None
shipping_rule: DF.Link | None
skip_delivery_note: DF.Check
<<<<<<< HEAD
source: DF.Link | None
status: DF.Literal[
"",
@@ -165,6 +162,9 @@ class SalesOrder(SellingController):
"Cancelled",
"Closed",
]
=======
status: DF.Literal["", "Draft", "On Hold", "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", "Completed", "Cancelled", "Closed"]
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
tax_category: DF.Link | None
tax_id: DF.Data | None
taxes: DF.Table[SalesTaxesandCharges]
@@ -195,6 +195,10 @@ class SalesOrder(SellingController):
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
self.validate_delivery_date()
@@ -231,6 +235,17 @@ class SalesOrder(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the SO has unit price items.
"""
if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_po(self):
# validate p.o date v/s delivery date
if self.po_date and not self.skip_delivery_note:
@@ -1093,7 +1108,13 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
target.debit_to = get_party_account("Customer", source.customer, source.company)
def update_item(source, target, source_parent):
target.amount = flt(source.amount) - flt(source.billed_amt)
if source_parent.has_unit_price_items:
# 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
pending_amount = flt(source.amount) - flt(source.billed_amt)
target.amount = pending_amount if flt(source.amount) else 0
else:
target.amount = flt(source.amount) - flt(source.billed_amt)
target.base_amount = target.amount * flt(source_parent.conversion_rate)
target.qty = (
target.amount / flt(source.rate)
@@ -1111,6 +1132,10 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
if cost_center:
target.cost_center = cost_center
# has_unit_price_items = 0 is accepted as the qty uncertain for some items
has_unit_price_items = frappe.db.get_value(
"Sales Order", source_name, "has_unit_price_items"
)
doclist = get_mapped_doc(
"Sales Order",
source_name,
@@ -1131,8 +1156,13 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
"parent": "sales_order",
},
"postprocess": update_item,
"condition": lambda doc: doc.qty
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)),
"condition": lambda doc: (
doc.qty
and (
doc.base_amount == 0
or abs(doc.billed_amt) < abs(doc.amount)
)
) or has_unit_price_items,
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",

View File

@@ -32,7 +32,16 @@
"allow_sales_order_creation_for_expired_quotation",
"dont_reserve_sales_order_qty_on_sales_return",
"hide_tax_id",
<<<<<<< HEAD
"enable_discount_accounting"
=======
"enable_discount_accounting",
"enable_cutoff_date_on_bulk_delivery_note_creation",
"allow_zero_qty_in_sales_order",
"allow_zero_qty_in_quotation",
"experimental_section",
"use_server_side_reactivity"
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
],
"fields": [
{
@@ -200,14 +209,51 @@
"fieldname": "blanket_order_allowance",
"fieldtype": "Float",
"label": "Blanket Order Allowance (%)"
<<<<<<< HEAD
=======
},
{
"default": "0",
"fieldname": "enable_cutoff_date_on_bulk_delivery_note_creation",
"fieldtype": "Check",
"label": "Enable Cut-Off Date on Bulk Delivery Note Creation"
},
{
"fieldname": "experimental_section",
"fieldtype": "Section Break",
"label": "Experimental"
},
{
"default": "1",
"fieldname": "use_server_side_reactivity",
"fieldtype": "Check",
"label": "Use Server Side Reactivity"
},
{
"default": "0",
"fieldname": "allow_zero_qty_in_sales_order",
"fieldtype": "Check",
"label": "Allow 0 Qty in Sales Order (Unit Price Contract)"
},
{
"default": "0",
"fieldname": "allow_zero_qty_in_quotation",
"fieldtype": "Check",
"label": "Allow 0 Qty in Quotation (Unit Price Contract)"
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
}
],
"grid_page_length": 50,
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
<<<<<<< HEAD
"modified": "2023-10-25 14:03:03.966701",
=======
"modified": "2025-02-28 18:19:46.436595",
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",
@@ -232,7 +278,12 @@
"write": 1
}
],
<<<<<<< HEAD
"sort_field": "modified",
=======
"row_format": "Dynamic",
"sort_field": "creation",
>>>>>>> c1e4e7af28 (feat: Unit Price Contract)
"sort_order": "DESC",
"states": [],
"track_changes": 1

View File

@@ -23,6 +23,8 @@ class SellingSettings(Document):
allow_multiple_items: DF.Check
allow_negative_rates_for_items: DF.Check
allow_sales_order_creation_for_expired_quotation: DF.Check
allow_zero_qty_in_quotation: DF.Check
allow_zero_qty_in_sales_order: DF.Check
blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None