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:
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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