mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-17 08:35:00 +00:00
feat: Unit Price Contract
This commit is contained in:
@@ -1190,6 +1190,9 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
|
||||
def validate_qty_is_not_zero(self):
|
||||
if self.flags.allow_zero_qty:
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
if self.doctype == "Purchase Receipt" and item.rejected_qty:
|
||||
continue
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"column_break1",
|
||||
"order_type",
|
||||
"company",
|
||||
"has_unit_price_items",
|
||||
"amended_from",
|
||||
"currency_and_price_list",
|
||||
"currency",
|
||||
@@ -1089,13 +1090,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-shopping-cart",
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-06 15:00:08.774925",
|
||||
"modified": "2025-02-28 18:52:44.063265",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
@@ -1186,6 +1194,7 @@
|
||||
"role": "Maintenance User"
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "status,transaction_date,party_name,order_type",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "creation",
|
||||
|
||||
@@ -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.TextEditor | None
|
||||
@@ -66,6 +65,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
|
||||
@@ -127,6 +127,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()
|
||||
@@ -158,6 +162,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(
|
||||
@@ -412,11 +427,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -549,6 +560,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", {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"po_date",
|
||||
"company",
|
||||
"skip_delivery_note",
|
||||
"has_unit_price_items",
|
||||
"amended_from",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
@@ -1672,13 +1673,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",
|
||||
@@ -1747,6 +1755,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": "creation",
|
||||
|
||||
@@ -57,16 +57,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.TextEditor | None
|
||||
@@ -104,9 +101,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.TextEditor | None
|
||||
@@ -114,6 +109,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
|
||||
@@ -156,18 +152,7 @@ class SalesOrder(SellingController):
|
||||
shipping_address_name: DF.Link | None
|
||||
shipping_rule: DF.Link | None
|
||||
skip_delivery_note: DF.Check
|
||||
status: DF.Literal[
|
||||
"",
|
||||
"Draft",
|
||||
"On Hold",
|
||||
"To Pay",
|
||||
"To Deliver and Bill",
|
||||
"To Bill",
|
||||
"To Deliver",
|
||||
"Completed",
|
||||
"Cancelled",
|
||||
"Closed",
|
||||
]
|
||||
status: DF.Literal["", "Draft", "On Hold", "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", "Completed", "Cancelled", "Closed"]
|
||||
tax_category: DF.Link | None
|
||||
tax_id: DF.Data | None
|
||||
taxes: DF.Table[SalesTaxesandCharges]
|
||||
@@ -201,6 +186,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()
|
||||
@@ -244,6 +233,17 @@ class SalesOrder(SellingController):
|
||||
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||
self.reserve_stock = 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_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:
|
||||
@@ -1118,7 +1118,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)
|
||||
@@ -1136,6 +1142,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,
|
||||
@@ -1156,8 +1166,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",
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"hide_tax_id",
|
||||
"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"
|
||||
],
|
||||
@@ -220,14 +222,27 @@
|
||||
"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)"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "fa fa-cog",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-06 11:41:54.722337",
|
||||
"modified": "2025-02-28 18:19:46.436595",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Selling Settings",
|
||||
@@ -252,6 +267,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user