mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-31 18:59:08 +00:00
feat: subcontracting inward (#47728)
* feat: subcontracting inward * feat: stock reservation * feat: subcontracting delivery * feat: all remaining stuff * fix: linter errors * fix: patch * fix: modify stock entry type validation * fix: customer provided item cost field mandatory validation * fix: failing tests * fix: failing tests * fix: subcontracting controlller * refactor: semi final * refactor: final * chore: resolve conflicts * refactor: changes requested * fix: reservation transfer of extra qty * fix: consider add cost for customer provided rate field * test: create test data * test: subcontracted sales order (partial) * test: fin * fix: do not add self RM in DN created from SI * fix: failing test case * fix: conflicting function name * refactor: final changes * fix: more bugs * perf: various and major performance improvements * fix: consider warehouse as well in all queries * fix: same item code with diff warehouse in manufacture entry * refactor: readability * fix: frontend validations * perf: replace query inside loop with single query * fix: set additional item flag to true when extra customer provided item is received * fix: bugs found by coderabbit * fix: more coderabbit bugs * fix: add validation to disallow cancellation of manufacturing entry * perf: use cached values wherever it makes sense * test: fix redundant insert to child tables * fix: consider SI return of billed self RM * fix: bug found by coderabbit --------- Co-authored-by: Mihir Kandoi <mihirkandoi@Mihirs-MacBook-Air.local>
This commit is contained in:
@@ -54,7 +54,8 @@ frappe.ui.form.on("Sales Order", {
|
||||
frm.doc.status !== "Closed" &&
|
||||
flt(frm.doc.per_delivered) < 100 &&
|
||||
flt(frm.doc.per_billed) < 100 &&
|
||||
frm.has_perm("write")
|
||||
frm.has_perm("write") &&
|
||||
!frm.doc.is_subcontracted
|
||||
) {
|
||||
frm.add_custom_button(__("Update Items"), () => {
|
||||
erpnext.utils.update_child_items({
|
||||
@@ -84,7 +85,8 @@ frappe.ui.form.on("Sales Order", {
|
||||
if (
|
||||
frm.doc.__onload &&
|
||||
frm.doc.__onload.has_reserved_stock &&
|
||||
frappe.model.can_cancel("Stock Reservation Entry")
|
||||
frappe.model.can_cancel("Stock Reservation Entry") &&
|
||||
!frm.doc.is_subcontracted
|
||||
) {
|
||||
frm.add_custom_button(
|
||||
__("Unreserve"),
|
||||
@@ -93,16 +95,21 @@ frappe.ui.form.on("Sales Order", {
|
||||
);
|
||||
}
|
||||
|
||||
frm.doc.items.forEach((item) => {
|
||||
if (flt(item.stock_reserved_qty) > 0 && frappe.model.can_read("Stock Reservation Entry")) {
|
||||
frm.add_custom_button(
|
||||
__("Reserved Stock"),
|
||||
() => frm.events.show_reserved_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (!frm.doc.is_subcontracted) {
|
||||
frm.doc.items.forEach((item) => {
|
||||
if (
|
||||
flt(item.stock_reserved_qty) > 0 &&
|
||||
frappe.model.can_read("Stock Reservation Entry")
|
||||
) {
|
||||
frm.add_custom_button(
|
||||
__("Reserved Stock"),
|
||||
() => frm.events.show_reserved_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus === 0) {
|
||||
@@ -112,7 +119,7 @@ frappe.ui.form.on("Sales Order", {
|
||||
frm.events.get_items_from_internal_purchase_order(frm);
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus === 0) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.is_subcontracted) {
|
||||
frappe.call({
|
||||
method: "erpnext.selling.doctype.sales_order.sales_order.get_stock_reservation_status",
|
||||
callback: function (r) {
|
||||
@@ -749,10 +756,28 @@ frappe.ui.form.on("Sales Order", {
|
||||
|
||||
frm.schedule_dialog.fields_dict.delivery_schedule.refresh();
|
||||
},
|
||||
|
||||
get_subcontracting_boms_for_finished_goods: function (fg_item) {
|
||||
return frappe.call({
|
||||
method: "erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_finished_goods",
|
||||
args: {
|
||||
fg_items: fg_item,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
get_subcontracting_boms_for_service_item: function (service_item) {
|
||||
return frappe.call({
|
||||
method: "erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_service_item",
|
||||
args: {
|
||||
service_item: service_item,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Sales Order Item", {
|
||||
item_code: function (frm, cdt, cdn) {
|
||||
item_code: async function (frm, cdt, cdn) {
|
||||
var row = locals[cdt][cdn];
|
||||
if (frm.doc.delivery_date) {
|
||||
row.delivery_date = frm.doc.delivery_date;
|
||||
@@ -760,6 +785,50 @@ frappe.ui.form.on("Sales Order Item", {
|
||||
} else {
|
||||
frm.script_manager.copy_from_first_row("items", row, ["delivery_date"]);
|
||||
}
|
||||
|
||||
if (frm.doc.is_subcontracted) {
|
||||
if (row.item_code && !row.fg_item) {
|
||||
var result = await frm.events.get_subcontracting_boms_for_service_item(row.item_code);
|
||||
|
||||
if (result.message && Object.keys(result.message).length) {
|
||||
var finished_goods = Object.keys(result.message);
|
||||
|
||||
// Set FG if only one active Subcontracting BOM is found
|
||||
if (finished_goods.length === 1) {
|
||||
row.fg_item = result.message[finished_goods[0]].finished_good;
|
||||
row.uom = result.message[finished_goods[0]].finished_good_uom;
|
||||
refresh_field("items");
|
||||
} else {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Select Finished Good"),
|
||||
size: "small",
|
||||
fields: [
|
||||
{
|
||||
fieldname: "finished_good",
|
||||
fieldtype: "Autocomplete",
|
||||
label: __("Finished Good"),
|
||||
options: finished_goods,
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Select"),
|
||||
primary_action: () => {
|
||||
var subcontracting_bom = result.message[dialog.get_value("finished_good")];
|
||||
|
||||
if (subcontracting_bom) {
|
||||
row.fg_item = subcontracting_bom.finished_good;
|
||||
row.uom = subcontracting_bom.finished_good_uom;
|
||||
refresh_field("items");
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
},
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
delivery_date: function (frm, cdt, cdn) {
|
||||
@@ -782,6 +851,50 @@ frappe.ui.form.on("Sales Order Item", {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
fg_item: async function (frm, cdt, cdn) {
|
||||
if (frm.doc.is_subcontracted) {
|
||||
var row = locals[cdt][cdn];
|
||||
|
||||
if (row.fg_item) {
|
||||
var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item);
|
||||
|
||||
if (result.message && Object.keys(result.message).length) {
|
||||
frappe.model.set_value(cdt, cdn, "item_code", result.message.service_item);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"qty",
|
||||
flt(row.fg_item_qty) * flt(result.message.conversion_factor)
|
||||
);
|
||||
frappe.model.set_value(cdt, cdn, "uom", result.message.service_item_uom);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
qty: async function (frm, cdt, cdn) {
|
||||
if (frm.doc.is_subcontracted) {
|
||||
var row = locals[cdt][cdn];
|
||||
|
||||
if (row.fg_item) {
|
||||
var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item);
|
||||
|
||||
if (
|
||||
result.message &&
|
||||
row.item_code == result.message.service_item &&
|
||||
row.uom == result.message.service_item_uom
|
||||
) {
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"fg_item_qty",
|
||||
flt(row.qty) / flt(result.message.conversion_factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
erpnext.selling.SalesOrderController = class SalesOrderController extends erpnext.selling.SellingController {
|
||||
@@ -795,6 +908,22 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
let allow_delivery = false;
|
||||
|
||||
if (doc.docstatus == 1) {
|
||||
if (
|
||||
!["Closed", "Completed"].includes(doc.status) &&
|
||||
flt(doc.per_delivered) < 100 &&
|
||||
flt(doc.per_billed) < 100
|
||||
) {
|
||||
if (!doc.__onload || doc.__onload.can_update_items) {
|
||||
this.frm.add_custom_button(__("Update Items"), () => {
|
||||
erpnext.utils.update_child_items({
|
||||
frm: this.frm,
|
||||
child_docname: "items",
|
||||
child_doctype: "Sales Order Detail",
|
||||
cannot_add_row: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.frm.has_perm("submit")) {
|
||||
if (doc.status === "On Hold") {
|
||||
// un-hold
|
||||
@@ -847,11 +976,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.is_subcontracted) {
|
||||
if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) {
|
||||
this.frm.add_custom_button(
|
||||
__("Subcontracting Inward Order"),
|
||||
() => {
|
||||
me.make_subcontracting_inward_order();
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!doc.__onload || !doc.__onload.has_reserved_stock) &&
|
||||
flt(doc.per_picked) < 100 &&
|
||||
flt(doc.per_delivered) < 100 &&
|
||||
frappe.model.can_create("Pick List")
|
||||
frappe.model.can_create("Pick List") &&
|
||||
!doc.is_subcontracted
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Pick List"),
|
||||
@@ -880,7 +1022,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
);
|
||||
}
|
||||
|
||||
if (frappe.model.can_create("Work Order")) {
|
||||
if (frappe.model.can_create("Work Order") && !doc.is_subcontracted) {
|
||||
this.frm.add_custom_button(
|
||||
__("Work Order"),
|
||||
() => this.make_work_order(),
|
||||
@@ -890,7 +1032,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
|
||||
// sales invoice
|
||||
if (flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) {
|
||||
if (
|
||||
(flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) ||
|
||||
doc.is_subcontracted
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Sales Invoice"),
|
||||
() => me.make_sales_invoice(),
|
||||
@@ -902,13 +1047,16 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
if (
|
||||
(!doc.order_type ||
|
||||
((order_is_a_sale || order_is_a_custom_sale) && flt(doc.per_delivered) < 100)) &&
|
||||
frappe.model.can_create("Material Request")
|
||||
frappe.model.can_create("Material Request") &&
|
||||
!doc.is_subcontracted
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Material Request"),
|
||||
() => this.make_material_request(),
|
||||
__("Create")
|
||||
);
|
||||
if (!doc.is_subcontracted) {
|
||||
this.frm.add_custom_button(
|
||||
__("Material Request"),
|
||||
() => this.make_material_request(),
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
this.frm.add_custom_button(
|
||||
__("Request for Raw Materials"),
|
||||
() => this.make_raw_material_request(),
|
||||
@@ -917,7 +1065,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
|
||||
// Make Purchase Order
|
||||
if (!this.frm.doc.is_internal_customer && frappe.model.can_create("Purchase Order")) {
|
||||
if (
|
||||
!this.frm.doc.is_internal_customer &&
|
||||
frappe.model.can_create("Purchase Order") &&
|
||||
!doc.is_subcontracted
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Purchase Order"),
|
||||
() => this.make_purchase_order(),
|
||||
@@ -991,7 +1143,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
}
|
||||
|
||||
if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Quotation")) {
|
||||
if (
|
||||
this.frm.doc.docstatus === 0 &&
|
||||
frappe.model.can_read("Quotation") &&
|
||||
!this.frm.doc.is_subcontracted
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Quotation"),
|
||||
function () {
|
||||
@@ -1606,6 +1762,14 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
make_subcontracting_inward_order() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order",
|
||||
frm: this.frm,
|
||||
freeze_message: __("Creating Subcontracting Inward Order ..."),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.selling.SalesOrderController({ frm: cur_frm }));
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"company",
|
||||
"skip_delivery_note",
|
||||
"has_unit_price_items",
|
||||
"is_subcontracted",
|
||||
"amended_from",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
@@ -1035,8 +1036,8 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "packed_items",
|
||||
"depends_on": "packed_items",
|
||||
"collapsible_depends_on": "eval:!doc.is_subcontracted && doc.packed_items",
|
||||
"depends_on": "eval:!doc.is_subcontracted && doc.packed_items",
|
||||
"fieldname": "packing_list",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_days": 1,
|
||||
@@ -1607,7 +1608,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)",
|
||||
"depends_on": "eval: ((doc.docstatus == 0 || doc.reserve_stock) && !doc.is_subcontracted)",
|
||||
"description": "If checked, Stock will be reserved on <b>Submit</b>",
|
||||
"fieldname": "reserve_stock",
|
||||
"fieldtype": "Check",
|
||||
@@ -1688,13 +1689,21 @@
|
||||
"fieldtype": "Data",
|
||||
"is_virtual": 1,
|
||||
"label": "Last Scanned Warehouse"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_subcontracted",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Subcontracted",
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "fa fa-file-text",
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-28 12:14:29.760988",
|
||||
"modified": "2025-10-12 12:14:29.760988",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
@@ -120,6 +120,7 @@ class SalesOrder(SellingController):
|
||||
incoterm: DF.Link | None
|
||||
inter_company_order_reference: DF.Link | None
|
||||
is_internal_customer: DF.Check
|
||||
is_subcontracted: DF.Check
|
||||
items: DF.Table[SalesOrderItem]
|
||||
language: DF.Link | None
|
||||
letter_head: DF.Link | None
|
||||
@@ -195,6 +196,10 @@ class SalesOrder(SellingController):
|
||||
def onload(self) -> None:
|
||||
super().onload()
|
||||
|
||||
if self.get("is_subcontracted"):
|
||||
self.set_onload("can_update_items", self.can_update_items())
|
||||
return
|
||||
|
||||
if frappe.get_single_value("Stock Settings", "enable_stock_reservation"):
|
||||
if self.has_unreserved_stock():
|
||||
self.set_onload("has_unreserved_stock", True)
|
||||
@@ -202,6 +207,15 @@ class SalesOrder(SellingController):
|
||||
if has_reserved_stock(self.doctype, self.name):
|
||||
self.set_onload("has_reserved_stock", True)
|
||||
|
||||
def can_update_items(self) -> bool:
|
||||
result = True
|
||||
|
||||
if self.is_subcontracted:
|
||||
if frappe.db.exists("Subcontracting Inward Order", {"sales_order": self.name, "docstatus": 1}):
|
||||
result = False
|
||||
|
||||
return result
|
||||
|
||||
def before_validate(self):
|
||||
self.set_has_unit_price_items()
|
||||
self.flags.allow_zero_qty = self.has_unit_price_items
|
||||
@@ -233,6 +247,7 @@ class SalesOrder(SellingController):
|
||||
make_packing_list(self)
|
||||
|
||||
self.validate_with_previous_doc()
|
||||
self.validate_fg_item_for_subcontracting()
|
||||
self.set_status()
|
||||
|
||||
if not self.billing_status:
|
||||
@@ -243,7 +258,39 @@ class SalesOrder(SellingController):
|
||||
self.advance_payment_status = "Not Requested"
|
||||
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.enable_auto_reserve_stock()
|
||||
if not self.get("is_subcontracted"):
|
||||
self.enable_auto_reserve_stock()
|
||||
|
||||
def validate_fg_item_for_subcontracting(self):
|
||||
if self.is_subcontracted:
|
||||
for item in self.items:
|
||||
if not item.fg_item:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Finished Good Item is not specified for service item {1}").format(
|
||||
item.idx, item.item_code
|
||||
)
|
||||
)
|
||||
else:
|
||||
if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Finished Good Item {1} must be a sub-contracted item").format(
|
||||
item.idx, item.fg_item
|
||||
)
|
||||
)
|
||||
if not frappe.db.get_value(
|
||||
"Subcontracting BOM",
|
||||
{"finished_good": item.fg_item, "is_active": 1},
|
||||
"finished_good_bom",
|
||||
) and not frappe.get_value("Item", item.fg_item, "default_bom"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: BOM not found for FG Item {1}").format(item.idx, item.fg_item)
|
||||
)
|
||||
if not item.fg_item_qty:
|
||||
frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx))
|
||||
else:
|
||||
for item in self.items:
|
||||
item.set("fg_item", None)
|
||||
item.set("fg_item_qty", 0)
|
||||
|
||||
def enable_auto_reserve_stock(self):
|
||||
if self.is_new() and frappe.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||
@@ -449,7 +496,7 @@ class SalesOrder(SellingController):
|
||||
|
||||
update_coupon_code_count(self.coupon_code, "used")
|
||||
|
||||
if self.get("reserve_stock"):
|
||||
if self.get("reserve_stock") and not self.get("is_subcontracted"):
|
||||
self.create_stock_reservation_entries()
|
||||
|
||||
def on_cancel(self):
|
||||
@@ -535,9 +582,23 @@ class SalesOrder(SellingController):
|
||||
if status == "Draft" and self.docstatus == 1:
|
||||
self.check_credit_limit()
|
||||
self.update_reserved_qty()
|
||||
self.update_subcontracting_order_status()
|
||||
self.notify_update()
|
||||
clear_doctype_notifications(self)
|
||||
|
||||
def update_subcontracting_order_status(self):
|
||||
from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import (
|
||||
update_subcontracting_inward_order_status as update_scio_status,
|
||||
)
|
||||
|
||||
if self.is_subcontracted:
|
||||
scio = frappe.get_cached_value(
|
||||
"Subcontracting Inward Order", {"sales_order": self.name, "docstatus": 1}, "name"
|
||||
)
|
||||
|
||||
if scio:
|
||||
update_scio_status(scio, "Closed" if self.status == "Closed" else None)
|
||||
|
||||
def update_reserved_qty(self, so_item_rows=None):
|
||||
"""update requested qty (before ordered_qty is updated)"""
|
||||
item_wh_list = []
|
||||
@@ -1290,6 +1351,46 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
return child_filter
|
||||
|
||||
def add_self_rm(doclist):
|
||||
parent = frappe.qb.DocType("Subcontracting Inward Order")
|
||||
child = frappe.qb.DocType("Subcontracting Inward Order Received Item")
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
child.required_qty,
|
||||
child.consumed_qty,
|
||||
(child.billed_qty - child.returned_qty).as_("qty"),
|
||||
child.rm_item_code,
|
||||
child.stock_uom,
|
||||
child.name,
|
||||
)
|
||||
.where(
|
||||
(parent.docstatus == 1)
|
||||
& (parent.sales_order == source_name)
|
||||
& (child.is_customer_provided_item == 0)
|
||||
)
|
||||
)
|
||||
result = query.run(as_dict=True)
|
||||
|
||||
if result:
|
||||
idx = len(doclist.items) + 1
|
||||
for item in result:
|
||||
if (qty := max(item.required_qty, item.consumed_qty) - item.qty) > 0:
|
||||
doclist.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item.rm_item_code,
|
||||
"qty": qty,
|
||||
"uom": item.stock_uom,
|
||||
"scio_detail": item.name,
|
||||
},
|
||||
)
|
||||
doclist.process_item_selection(idx)
|
||||
idx += 1
|
||||
doclist.has_subcontracted = 1
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Sales Order",
|
||||
source_name,
|
||||
@@ -1328,6 +1429,9 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
|
||||
ignore_permissions=ignore_permissions,
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"):
|
||||
add_self_rm(doclist)
|
||||
|
||||
automatically_fetch_payment_terms = cint(
|
||||
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
@@ -2005,3 +2109,71 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
|
||||
@frappe.whitelist()
|
||||
def get_stock_reservation_status():
|
||||
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_subcontracting_inward_order(source_name, target_doc=None):
|
||||
if not is_so_fully_subcontracted(source_name):
|
||||
return get_mapped_subcontracting_inward_order(source_name, target_doc)
|
||||
else:
|
||||
frappe.throw(_("This Sales Order has been fully subcontracted."))
|
||||
|
||||
|
||||
def is_so_fully_subcontracted(so_name):
|
||||
table = frappe.qb.DocType("Sales Order Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.name)
|
||||
.where((table.parent == so_name) & (table.qty != table.subcontracted_qty))
|
||||
)
|
||||
return not query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_mapped_subcontracting_inward_order(source_name, target_doc=None):
|
||||
def post_process(source_doc, target_doc):
|
||||
if (
|
||||
frappe.db.count(
|
||||
"Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}
|
||||
)
|
||||
== 1
|
||||
):
|
||||
target_doc.customer_warehouse = frappe.get_cached_value(
|
||||
"Warehouse",
|
||||
{"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0},
|
||||
"name",
|
||||
)
|
||||
target_doc.populate_items_table()
|
||||
|
||||
if target_doc and isinstance(target_doc, str):
|
||||
target_doc = json.loads(target_doc)
|
||||
for key in ["service_items", "items", "received_items"]:
|
||||
if key in target_doc:
|
||||
del target_doc[key]
|
||||
target_doc = json.dumps(target_doc)
|
||||
|
||||
target_doc = get_mapped_doc(
|
||||
"Sales Order",
|
||||
source_name,
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Subcontracting Inward Order",
|
||||
"field_map": {},
|
||||
"field_no_map": ["total_qty", "total", "net_total"],
|
||||
"validation": {
|
||||
"docstatus": ["=", 1],
|
||||
},
|
||||
},
|
||||
"Sales Order Item": {
|
||||
"doctype": "Subcontracting Inward Order Service Item",
|
||||
"field_map": {
|
||||
"name": "sales_order_item",
|
||||
},
|
||||
"field_no_map": ["qty", "fg_item_qty", "amount"],
|
||||
"condition": lambda item: item.qty != item.subcontracted_qty,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return target_doc
|
||||
|
||||
@@ -30,5 +30,6 @@ def get_data():
|
||||
{"label": _("Reference"), "items": ["Quotation", "Auto Repeat", "Stock Reservation Entry"]},
|
||||
{"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]},
|
||||
{"label": _("Schedule"), "items": ["Delivery Schedule Item"]},
|
||||
{"label": _("Subcontracting Inward"), "items": ["Subcontracting Inward Order"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -2613,6 +2613,7 @@ def make_sales_order(**args):
|
||||
so.customer = args.customer or "_Test Customer"
|
||||
so.currency = args.currency or "INR"
|
||||
so.po_no = args.po_no or ""
|
||||
so.is_subcontracted = args.is_subcontracted or 0
|
||||
if args.selling_price_list:
|
||||
so.selling_price_list = args.selling_price_list
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"fg_item",
|
||||
"fg_item_qty",
|
||||
"item_code",
|
||||
"customer_item_code",
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
@@ -25,6 +27,7 @@
|
||||
"quantity_and_rate",
|
||||
"qty",
|
||||
"stock_uom",
|
||||
"subcontracted_qty",
|
||||
"col_break2",
|
||||
"uom",
|
||||
"conversion_factor",
|
||||
@@ -468,6 +471,7 @@
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.delivered_by_supplier==1||doc.supplier",
|
||||
"depends_on": "eval:!parent.is_subcontracted",
|
||||
"fieldname": "drop_ship_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Drop Ship",
|
||||
@@ -490,6 +494,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"depends_on": "eval:!parent.is_subcontracted",
|
||||
"fieldname": "item_weight_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Item Weight Details"
|
||||
@@ -517,6 +522,7 @@
|
||||
"options": "UOM"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!parent.is_subcontracted",
|
||||
"fieldname": "warehouse_and_reference",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Warehouse and Reference"
|
||||
@@ -879,7 +885,7 @@
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.is_stock_item",
|
||||
"depends_on": "eval:(doc.is_stock_item && !parent.is_subcontracted)",
|
||||
"fieldname": "reserve_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": "Reserve Stock",
|
||||
@@ -935,6 +941,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!parent.is_subcontracted",
|
||||
"fieldname": "available_quantity_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Available Quantity"
|
||||
@@ -977,12 +984,39 @@
|
||||
"fieldname": "add_schedule",
|
||||
"fieldtype": "Button",
|
||||
"label": "Add Schedule"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"depends_on": "eval:parent.is_subcontracted",
|
||||
"fieldname": "subcontracted_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Subcontracted Quantity",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.is_subcontracted",
|
||||
"fieldname": "fg_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Finished Good",
|
||||
"mandatory_depends_on": "eval:parent.is_subcontracted",
|
||||
"options": "Item"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.is_subcontracted",
|
||||
"fieldname": "fg_item_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Finished Good Qty",
|
||||
"mandatory_depends_on": "eval:parent.is_subcontracted"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-21 17:01:54.269105",
|
||||
"modified": "2025-10-13 10:57:43.378448",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -42,6 +42,8 @@ class SalesOrderItem(Document):
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
ensure_delivery_based_on_produced_serial_no: DF.Check
|
||||
fg_item: DF.Link | None
|
||||
fg_item_qty: DF.Float
|
||||
grant_commission: DF.Check
|
||||
gross_profit: DF.Currency
|
||||
image: DF.Attach | None
|
||||
@@ -84,6 +86,7 @@ class SalesOrderItem(Document):
|
||||
stock_reserved_qty: DF.Float
|
||||
stock_uom: DF.Link | None
|
||||
stock_uom_rate: DF.Currency
|
||||
subcontracted_qty: DF.Float
|
||||
supplier: DF.Link | None
|
||||
target_warehouse: DF.Link | None
|
||||
total_weight: DF.Float
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"customer_group",
|
||||
"column_break_4",
|
||||
"territory",
|
||||
"item_price_tab",
|
||||
"item_price_settings_section",
|
||||
"selling_price_list",
|
||||
"maintain_same_rate_action",
|
||||
@@ -22,6 +23,7 @@
|
||||
"validate_selling_price",
|
||||
"editable_bundle_item_rates",
|
||||
"allow_negative_rates_for_items",
|
||||
"transaction_tab",
|
||||
"sales_transactions_settings_section",
|
||||
"so_required",
|
||||
"dn_required",
|
||||
@@ -38,7 +40,12 @@
|
||||
"allow_zero_qty_in_quotation",
|
||||
"allow_zero_qty_in_sales_order",
|
||||
"experimental_section",
|
||||
"use_legacy_js_reactivity"
|
||||
"use_legacy_js_reactivity",
|
||||
"subcontracting_inward_tab",
|
||||
"section_break_zwh6",
|
||||
"allow_delivery_of_overproduced_qty",
|
||||
"column_break_mla9",
|
||||
"deliver_scrap_items"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -232,6 +239,44 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Quotation with Zero Quantity"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_zwh6",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Subcontracting Inward Settings"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, system will allow user to deliver the entire quantity of the finished goods produced against the Subcontracting Inward Order. If disabled, system will allow delivery of only the ordered quantity.",
|
||||
"fieldname": "allow_delivery_of_overproduced_qty",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Delivery of Overproduced Qty"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_mla9",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the Scrap Item generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.",
|
||||
"fieldname": "deliver_scrap_items",
|
||||
"fieldtype": "Check",
|
||||
"label": "Deliver Scrap Items"
|
||||
},
|
||||
{
|
||||
"fieldname": "item_price_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Item Price"
|
||||
},
|
||||
{
|
||||
"fieldname": "transaction_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Transaction"
|
||||
},
|
||||
{
|
||||
"fieldname": "subcontracting_inward_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Subcontracting Inward"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "fallback_to_default_price_list",
|
||||
@@ -251,7 +296,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-24 16:08:48.865885",
|
||||
"modified": "2025-10-12 16:08:48.865885",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Selling Settings",
|
||||
|
||||
@@ -21,6 +21,7 @@ class SellingSettings(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
allow_against_multiple_purchase_orders: DF.Check
|
||||
allow_delivery_of_overproduced_qty: DF.Check
|
||||
allow_multiple_items: DF.Check
|
||||
allow_negative_rates_for_items: DF.Check
|
||||
allow_sales_order_creation_for_expired_quotation: DF.Check
|
||||
@@ -29,6 +30,7 @@ class SellingSettings(Document):
|
||||
blanket_order_allowance: DF.Float
|
||||
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
|
||||
customer_group: DF.Link | None
|
||||
deliver_scrap_items: DF.Check
|
||||
dn_required: DF.Literal["No", "Yes"]
|
||||
dont_reserve_sales_order_qty_on_sales_return: DF.Check
|
||||
editable_bundle_item_rates: DF.Check
|
||||
|
||||
Reference in New Issue
Block a user