From 731f03e2f2553cb7c0c475627bcba2d795ef0f19 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Wed, 26 Aug 2026 16:28:31 +0530 Subject: [PATCH] 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 --- .../doctype/sales_invoice/sales_invoice.py | 1 + .../accounts/services/child_item_update.py | 1 + erpnext/controllers/status_updater.py | 12 +- .../doctype/work_order/work_order.py | 4 +- erpnext/selling/doctype/sales_order/mapper.py | 6 +- .../doctype/sales_order/sales_order.js | 17 +-- .../doctype/sales_order/sales_order.json | 23 ++-- .../doctype/sales_order/sales_order.py | 46 ++++++- .../doctype/sales_order/sales_order_list.js | 8 +- .../doctype/sales_order/services/status.py | 3 + .../doctype/sales_order/test_sales_order.py | 115 ++++++++++++++++-- .../sales_order_item/sales_order_item.json | 14 ++- .../sales_order_item/sales_order_item.py | 1 + .../selling_settings/selling_settings.json | 10 +- .../doctype/delivery_note/delivery_note.py | 1 + 15 files changed, 214 insertions(+), 48 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index d756eedeb5f..d5263e83622 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -614,6 +614,7 @@ class SalesInvoice(SellingController): "percent_join_field": "sales_order", "status_field": "delivery_status", "keyword": "Delivered", + "exclude_field": "skip_delivery", "second_source_dt": "Delivery Note Item", "second_source_field": "qty", "second_join_field": "so_detail", diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py index d66c5621f7a..e8ec823c1cf 100644 --- a/erpnext/accounts/services/child_item_update.py +++ b/erpnext/accounts/services/child_item_update.py @@ -144,6 +144,7 @@ class ChildItemUpdater: if parent.is_against_so(): parent.update_status_updater() elif self.parent_doctype == "Sales Order": + parent.set_skip_delivery() parent.check_credit_limit() for idx, row in enumerate(parent.get(self.child_docname), start=1): diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 372a03ddab6..df7a3482ec5 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -605,14 +605,21 @@ class StatusUpdater(Document): @staticmethod 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( target_dt, - filters={"parent": name, "parenttype": target_parent_dt}, + filters=filters, 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 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_ref_field"], args["target_field"], + args.get("exclude_field"), ) # update field if args.get("status_field"): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index c8210857437..a0357751635 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -524,7 +524,8 @@ class WorkOrder(Document): .on(ProductBundleItem.parent == SalesOrderItem.item_code) .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) .where( - (SalesOrder.skip_delivery_note == 0) + (SalesOrderItem.skip_delivery == 0) + & (SalesOrder.skip_delivery_note == 0) & (SalesOrder.docstatus == 1) & (SalesOrder.name == self.sales_order) & ( @@ -545,6 +546,7 @@ class WorkOrder(Document): .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) .where( (SalesOrder.name == self.sales_order) + & (SalesOrderItem.skip_delivery == 0) & (SalesOrder.skip_delivery_note == 0) & (SalesOrderItem.item_code == PackedItem.parent_item) & (SalesOrder.docstatus == 1) diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index 5bf172e594e..e73520455e6 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -307,8 +307,10 @@ def make_delivery_note( return False return ( - (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) - ) and doc.delivered_by_supplier != 1 + ((abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)) + and doc.delivered_by_supplier != 1 + and not cint(doc.skip_delivery) + ) def update_item(source, target, source_parent): target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 61a79027a33..69d0b66040b 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -1020,11 +1020,14 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if (doc.status !== "Closed") { if (doc.status !== "On Hold") { 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 = - (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 (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() { + 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( "delivery_date", - this.frm.doc.order_type == "Sales" && !this.frm.doc.skip_delivery_note + this.frm.doc.order_type == "Sales" && !all_skipped ); } diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 660b8254683..bd85ba35860 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -312,7 +312,6 @@ }, { "allow_on_submit": 1, - "depends_on": "eval:!doc.skip_delivery_note", "fieldname": "delivery_date", "fieldtype": "Date", "hide_days": 1, @@ -1478,16 +1477,6 @@ "options": "Phone", "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", "fetch_from": "customer.is_internal_customer", @@ -1708,6 +1697,16 @@ "options": "Contact", "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", "fieldname": "has_unit_price_items", @@ -1826,7 +1825,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-08-21 23:11:48.053347", + "modified": "2026-08-26 12:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 812fc89a5d0..3996b9c716a 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -222,6 +222,7 @@ class SalesOrder(SellingController): def validate(self): super().validate() + self.set_skip_delivery() self.validate_delivery_date() self.validate_proj_cust() self.validate_po() @@ -268,7 +269,7 @@ class SalesOrder(SellingController): def validate_po(self): # 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"): if d.delivery_date and getdate(self.po_date) > getdate(d.delivery_date): 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( "Sales Order", filters={ @@ -347,6 +348,44 @@ class SalesOrder(SellingController): 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): 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)) 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] max_delivery_date = max(delivery_date_list) if delivery_date_list else None 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, ) .distinct() + .where(SalesOrderItem.skip_delivery == 0) .where(SalesOrder.skip_delivery_note == 0) .where(SalesOrder.docstatus < 2) .where(SalesOrderItem.delivery_date.between(start, end)) diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js index d83e3cfadf8..12b2c0ad6f1 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_list.js +++ b/erpnext/selling/doctype/sales_order/sales_order_list.js @@ -9,8 +9,8 @@ frappe.listview_settings["Sales Order"] = { "status", "advance_payment_status", "order_type", - "name", "skip_delivery_note", + "name", ], get_indicator: function (doc) { if (doc.status === "Closed") { @@ -23,7 +23,7 @@ frappe.listview_settings["Sales Order"] = { return [__("Completed"), "green", "status,=,Completed"]; } else if (doc.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) { // not delivered & overdue return [ @@ -50,14 +50,12 @@ frappe.listview_settings["Sales Order"] = { return [__("To Deliver"), "orange", "per_delivered,<,100|per_billed,=,100|status,!=,Closed"]; } } else if ( - flt(doc.per_delivered) === 100 && + (flt(doc.per_delivered) === 100 || doc.skip_delivery_note) && flt(doc.grand_total) !== 0 && flt(doc.per_billed) < 100 ) { // to bill 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) { diff --git a/erpnext/selling/doctype/sales_order/services/status.py b/erpnext/selling/doctype/sales_order/services/status.py index c11163088c8..930481d2498 100644 --- a/erpnext/selling/doctype/sales_order/services/status.py +++ b/erpnext/selling/doctype/sales_order/services/status.py @@ -50,6 +50,9 @@ class StatusService: tot_qty, delivered_qty = 0.0, 0.0 for item in doc.items: + if item.skip_delivery: + continue + if item.delivered_by_supplier: item_delivered_qty = frappe.get_all( "Purchase Order Item", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 48221f17f22..691bd804472 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -107,28 +107,119 @@ class TestSalesOrder(ERPNextTestSuite): mr.reload() self.assertRaises(frappe.ValidationError, make_material_request, so.name) - def test_sales_order_skip_delivery_note(self): - so = make_sales_order(do_not_submit=True) + @ERPNextTestSuite.change_settings("Selling Settings", {"skip_delivery_note_for_service_items": 1}) + 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.skip_delivery_note = 1 - so.append( - "items", - { - "item_code": "_Test Item 2", - "qty": 2, - "rate": 100, - }, - ) so.save() 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.insert() si.submit() + so.reload() 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( "Selling Settings", {"allow_multiple_items": 1, "allow_negative_rates_for_items": 1} ) diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 4105878df42..87f38e7c3c8 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -70,6 +70,7 @@ "gross_profit", "drop_ship_section", "delivered_by_supplier", + "skip_delivery", "supplier", "item_weight_details", "weight_per_unit", @@ -203,7 +204,6 @@ { "allow_on_submit": 1, "columns": 2, - "depends_on": "eval: !parent.skip_delivery_note", "fieldname": "delivery_date", "fieldtype": "Date", "in_list_view": 1, @@ -507,6 +507,16 @@ "label": "Supplier delivers to Customer", "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, "fieldname": "supplier", @@ -1056,7 +1066,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-25 10:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 98298f22036..d0f77fc0a2d 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -83,6 +83,7 @@ class SalesOrderItem(Document): requested_qty: DF.Float reserve_stock: DF.Check returned_qty: DF.Float + skip_delivery: DF.Check stock_qty: DF.Float stock_reserved_qty: DF.Float stock_uom: DF.Link | None diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 4cd5c6d2625..646d71e6c74 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -32,6 +32,7 @@ "sales_transactions_settings_section", "so_required", "dn_required", + "skip_delivery_note_for_service_items", "sales_update_frequency", "column_break_5", "allow_multiple_items", @@ -116,6 +117,13 @@ "label": "Is Delivery Note required to create Sales Invoice?", "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", "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, "issingle": 1, "links": [], - "modified": "2026-04-29 11:05:48.836362", + "modified": "2026-08-19 11:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 4f53d00a453..96b14d598a0 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -172,6 +172,7 @@ class DeliveryNote(SellingController): "percent_join_field": "against_sales_order", "status_field": "delivery_status", "keyword": "Delivered", + "exclude_field": "skip_delivery", "second_source_dt": "Sales Invoice Item", "second_source_field": "qty", "second_join_field": "so_detail",