mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-13 09:10:36 +00:00
feat: option to skip delivery note for service items in sales order (#58297)
* feat: option to skip delivery note for service items in sales order * fix: reset stale skip delivery flags when setting is disabled * fix: clear stale skip delivery note flag for non-sales order types * fix: reset auto skip delivery flags on switch to maintenance order * refactor: replace sales order skip_delivery_note with item level skip_delivery * chore: drop skip delivery migration patch * fix: honor legacy skip_delivery_note flag instead of data migration
This commit is contained in:
@@ -614,6 +614,7 @@ class SalesInvoice(SellingController):
|
|||||||
"percent_join_field": "sales_order",
|
"percent_join_field": "sales_order",
|
||||||
"status_field": "delivery_status",
|
"status_field": "delivery_status",
|
||||||
"keyword": "Delivered",
|
"keyword": "Delivered",
|
||||||
|
"exclude_field": "skip_delivery",
|
||||||
"second_source_dt": "Delivery Note Item",
|
"second_source_dt": "Delivery Note Item",
|
||||||
"second_source_field": "qty",
|
"second_source_field": "qty",
|
||||||
"second_join_field": "so_detail",
|
"second_join_field": "so_detail",
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class ChildItemUpdater:
|
|||||||
if parent.is_against_so():
|
if parent.is_against_so():
|
||||||
parent.update_status_updater()
|
parent.update_status_updater()
|
||||||
elif self.parent_doctype == "Sales Order":
|
elif self.parent_doctype == "Sales Order":
|
||||||
|
parent.set_skip_delivery()
|
||||||
parent.check_credit_limit()
|
parent.check_credit_limit()
|
||||||
|
|
||||||
for idx, row in enumerate(parent.get(self.child_docname), start=1):
|
for idx, row in enumerate(parent.get(self.child_docname), start=1):
|
||||||
|
|||||||
@@ -605,14 +605,21 @@ class StatusUpdater(Document):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _calculate_target_parent_percentage(
|
def _calculate_target_parent_percentage(
|
||||||
name, target_parent_dt, target_dt, target_ref_field, target_field
|
name, target_parent_dt, target_dt, target_ref_field, target_field, exclude_field=None
|
||||||
):
|
):
|
||||||
|
filters = {"parent": name, "parenttype": target_parent_dt}
|
||||||
|
if exclude_field:
|
||||||
|
filters[exclude_field] = 0
|
||||||
|
|
||||||
child_records = frappe.get_all(
|
child_records = frappe.get_all(
|
||||||
target_dt,
|
target_dt,
|
||||||
filters={"parent": name, "parenttype": target_parent_dt},
|
filters=filters,
|
||||||
fields=[target_ref_field, target_field],
|
fields=[target_ref_field, target_field],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if exclude_field and not child_records:
|
||||||
|
return 100
|
||||||
|
|
||||||
# For operator dicts, the alias is in the "as" key; for strings, use the field name directly
|
# For operator dicts, the alias is in the "as" key; for strings, use the field name directly
|
||||||
ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field
|
ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field
|
||||||
|
|
||||||
@@ -671,6 +678,7 @@ class StatusUpdater(Document):
|
|||||||
args["target_dt"],
|
args["target_dt"],
|
||||||
args["target_ref_field"],
|
args["target_ref_field"],
|
||||||
args["target_field"],
|
args["target_field"],
|
||||||
|
args.get("exclude_field"),
|
||||||
)
|
)
|
||||||
# update field
|
# update field
|
||||||
if args.get("status_field"):
|
if args.get("status_field"):
|
||||||
|
|||||||
@@ -524,7 +524,8 @@ class WorkOrder(Document):
|
|||||||
.on(ProductBundleItem.parent == SalesOrderItem.item_code)
|
.on(ProductBundleItem.parent == SalesOrderItem.item_code)
|
||||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||||
.where(
|
.where(
|
||||||
(SalesOrder.skip_delivery_note == 0)
|
(SalesOrderItem.skip_delivery == 0)
|
||||||
|
& (SalesOrder.skip_delivery_note == 0)
|
||||||
& (SalesOrder.docstatus == 1)
|
& (SalesOrder.docstatus == 1)
|
||||||
& (SalesOrder.name == self.sales_order)
|
& (SalesOrder.name == self.sales_order)
|
||||||
& (
|
& (
|
||||||
@@ -545,6 +546,7 @@ class WorkOrder(Document):
|
|||||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||||
.where(
|
.where(
|
||||||
(SalesOrder.name == self.sales_order)
|
(SalesOrder.name == self.sales_order)
|
||||||
|
& (SalesOrderItem.skip_delivery == 0)
|
||||||
& (SalesOrder.skip_delivery_note == 0)
|
& (SalesOrder.skip_delivery_note == 0)
|
||||||
& (SalesOrderItem.item_code == PackedItem.parent_item)
|
& (SalesOrderItem.item_code == PackedItem.parent_item)
|
||||||
& (SalesOrder.docstatus == 1)
|
& (SalesOrder.docstatus == 1)
|
||||||
|
|||||||
@@ -307,8 +307,10 @@ def make_delivery_note(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
|
((abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc))
|
||||||
) and doc.delivered_by_supplier != 1
|
and doc.delivered_by_supplier != 1
|
||||||
|
and not cint(doc.skip_delivery)
|
||||||
|
)
|
||||||
|
|
||||||
def update_item(source, target, source_parent):
|
def update_item(source, target, source_parent):
|
||||||
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
|
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
|
||||||
|
|||||||
@@ -1020,11 +1020,14 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
|||||||
if (doc.status !== "Closed") {
|
if (doc.status !== "Closed") {
|
||||||
if (doc.status !== "On Hold") {
|
if (doc.status !== "On Hold") {
|
||||||
const items_are_deliverable = this.frm.doc.items.some(
|
const items_are_deliverable = this.frm.doc.items.some(
|
||||||
(item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty)
|
(item) =>
|
||||||
|
!item.skip_delivery &&
|
||||||
|
item.delivered_by_supplier === 0 &&
|
||||||
|
item.qty > flt(item.delivered_qty)
|
||||||
);
|
);
|
||||||
allow_delivery =
|
allow_delivery =
|
||||||
(this.frm.doc.has_unit_price_items || items_are_deliverable) &&
|
!this.frm.doc.skip_delivery_note &&
|
||||||
!this.frm.doc.skip_delivery_note;
|
(this.frm.doc.has_unit_price_items || items_are_deliverable);
|
||||||
|
|
||||||
if (this.frm.has_perm("submit")) {
|
if (this.frm.has_perm("submit")) {
|
||||||
if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) {
|
if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) {
|
||||||
@@ -1426,14 +1429,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
skip_delivery_note() {
|
|
||||||
this.toggle_delivery_date();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle_delivery_date() {
|
toggle_delivery_date() {
|
||||||
|
const items = this.frm.doc.items || [];
|
||||||
|
const all_skipped = items.length && items.every((item) => item.skip_delivery);
|
||||||
this.frm.fields_dict.items.grid.toggle_reqd(
|
this.frm.fields_dict.items.grid.toggle_reqd(
|
||||||
"delivery_date",
|
"delivery_date",
|
||||||
this.frm.doc.order_type == "Sales" && !this.frm.doc.skip_delivery_note
|
this.frm.doc.order_type == "Sales" && !all_skipped
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"allow_on_submit": 1,
|
"allow_on_submit": 1,
|
||||||
"depends_on": "eval:!doc.skip_delivery_note",
|
|
||||||
"fieldname": "delivery_date",
|
"fieldname": "delivery_date",
|
||||||
"fieldtype": "Date",
|
"fieldtype": "Date",
|
||||||
"hide_days": 1,
|
"hide_days": 1,
|
||||||
@@ -1478,16 +1477,6 @@
|
|||||||
"options": "Phone",
|
"options": "Phone",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default": "0",
|
|
||||||
"depends_on": "eval:doc.order_type == 'Maintenance';",
|
|
||||||
"fieldname": "skip_delivery_note",
|
|
||||||
"fieldtype": "Check",
|
|
||||||
"hide_days": 1,
|
|
||||||
"hide_seconds": 1,
|
|
||||||
"label": "Skip Delivery Note",
|
|
||||||
"print_hide": 1
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fetch_from": "customer.is_internal_customer",
|
"fetch_from": "customer.is_internal_customer",
|
||||||
@@ -1708,6 +1697,16 @@
|
|||||||
"options": "Contact",
|
"options": "Contact",
|
||||||
"print_hide": 1
|
"print_hide": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "skip_delivery_note",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"hidden": 1,
|
||||||
|
"label": "Skip Delivery Note",
|
||||||
|
"no_copy": 1,
|
||||||
|
"print_hide": 1,
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "has_unit_price_items",
|
"fieldname": "has_unit_price_items",
|
||||||
@@ -1826,7 +1825,7 @@
|
|||||||
"idx": 105,
|
"idx": 105,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-08-21 23:11:48.053347",
|
"modified": "2026-08-26 12:00:00.000000",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Sales Order",
|
"name": "Sales Order",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class SalesOrder(SellingController):
|
|||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
super().validate()
|
super().validate()
|
||||||
|
self.set_skip_delivery()
|
||||||
self.validate_delivery_date()
|
self.validate_delivery_date()
|
||||||
self.validate_proj_cust()
|
self.validate_proj_cust()
|
||||||
self.validate_po()
|
self.validate_po()
|
||||||
@@ -268,7 +269,7 @@ class SalesOrder(SellingController):
|
|||||||
|
|
||||||
def validate_po(self):
|
def validate_po(self):
|
||||||
# validate p.o date v/s delivery date
|
# validate p.o date v/s delivery date
|
||||||
if self.po_date and not self.skip_delivery_note:
|
if self.po_date and not self.delivery_not_required():
|
||||||
for d in self.get("items"):
|
for d in self.get("items"):
|
||||||
if d.delivery_date and getdate(self.po_date) > getdate(d.delivery_date):
|
if d.delivery_date and getdate(self.po_date) > getdate(d.delivery_date):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
@@ -277,7 +278,7 @@ class SalesOrder(SellingController):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.po_no and self.customer and not self.skip_delivery_note:
|
if self.po_no and self.customer and not self.delivery_not_required():
|
||||||
so = frappe.db.get_value(
|
so = frappe.db.get_value(
|
||||||
"Sales Order",
|
"Sales Order",
|
||||||
filters={
|
filters={
|
||||||
@@ -347,6 +348,44 @@ class SalesOrder(SellingController):
|
|||||||
|
|
||||||
return frappe.db.exists("Item", {"name": ["in", bundle_items], "is_stock_item": 1}) is not None
|
return frappe.db.exists("Item", {"name": ["in", bundle_items], "is_stock_item": 1}) is not None
|
||||||
|
|
||||||
|
def set_skip_delivery(self):
|
||||||
|
enabled = cint(frappe.get_single_value("Selling Settings", "skip_delivery_note_for_service_items"))
|
||||||
|
for d in self.get("items"):
|
||||||
|
d.skip_delivery = cint(
|
||||||
|
bool(enabled) and not cint(d.delivered_by_supplier) and not self.requires_delivery(d)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.set_delivery_progress()
|
||||||
|
|
||||||
|
def delivery_not_required(self):
|
||||||
|
if cint(self.get("skip_delivery_note")):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return bool(self.get("items")) and all(cint(d.skip_delivery) for d in self.get("items"))
|
||||||
|
|
||||||
|
def set_delivery_progress(self):
|
||||||
|
if self.delivery_not_required():
|
||||||
|
self.per_delivered = 100
|
||||||
|
self.delivery_status = "Not Applicable"
|
||||||
|
return
|
||||||
|
|
||||||
|
deliverable = [d for d in self.get("items") if not cint(d.skip_delivery)]
|
||||||
|
total_qty = sum(abs(flt(d.qty)) for d in deliverable)
|
||||||
|
delivered_qty = sum(min(abs(flt(d.delivered_qty)), abs(flt(d.qty))) for d in deliverable)
|
||||||
|
self.per_delivered = round(delivered_qty / total_qty * 100, 6) if total_qty else 0
|
||||||
|
|
||||||
|
if self.delivery_status == "Not Applicable":
|
||||||
|
self.delivery_status = self._determine_status(self.per_delivered, "Delivered")
|
||||||
|
|
||||||
|
def requires_delivery(self, row):
|
||||||
|
is_stock_item, is_fixed_asset = frappe.get_cached_value(
|
||||||
|
"Item", row.item_code, ["is_stock_item", "is_fixed_asset"]
|
||||||
|
)
|
||||||
|
if is_stock_item or is_fixed_asset:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return self.has_product_bundle(row.item_code) and self.product_bundle_has_stock_item(row.item_code)
|
||||||
|
|
||||||
def validate_sales_mntc_quotation(self):
|
def validate_sales_mntc_quotation(self):
|
||||||
quotation_names = [d.prevdoc_docname for d in self.get("items") if d.prevdoc_docname]
|
quotation_names = [d.prevdoc_docname for d in self.get("items") if d.prevdoc_docname]
|
||||||
|
|
||||||
@@ -364,7 +403,7 @@ class SalesOrder(SellingController):
|
|||||||
frappe.msgprint(_("Quotation {0} not of type {1}").format(d.prevdoc_docname, self.order_type))
|
frappe.msgprint(_("Quotation {0} not of type {1}").format(d.prevdoc_docname, self.order_type))
|
||||||
|
|
||||||
def validate_delivery_date(self):
|
def validate_delivery_date(self):
|
||||||
if self.order_type == "Sales" and not self.skip_delivery_note:
|
if self.order_type == "Sales" and not self.delivery_not_required():
|
||||||
delivery_date_list = [d.delivery_date for d in self.get("items") if d.delivery_date]
|
delivery_date_list = [d.delivery_date for d in self.get("items") if d.delivery_date]
|
||||||
max_delivery_date = max(delivery_date_list) if delivery_date_list else None
|
max_delivery_date = max(delivery_date_list) if delivery_date_list else None
|
||||||
if (max_delivery_date and not self.delivery_date) or (
|
if (max_delivery_date and not self.delivery_date) or (
|
||||||
@@ -762,6 +801,7 @@ def get_events(start: str, end: str, filters: str | dict | None = None):
|
|||||||
SalesOrderItem.delivery_date,
|
SalesOrderItem.delivery_date,
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
.where(SalesOrderItem.skip_delivery == 0)
|
||||||
.where(SalesOrder.skip_delivery_note == 0)
|
.where(SalesOrder.skip_delivery_note == 0)
|
||||||
.where(SalesOrder.docstatus < 2)
|
.where(SalesOrder.docstatus < 2)
|
||||||
.where(SalesOrderItem.delivery_date.between(start, end))
|
.where(SalesOrderItem.delivery_date.between(start, end))
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ frappe.listview_settings["Sales Order"] = {
|
|||||||
"status",
|
"status",
|
||||||
"advance_payment_status",
|
"advance_payment_status",
|
||||||
"order_type",
|
"order_type",
|
||||||
"name",
|
|
||||||
"skip_delivery_note",
|
"skip_delivery_note",
|
||||||
|
"name",
|
||||||
],
|
],
|
||||||
get_indicator: function (doc) {
|
get_indicator: function (doc) {
|
||||||
if (doc.status === "Closed") {
|
if (doc.status === "Closed") {
|
||||||
@@ -23,7 +23,7 @@ frappe.listview_settings["Sales Order"] = {
|
|||||||
return [__("Completed"), "green", "status,=,Completed"];
|
return [__("Completed"), "green", "status,=,Completed"];
|
||||||
} else if (doc.advance_payment_status === "Requested") {
|
} else if (doc.advance_payment_status === "Requested") {
|
||||||
return [__("To Pay"), "gray", "advance_payment_status,=,Requested"];
|
return [__("To Pay"), "gray", "advance_payment_status,=,Requested"];
|
||||||
} else if (!doc.skip_delivery_note && flt(doc.per_delivered) < 100) {
|
} else if (flt(doc.per_delivered) < 100 && !doc.skip_delivery_note) {
|
||||||
if (frappe.datetime.get_diff(doc.delivery_date) < 0) {
|
if (frappe.datetime.get_diff(doc.delivery_date) < 0) {
|
||||||
// not delivered & overdue
|
// not delivered & overdue
|
||||||
return [
|
return [
|
||||||
@@ -50,14 +50,12 @@ frappe.listview_settings["Sales Order"] = {
|
|||||||
return [__("To Deliver"), "orange", "per_delivered,<,100|per_billed,=,100|status,!=,Closed"];
|
return [__("To Deliver"), "orange", "per_delivered,<,100|per_billed,=,100|status,!=,Closed"];
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
flt(doc.per_delivered) === 100 &&
|
(flt(doc.per_delivered) === 100 || doc.skip_delivery_note) &&
|
||||||
flt(doc.grand_total) !== 0 &&
|
flt(doc.grand_total) !== 0 &&
|
||||||
flt(doc.per_billed) < 100
|
flt(doc.per_billed) < 100
|
||||||
) {
|
) {
|
||||||
// to bill
|
// to bill
|
||||||
return [__("To Bill"), "orange", "per_delivered,=,100|per_billed,<,100|status,!=,Closed"];
|
return [__("To Bill"), "orange", "per_delivered,=,100|per_billed,<,100|status,!=,Closed"];
|
||||||
} else if (doc.skip_delivery_note && flt(doc.per_billed) < 100) {
|
|
||||||
return [__("To Bill"), "orange", "per_billed,<,100|status,!=,Closed"];
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onload: function (listview) {
|
onload: function (listview) {
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ class StatusService:
|
|||||||
tot_qty, delivered_qty = 0.0, 0.0
|
tot_qty, delivered_qty = 0.0, 0.0
|
||||||
|
|
||||||
for item in doc.items:
|
for item in doc.items:
|
||||||
|
if item.skip_delivery:
|
||||||
|
continue
|
||||||
|
|
||||||
if item.delivered_by_supplier:
|
if item.delivered_by_supplier:
|
||||||
item_delivered_qty = frappe.get_all(
|
item_delivered_qty = frappe.get_all(
|
||||||
"Purchase Order Item",
|
"Purchase Order Item",
|
||||||
|
|||||||
@@ -107,28 +107,119 @@ class TestSalesOrder(ERPNextTestSuite):
|
|||||||
mr.reload()
|
mr.reload()
|
||||||
self.assertRaises(frappe.ValidationError, make_material_request, so.name)
|
self.assertRaises(frappe.ValidationError, make_material_request, so.name)
|
||||||
|
|
||||||
def test_sales_order_skip_delivery_note(self):
|
@ERPNextTestSuite.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 1})
|
||||||
so = make_sales_order(do_not_submit=True)
|
def test_maintenance_order_completes_with_service_items(self):
|
||||||
|
service_item = make_item("_Test Service Item For Skip DN", {"is_stock_item": 0}).name
|
||||||
|
so = make_sales_order(item_code=service_item, qty=2, rate=100, do_not_submit=True)
|
||||||
so.order_type = "Maintenance"
|
so.order_type = "Maintenance"
|
||||||
so.skip_delivery_note = 1
|
|
||||||
so.append(
|
|
||||||
"items",
|
|
||||||
{
|
|
||||||
"item_code": "_Test Item 2",
|
|
||||||
"qty": 2,
|
|
||||||
"rate": 100,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
so.save()
|
so.save()
|
||||||
so.submit()
|
so.submit()
|
||||||
|
|
||||||
so.reload()
|
self.assertEqual(so.items[0].skip_delivery, 1)
|
||||||
|
self.assertEqual(flt(so.per_delivered), 100)
|
||||||
|
self.assertEqual(so.delivery_status, "Not Applicable")
|
||||||
|
|
||||||
si = make_sales_invoice(so.name)
|
si = make_sales_invoice(so.name)
|
||||||
si.insert()
|
si.insert()
|
||||||
si.submit()
|
si.submit()
|
||||||
|
|
||||||
so.reload()
|
so.reload()
|
||||||
self.assertEqual(so.status, "Completed")
|
self.assertEqual(so.status, "Completed")
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 1})
|
||||||
|
def test_auto_skip_delivery_note_for_service_items(self):
|
||||||
|
service_item = make_item("_Test Service Item For Skip DN", {"is_stock_item": 0}).name
|
||||||
|
so = make_sales_order(item_code=service_item, qty=2, rate=100)
|
||||||
|
so.reload()
|
||||||
|
|
||||||
|
self.assertEqual(so.items[0].skip_delivery, 1)
|
||||||
|
self.assertEqual(flt(so.per_delivered), 100)
|
||||||
|
self.assertEqual(so.delivery_status, "Not Applicable")
|
||||||
|
self.assertEqual(so.status, "To Bill")
|
||||||
|
|
||||||
|
si = make_sales_invoice(so.name)
|
||||||
|
si.insert()
|
||||||
|
si.submit()
|
||||||
|
|
||||||
|
so.reload()
|
||||||
|
self.assertEqual(so.status, "Completed")
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 1})
|
||||||
|
def test_mixed_sales_order_with_service_items(self):
|
||||||
|
service_item = make_item("_Test Service Item For Skip DN", {"is_stock_item": 0}).name
|
||||||
|
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, rate=100)
|
||||||
|
|
||||||
|
so = make_sales_order(
|
||||||
|
item_list=[
|
||||||
|
{
|
||||||
|
"item_code": "_Test Item",
|
||||||
|
"qty": 2,
|
||||||
|
"rate": 100,
|
||||||
|
"warehouse": "_Test Warehouse - _TC",
|
||||||
|
},
|
||||||
|
{"item_code": service_item, "qty": 1, "rate": 50},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(so.items[0].skip_delivery, 0)
|
||||||
|
self.assertEqual(so.items[1].skip_delivery, 1)
|
||||||
|
|
||||||
|
dn = make_delivery_note(so.name)
|
||||||
|
self.assertEqual(len(dn.items), 1)
|
||||||
|
self.assertEqual(dn.items[0].item_code, "_Test Item")
|
||||||
|
dn.insert()
|
||||||
|
dn.submit()
|
||||||
|
|
||||||
|
so.reload()
|
||||||
|
self.assertEqual(flt(so.per_delivered), 100)
|
||||||
|
|
||||||
|
si = make_sales_invoice(so.name)
|
||||||
|
si.insert()
|
||||||
|
si.submit()
|
||||||
|
|
||||||
|
so.reload()
|
||||||
|
self.assertEqual(so.status, "Completed")
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 1})
|
||||||
|
def test_no_skip_delivery_for_bundle_with_stock_items(self):
|
||||||
|
make_item("_Test Bundle Parent For Skip DN", {"is_stock_item": 0})
|
||||||
|
make_item("_Test Bundle Child For Skip DN", {"is_stock_item": 1})
|
||||||
|
make_product_bundle("_Test Bundle Parent For Skip DN", ["_Test Bundle Child For Skip DN"], 1)
|
||||||
|
|
||||||
|
so = make_sales_order(item_code="_Test Bundle Parent For Skip DN", qty=1, rate=100)
|
||||||
|
|
||||||
|
self.assertEqual(so.items[0].skip_delivery, 0)
|
||||||
|
|
||||||
|
def test_service_item_needs_delivery_when_setting_disabled(self):
|
||||||
|
service_item = make_item("_Test Service Item For Skip DN", {"is_stock_item": 0}).name
|
||||||
|
so = make_sales_order(item_code=service_item, qty=1, rate=100)
|
||||||
|
|
||||||
|
self.assertEqual(so.items[0].skip_delivery, 0)
|
||||||
|
self.assertEqual(flt(so.per_delivered), 0)
|
||||||
|
|
||||||
|
si = make_sales_invoice(so.name)
|
||||||
|
si.insert()
|
||||||
|
si.submit()
|
||||||
|
|
||||||
|
so.reload()
|
||||||
|
self.assertEqual(so.status, "To Deliver")
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 1})
|
||||||
|
def test_stale_skip_delivery_cleared_after_setting_disabled(self):
|
||||||
|
service_item = make_item("_Test Service Item For Skip DN", {"is_stock_item": 0}).name
|
||||||
|
so = make_sales_order(item_code=service_item, qty=1, rate=100, do_not_submit=True)
|
||||||
|
|
||||||
|
self.assertEqual(so.items[0].skip_delivery, 1)
|
||||||
|
self.assertEqual(flt(so.per_delivered), 100)
|
||||||
|
self.assertEqual(so.delivery_status, "Not Applicable")
|
||||||
|
|
||||||
|
with self.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 0}):
|
||||||
|
so.save()
|
||||||
|
|
||||||
|
self.assertEqual(so.items[0].skip_delivery, 0)
|
||||||
|
self.assertEqual(flt(so.per_delivered), 0)
|
||||||
|
self.assertEqual(so.delivery_status, "Not Delivered")
|
||||||
|
|
||||||
@ERPNextTestSuite.change_settings(
|
@ERPNextTestSuite.change_settings(
|
||||||
"Selling Settings", {"allow_multiple_items": 1, "allow_negative_rates_for_items": 1}
|
"Selling Settings", {"allow_multiple_items": 1, "allow_negative_rates_for_items": 1}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"gross_profit",
|
"gross_profit",
|
||||||
"drop_ship_section",
|
"drop_ship_section",
|
||||||
"delivered_by_supplier",
|
"delivered_by_supplier",
|
||||||
|
"skip_delivery",
|
||||||
"supplier",
|
"supplier",
|
||||||
"item_weight_details",
|
"item_weight_details",
|
||||||
"weight_per_unit",
|
"weight_per_unit",
|
||||||
@@ -203,7 +204,6 @@
|
|||||||
{
|
{
|
||||||
"allow_on_submit": 1,
|
"allow_on_submit": 1,
|
||||||
"columns": 2,
|
"columns": 2,
|
||||||
"depends_on": "eval: !parent.skip_delivery_note",
|
|
||||||
"fieldname": "delivery_date",
|
"fieldname": "delivery_date",
|
||||||
"fieldtype": "Date",
|
"fieldtype": "Date",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
@@ -507,6 +507,16 @@
|
|||||||
"label": "Supplier delivers to Customer",
|
"label": "Supplier delivers to Customer",
|
||||||
"print_hide": 1
|
"print_hide": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "skip_delivery",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"hidden": 1,
|
||||||
|
"label": "Skip Delivery",
|
||||||
|
"no_copy": 1,
|
||||||
|
"print_hide": 1,
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"allow_on_submit": 1,
|
"allow_on_submit": 1,
|
||||||
"fieldname": "supplier",
|
"fieldname": "supplier",
|
||||||
@@ -1056,7 +1066,7 @@
|
|||||||
"idx": 1,
|
"idx": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-08-07 17:31:31.732720",
|
"modified": "2026-08-25 10:00:00.000000",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Sales Order Item",
|
"name": "Sales Order Item",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class SalesOrderItem(Document):
|
|||||||
requested_qty: DF.Float
|
requested_qty: DF.Float
|
||||||
reserve_stock: DF.Check
|
reserve_stock: DF.Check
|
||||||
returned_qty: DF.Float
|
returned_qty: DF.Float
|
||||||
|
skip_delivery: DF.Check
|
||||||
stock_qty: DF.Float
|
stock_qty: DF.Float
|
||||||
stock_reserved_qty: DF.Float
|
stock_reserved_qty: DF.Float
|
||||||
stock_uom: DF.Link | None
|
stock_uom: DF.Link | None
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"sales_transactions_settings_section",
|
"sales_transactions_settings_section",
|
||||||
"so_required",
|
"so_required",
|
||||||
"dn_required",
|
"dn_required",
|
||||||
|
"skip_delivery_note_for_service_items",
|
||||||
"sales_update_frequency",
|
"sales_update_frequency",
|
||||||
"column_break_5",
|
"column_break_5",
|
||||||
"allow_multiple_items",
|
"allow_multiple_items",
|
||||||
@@ -116,6 +117,13 @@
|
|||||||
"label": "Is Delivery Note required to create Sales Invoice?",
|
"label": "Is Delivery Note required to create Sales Invoice?",
|
||||||
"options": "No\nYes"
|
"options": "No\nYes"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "Non-stock items will not require a Delivery Note. Sales Orders will be marked as Completed once all stock items are delivered and the order is fully billed",
|
||||||
|
"fieldname": "skip_delivery_note_for_service_items",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Skip Delivery Note Creation for Service Items"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default": "Daily",
|
"default": "Daily",
|
||||||
"description": "The frequency at which project progress and company transaction details will be updated. Set it to daily or monthly if you post a lot of transactions.",
|
"description": "The frequency at which project progress and company transaction details will be updated. Set it to daily or monthly if you post a lot of transactions.",
|
||||||
@@ -435,7 +443,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-04-29 11:05:48.836362",
|
"modified": "2026-08-19 11:00:00.000000",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Selling Settings",
|
"name": "Selling Settings",
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class DeliveryNote(SellingController):
|
|||||||
"percent_join_field": "against_sales_order",
|
"percent_join_field": "against_sales_order",
|
||||||
"status_field": "delivery_status",
|
"status_field": "delivery_status",
|
||||||
"keyword": "Delivered",
|
"keyword": "Delivered",
|
||||||
|
"exclude_field": "skip_delivery",
|
||||||
"second_source_dt": "Sales Invoice Item",
|
"second_source_dt": "Sales Invoice Item",
|
||||||
"second_source_field": "qty",
|
"second_source_field": "qty",
|
||||||
"second_join_field": "so_detail",
|
"second_join_field": "so_detail",
|
||||||
|
|||||||
Reference in New Issue
Block a user