diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 7992d61e7e802857aca94f339b5958abab470602..c7fe68225ade0902d2df800abdc74333b4d1c9ed 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -303,6 +303,18 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( sales_order_btn() { var me = this; + + let filters = { + docstatus: 1, + status: ["not in", ["Closed", "On Hold"]], + per_billed: ["<", 99.99], + company: me.frm.doc.company, + }; + + if (me.frm.doc.has_subcontracted) { + filters.is_subcontracted = 1; + } + this.$sales_order_btn = this.frm.add_custom_button( __("Sales Order"), function () { @@ -313,12 +325,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( setters: { customer: me.frm.doc.customer || undefined, }, - get_query_filters: { - docstatus: 1, - status: ["not in", ["Closed", "On Hold"]], - per_billed: ["<", 99.99], - company: me.frm.doc.company, - }, + get_query_filters: filters, allow_child_item_selection: true, child_fieldname: "items", child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"], @@ -1154,6 +1161,9 @@ frappe.ui.form.on("Sales Invoice", { if (frm.doc.is_debit_note) { frm.set_df_property("return_against", "label", __("Adjustment Against")); } + + frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted); + frm.toggle_display("update_stock", !frm.doc.has_subcontracted); }, is_down_payment_invoice: function (frm) { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index c398c05d6af7f372a1c4f60f0b0e1a828271aca6..1e42fa452a3332af0779593179f42a2cfc9624f6 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -34,6 +34,7 @@ "amended_from", "is_created_using_pos", "pos_closing_entry", + "has_subcontracted", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -1977,6 +1978,14 @@ { "fieldname": "column_break_lvbz", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "has_subcontracted", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Subcontracted", + "read_only": 1 } ], "grid_page_length": 50, @@ -1992,7 +2001,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2025-09-28 11:28:35.262629", + "modified": "2025-10-09 14:48:59.472826", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index c28c9bc62c0091dc720d7281b7a5572eae3c1d2b..eca61e9ea6782f7201fbf2045dd4aeb28d939a78 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -7,6 +7,7 @@ from frappe import _, msgprint, throw from frappe.contacts.doctype.address.address import get_address_display from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values +from frappe.query_builder import Case from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate from frappe.utils.data import comma_and @@ -35,7 +36,6 @@ from erpnext.accounts.general_ledger import ( ) from erpnext.accounts.party import get_due_date, get_party_account, get_party_details from erpnext.accounts.utils import ( - cancel_exchange_gain_loss_journal, get_account_currency, update_voucher_outstanding, ) @@ -138,6 +138,7 @@ class SalesInvoice(SellingController): from_date: DF.Date | None grand_total: DF.Currency group_same_items: DF.Check + has_subcontracted: DF.Check ignore_default_payment_terms_template: DF.Check ignore_pricing_rule: DF.Check in_words: DF.SmallText | None @@ -346,6 +347,8 @@ class SalesInvoice(SellingController): self.validate_posting_datetime_chronology() # @dokos super().validate() + self.is_subcontracted() + if not (self.is_pos or self.is_debit_note): self.so_dn_required() @@ -425,6 +428,8 @@ class SalesInvoice(SellingController): self.allow_write_off_only_on_pos() self.reset_default_field_value("set_warehouse", "items", "warehouse") + self.validate_subcontracted_sales_order() + self.validate_scio_self_rm_qty() def validate_accounts(self): self.validate_write_off_account() @@ -595,6 +600,7 @@ class SalesInvoice(SellingController): self.apply_loyalty_points() self.process_common_party_accounting() + self.update_billed_qty_in_scio() def validate_pos_return(self): if self.is_consolidated: @@ -725,6 +731,8 @@ class SalesInvoice(SellingController): ): self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode() + self.update_billed_qty_in_scio() + def update_status_updater_args(self): if not cint(self.update_stock): return @@ -861,6 +869,26 @@ class SalesInvoice(SellingController): timesheet.set_status() timesheet.db_update_all() + def update_billed_qty_in_scio(self): + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + fieldname = table.returned_qty if self.is_return else table.billed_qty + + data = frappe._dict( + { + item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty + for item in self.items + if item.scio_detail + } + ) + + if data: + case_expr = Case() + for name, qty in data.items(): + case_expr = case_expr.when(table.name == name, fieldname + qty) + frappe.qb.update(table).set(fieldname, case_expr).where( + (table.name.isin(list(data.keys()))) & (table.docstatus == 1) + ).run() + def update_time_sheet_detail(self, timesheet, args, sales_invoice): for data in timesheet.time_logs: if ( @@ -1265,6 +1293,50 @@ class SalesInvoice(SellingController): if not self.is_pos and self.write_off_account: self.write_off_account = None + def validate_subcontracted_sales_order(self): + if self.has_subcontracted: + if [item for item in self.items if not item.sales_order and not item.scio_detail]: + frappe.throw( + _( + "All items must be linked to a Sales Order or Subcontracting Inward Order for this Sales Invoice." + ) + ) + if not all( + frappe.get_all( + "Sales Order", + {"name": ["in", [item.sales_order for item in self.items if item.sales_order]]}, + pluck="is_subcontracted", + ) + ): + frappe.throw(_("All linked Sales Orders must be subcontracted.")) + + def validate_scio_self_rm_qty(self): + self_rms = [item for item in self.items if item.scio_detail] + if self_rms: + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + query = ( + frappe.qb.from_(table) + .select( + table.required_qty, table.consumed_qty, table.billed_qty, table.returned_qty, table.name + ) + .where((table.docstatus == 1) & (table.name.isin([item.scio_detail for item in self_rms]))) + ) + result = query.run(as_dict=True) + data = {item.name: item for item in result} + for item in self_rms: + row = data.get(item.scio_detail) + max_qty = max(row.required_qty, row.consumed_qty) - row.billed_qty - row.returned_qty + if item.stock_qty > max_qty: + frappe.throw( + _("Row #{0}: Stock quantity {1} ({2}) for item {3} cannot exceed {4}").format( + item.idx, + item.stock_qty, + item.stock_uom, + frappe.bold(item.item_code), + frappe.bold(max_qty), + ) + ) + def validate_write_off_account(self): if flt(self.write_off_amount) and not self.write_off_account: self.write_off_account = frappe.get_cached_value("Company", self.company, "write_off_account") @@ -2248,6 +2320,23 @@ class SalesInvoice(SellingController): if self.subscription: frappe.get_doc("Subscription", self.subscription).update_outstanding() + @frappe.whitelist() + def is_subcontracted(self): + if not self.has_subcontracted: + self.has_subcontracted = bool( + frappe.get_cached_value( + "Sales Order", + { + "name": ["in", [item.sales_order for item in self.items if item.sales_order]], + "is_subcontracted": 1, + }, + "name", + ) + ) + if self.has_subcontracted: + self.update_stock = 0 + return self.has_subcontracted + def get_total_in_party_account_currency(doc): total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total" @@ -2449,7 +2538,7 @@ def make_delivery_note(source_name, target_doc=None): "cost_center": "cost_center", }, "postprocess": update_item, - "condition": lambda doc: doc.delivered_by_supplier != 1, + "condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail, }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, "Sales Team": { diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index 8b77b22dc33c6334a259151a2967ea790139342e..6e0fd832f787c795b1d2d5bc9a2640fdec183247 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -112,6 +112,7 @@ "column_break_vwhb", "pos_invoice", "pos_invoice_item", + "scio_detail", "internal_transfer_section", "purchase_order", "column_break_92", @@ -1005,13 +1006,20 @@ "label": "Product Bundle", "options": "Product Bundle", "read_only": 1 + }, + { + "fieldname": "scio_detail", + "fieldtype": "Data", + "hidden": 1, + "label": "SCIO Detail", + "read_only": 1 } ], "grid_page_length": 50, "idx": 1, "istable": 1, "links": [], - "modified": "2025-03-07 10:25:30.275246", + "modified": "2025-09-04 11:08:25.583561", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index a56ed7200c107c01852eefb50ddc6fbdf15494b8..ac1cf8fffc12163c31c830a0f83d821df60c712e 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -87,6 +87,7 @@ class SalesInvoiceItem(Document): rate_with_margin: DF.Currency sales_invoice_item: DF.Data | None sales_order: DF.Link | None + scio_detail: DF.Data | None serial_and_batch_bundle: DF.Link | None serial_no: DF.Text | None service_end_date: DF.Date | None diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index f2274ceafb712971a8f037f36c67a5715e16c17b..0106809af4e1a372b2f49be808b839189fe3dda9 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -434,7 +434,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( ); } } else { - if (!doc.items.every((item) => item.qty == item.subcontracted_quantity)) { + if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) { this.frm.add_custom_button( __("Subcontracting Order"), () => { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index e39ba9d8eacd8f059541afd3180de3af427be383..9419d47fc403f63c40dc7a1c5c12b00da4c10162 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -1013,7 +1013,7 @@ def make_subcontracting_order(source_name, target_doc=None, save=False, submit=F return target_doc else: - frappe.throw(_("This PO has been fully subcontracted.")) + frappe.throw(_("This Purchase Order has been fully subcontracted.")) def is_po_fully_subcontracted(po_name): @@ -1021,7 +1021,7 @@ def is_po_fully_subcontracted(po_name): query = ( frappe.qb.from_(table) .select(table.name) - .where((table.parent == po_name) & (table.qty != table.subcontracted_quantity)) + .where((table.parent == po_name) & (table.qty != table.subcontracted_qty)) ) return not query.run(as_dict=True) @@ -1076,7 +1076,7 @@ def get_mapped_subcontracting_order(source_name, target_doc=None): "material_request_item": "material_request_item", }, "field_no_map": ["qty", "fg_item_qty", "amount"], - "condition": lambda item: item.qty != item.subcontracted_quantity, + "condition": lambda item: item.qty != item.subcontracted_qty, }, }, target_doc, diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 80c59e65e1581ac784fef2e26ba82031a71307dc..9e33357c5042c7b8cb9fb6984a7303d911296e83 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1097,9 +1097,9 @@ class TestPurchaseOrder(IntegrationTestCase): # Test - 2: Subcontracted Quantity for the PO Items of each line item should be updated accordingly po.reload() - self.assertEqual(po.items[0].subcontracted_quantity, 5) - self.assertEqual(po.items[1].subcontracted_quantity, 0) - self.assertEqual(po.items[2].subcontracted_quantity, 12.5) + self.assertEqual(po.items[0].subcontracted_qty, 5) + self.assertEqual(po.items[1].subcontracted_qty, 0) + self.assertEqual(po.items[2].subcontracted_qty, 12.5) # Test - 3: Amount for both FG Item and its Service Item should be updated correctly based on change in Quantity self.assertEqual(sco.items[0].amount, 2000) @@ -1135,10 +1135,10 @@ class TestPurchaseOrder(IntegrationTestCase): # Test - 8: Subcontracted Quantity for each PO Item should be subtracted if SCO gets cancelled po.reload() - self.assertEqual(po.items[2].subcontracted_quantity, 25) + self.assertEqual(po.items[2].subcontracted_qty, 25) sco.cancel() po.reload() - self.assertEqual(po.items[2].subcontracted_quantity, 12.5) + self.assertEqual(po.items[2].subcontracted_qty, 12.5) sco = make_subcontracting_order(po.name) sco.save() diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 59fa3a411b4399cb5725b72d15c860547cbced3b..9430562ff4a41c5c2a37966c87496c385908d95e 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -26,7 +26,7 @@ "quantity_and_rate", "qty", "stock_uom", - "subcontracted_quantity", + "subcontracted_qty", "col_break2", "uom", "conversion_factor", @@ -933,8 +933,9 @@ }, { "allow_on_submit": 1, + "default": "0", "depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow", - "fieldname": "subcontracted_quantity", + "fieldname": "subcontracted_qty", "fieldtype": "Float", "label": "Subcontracted Quantity", "no_copy": 1, @@ -947,7 +948,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-04-30 14:38:34.491901", + "modified": "2025-10-12 10:57:31.552812", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py index aebe6e1299ee887c6125a2b102423aaccb6b4792..c0747a614bc9e38c3d926683a31a312f5e1acfd7 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py @@ -85,7 +85,7 @@ class PurchaseOrderItem(Document): stock_qty: DF.Float stock_uom: DF.Link stock_uom_rate: DF.Currency - subcontracted_quantity: DF.Float + subcontracted_qty: DF.Float supplier_part_no: DF.Data | None supplier_quotation: DF.Link | None supplier_quotation_item: DF.Link | None diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index bd189db1a03410a2070720a0d68f78cebcbed61e..e325c837c81dabacf7b75c6babf33779433de0c8 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -4033,9 +4033,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil any_qty_changed = True if ( - parent.doctype == "Purchase Order" + parent.doctype in ["Sales Order", "Purchase Order"] and parent.is_subcontracted - and not parent.is_old_subcontracting_flow + and not parent.get("is_old_subcontracting_flow") ): validate_fg_item_for_subcontracting(d, new_child_flag) child_item.fg_item_qty = flt(d["fg_item_qty"]) @@ -4127,7 +4127,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.set_qty_as_per_stock_uom() parent.calculate_taxes_and_totals() parent.set_total_in_words() - if parent_doctype == "Sales Order": + if parent_doctype == "Sales Order" and not parent.is_subcontracted: make_packing_list(parent) parent.set_gross_profit() frappe.get_cached_doc("Authorization Control").validate_approving_authority( @@ -4174,6 +4174,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ).format(frappe.bold(parent.name)) ) else: # Sales Order + if parent.is_subcontracted and not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Inward Order is created against the Sales Order {0}." + ).format(frappe.bold(parent.name)) + ) parent.validate_selling_price() parent.validate_for_duplicate_items() parent.validate_items_product_bundle() @@ -4194,7 +4200,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.validate_uom_is_integer("stock_uom", "stock_qty") # Cancel and Recreate Stock Reservation Entries. - if parent_doctype == "Sales Order": + if parent_doctype == "Sales Order" and not parent.is_subcontracted: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( cancel_stock_reservation_entries, has_reserved_stock, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 52236de340b6d9b598158e1b0d1fd7570ebef3e0..3970963005e3923f6961d0c1b647fdabe9a881b9 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -95,9 +95,11 @@ class StockController(AccountsController): "Stock Reconciliation", ]: for item in self.get("items"): - if (item.get("valuation_rate") == 0 or item.get("incoming_rate") == 0) and item.get( - "allow_zero_valuation_rate" - ) == 0: + if ( + (item.get("valuation_rate") == 0 or item.get("incoming_rate") == 0) + and item.get("allow_zero_valuation_rate") == 0 + and frappe.get_cached_value("Item", item.item_code, "is_stock_item") + ): frappe.toast( _( "Row #{0}: Item {1} has zero rate but 'Allow Zero Valuation Rate' is not enabled." @@ -552,10 +554,14 @@ class StockController(AccountsController): break elif row.batch_no: - batches = frappe.get_all( - "Serial and Batch Entry", fields=["batch_no"], filters={"parent": row.serial_and_batch_bundle} + batches = sorted( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": row.serial_and_batch_bundle, "batch_no": ("is", "set")}, + pluck="batch_no", + distinct=True, + ) ) - batches = sorted([d.batch_no for d in batches]) if batches != [row.batch_no]: throw_error = True diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index feba62d72004f86037a5ce98e34f4ec022c4a34b..0b84d69773209efb11d85af957dba8a7729f334f 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -24,7 +24,7 @@ from erpnext.stock.utils import get_incoming_rate class SubcontractingController(StockController): def __init__(self, *args, **kwargs): - super(SubcontractingController, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.get("is_old_subcontracting_flow"): self.subcontract_data = frappe._dict( { @@ -35,6 +35,14 @@ class SubcontractingController(StockController): "order_supplied_items_field": "Purchase Order Item Supplied", } ) + elif self.doctype == "Subcontracting Inward Order": + self.subcontract_data = frappe._dict( + { + "order_doctype": "Subcontracting Inward Order", + "order_field": "subcontracting_inward_order", + "rm_detail_field": "scio_detail", + } + ) else: self.subcontract_data = frappe._dict( { @@ -47,49 +55,25 @@ class SubcontractingController(StockController): ) def before_validate(self): - if self.doctype in ["Subcontracting Order", "Subcontracting Receipt"]: + if self.doctype in [ + "Subcontracting Order", + "Subcontracting Inward Order", + "Subcontracting Receipt", + ]: self.remove_empty_rows() self.set_items_conversion_factor() def validate(self): - if self.doctype in ["Subcontracting Order", "Subcontracting Receipt"]: + if self.doctype in ["Subcontracting Order", "Subcontracting Receipt", "Subcontracting Inward Order"]: self.validate_items() - self.create_raw_materials_supplied() + self.create_raw_materials_supplied_or_received( + raw_material_table="supplied_items" + if self.doctype != "Subcontracting Inward Order" + else "received_items" + ) self.set_valuation_rate_for_rm() else: - super(SubcontractingController, self).validate() - - def set_valuation_rate_for_rm(self): - rate_changed = False - if self.doctype == "Subcontracting Receipt": - for row in self.supplied_items: - kwargs = frappe._dict( - { - "item_code": row.rm_item_code, - "warehouse": self.supplier_warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "qty": flt(row.consumed_qty) * (-1 if not self.is_return else 1), - "voucher_type": self.doctype, - "voucher_no": self.name, - "company": self.company, - "serial_and_batch_bundle": row.serial_and_batch_bundle, - "voucher_detail_no": row.name, - "batch_no": row.batch_no, - "serial_no": row.serial_no, - "use_serial_batch_fields": row.use_serial_batch_fields, - } - ) - - rate = get_incoming_rate(kwargs) - precision = frappe.get_precision("Subcontracting Receipt Supplied Item", "rate") - if flt(rate, precision) != flt(row.rate, precision): - row.rate = rate - row.amount = flt(row.consumed_qty) * flt(rate) - rate_changed = True - - if rate_changed: - self.calculate_items_qty_and_amount() + super().validate() def set_valuation_rate_for_rm(self): rate_changed = False @@ -141,7 +125,7 @@ class SubcontractingController(StockController): ) def remove_empty_rows(self): - for key in ["service_items", "items", "supplied_items"]: + for key in ["service_items", "items", "supplied_items", "received_items"]: if self.get(key): idx = 1 for item in self.get(key)[:]: @@ -165,33 +149,47 @@ class SubcontractingController(StockController): if not is_stock_item: frappe.throw(_("Row {0}: Item {1} must be a stock item.").format(item.idx, item.item_name)) + if ( + self.doctype == "Subcontracting Inward Order" + and item.delivery_warehouse == self.customer_warehouse + ): + frappe.throw( + _( + "Row {0}: Delivery Warehouse cannot be same as Customer Warehouse for Item {1}." + ).format(item.idx, frappe.bold(item.item_name)) + ) + if not item.get("is_scrap_item"): if not is_sub_contracted_item: frappe.throw( _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) ) - if ( - self.doctype == "Subcontracting Order" and not item.subcontracting_conversion_factor - ): # this condition will only be true if user has recently updated from develop branch - service_item_qty = frappe.get_value( - "Subcontracting Order Service Item", - filters={"purchase_order_item": item.purchase_order_item, "parent": self.name}, - fieldname=["qty"], + if self.doctype != "Subcontracting Receipt" and item.qty > flt( + get_pending_subcontracted_quantity( + self.doctype, + self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order, + ).get( + item.purchase_order_item + if self.doctype == "Subcontracting Order" + else item.sales_order_item ) - item.subcontracting_conversion_factor = service_item_qty / item.qty - - if self.doctype not in "Subcontracting Receipt" and item.qty > flt( - get_pending_subcontracted_quantity(self.purchase_order).get(item.purchase_order_item) / item.subcontracting_conversion_factor, - frappe.get_precision("Purchase Order Item", "qty"), + frappe.get_precision( + "Purchase Order Item" + if self.doctype == "Subcontracting Order" + else "Sales Order Item", + "qty", + ), ): frappe.throw( _( "Row {0}: Item {1}'s quantity cannot be higher than the available quantity." ).format(item.idx, item.item_name) ) - item.amount = item.qty * item.rate + + if self.doctype != "Subcontracting Inward Order": + item.amount = item.qty * item.rate if item.bom: is_active, bom_item = frappe.get_value("BOM", item.bom, ["is_active", "item"]) @@ -230,7 +228,10 @@ class SubcontractingController(StockController): self.__changed_name = [] self.__reference_name = [] - if self.doctype in ["Purchase Order", "Subcontracting Order"] or self.is_new(): + if ( + self.doctype in ["Purchase Order", "Subcontracting Order", "Subcontracting Inward Order"] + or self.is_new() + ): self.set(self.raw_material_table, []) return @@ -252,8 +253,13 @@ class SubcontractingController(StockController): self.__changed_name.extend(item_dict.keys()) def __get_backflush_based_on(self): - self.backflush_based_on = frappe.db.get_single_value( - "Buying Settings", "backflush_raw_materials_of_subcontract_based_on" + self.backflush_based_on = ( + frappe.db.get_single_value( + "Buying Settings", + "backflush_raw_materials_of_subcontract_based_on", + ) + if self.subcontract_data.order_doctype == "Subcontracting Order" + else "Material Transferred for Subcontract" ) def initialized_fields(self): @@ -265,7 +271,7 @@ class SubcontractingController(StockController): def __get_subcontract_orders(self): self.subcontract_orders = [] - if self.doctype in ["Purchase Order", "Subcontracting Order"]: + if self.doctype in ["Purchase Order", "Subcontracting Order", "Subcontracting Inward Order"]: return self.subcontract_orders = [ @@ -575,8 +581,13 @@ class SubcontractingController(StockController): return frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or [] def __update_reserve_warehouse(self, row, item): - if self.doctype == self.subcontract_data.order_doctype: + if ( + self.doctype == self.subcontract_data.order_doctype + and self.doctype != "Subcontracting Inward Order" + ): row.reserve_warehouse = self.set_reserve_warehouse or item.warehouse + elif frappe.get_cached_value("Item", row.rm_item_code, "is_customer_provided_item"): + row.warehouse = self.customer_warehouse def __set_alternative_item(self, bom_item): if self.alternative_item_details.get(bom_item.rm_item_code): @@ -651,10 +662,7 @@ class SubcontractingController(StockController): return serial_nos - def __add_supplied_item(self, item_row, bom_item, qty): - if bom_item.get("qty"): - bom_item.pop("qty") - + def __add_supplied_or_received_item(self, item_row, bom_item, qty): bom_item.conversion_factor = item_row.conversion_factor rm_obj = self.append(self.raw_material_table, bom_item) if rm_obj.get("qty"): @@ -667,7 +675,8 @@ class SubcontractingController(StockController): if self.doctype == self.subcontract_data.order_doctype: rm_obj.required_qty = flt(qty, rm_obj.precision("required_qty")) - rm_obj.amount = flt(rm_obj.required_qty * rm_obj.rate, rm_obj.precision("amount")) + if self.doctype != "Subcontracting Inward Order": + rm_obj.amount = flt(rm_obj.required_qty * rm_obj.rate, rm_obj.precision("amount")) else: rm_obj.consumed_qty = flt(qty, rm_obj.precision("consumed_qty")) rm_obj.required_qty = flt(bom_item.required_qty or qty, rm_obj.precision("required_qty")) @@ -876,14 +885,14 @@ class SubcontractingController(StockController): return qty - def __set_supplied_items(self): + def __set_supplied_or_received_items(self): self.bom_items = {} - has_supplied_items = True if self.get(self.raw_material_table) else False + has_items = True if self.get(self.raw_material_table) else False for row in self.items: if self.doctype != self.subcontract_data.order_doctype and ( (self.__changed_name and row.name not in self.__changed_name) - or (has_supplied_items and not self.__changed_name) + or (has_items and not self.__changed_name) ): continue @@ -897,7 +906,7 @@ class SubcontractingController(StockController): bom_item.main_item_code = row.item_code self.__update_reserve_warehouse(bom_item, row) self.__set_alternative_item(bom_item) - self.__add_supplied_item(row, bom_item, qty) + self.__add_supplied_or_received_item(row, bom_item, qty) elif self.backflush_based_on != "BOM": for key, transfer_item in self.available_materials.items(): @@ -907,7 +916,7 @@ class SubcontractingController(StockController): ) and transfer_item.qty > 0: qty = flt(self.__get_qty_based_on_material_transfer(row, transfer_item)) transfer_item.qty -= qty - self.__add_supplied_item(row, transfer_item.get("item_details"), qty) + self.__add_supplied_or_received_item(row, transfer_item.get("item_details"), qty) if self.qty_to_be_received: self.qty_to_be_received[ @@ -975,13 +984,13 @@ class SubcontractingController(StockController): ): return row - def __prepare_supplied_items(self): + def __prepare_supplied_or_received_items(self): self.initialized_fields() self.__get_subcontract_orders() self.__get_pending_qty_to_receive() self.get_available_materials() self.__remove_changed_rows() - self.__set_supplied_items() + self.__set_supplied_or_received_items() self.__modify_serial_and_batch_bundle() self.__set_rate_for_serial_and_batch_bundle() @@ -1008,7 +1017,7 @@ class SubcontractingController(StockController): msg = f"The Serial Nos {incorrect_sn} has not supplied against the {self.subcontract_data.order_doctype} {link}" frappe.throw(_(msg), title=_("Incorrect Serial Number Consumed")) - def __validate_supplied_items(self): + def __validate_supplied_or_received_items(self): if self.doctype not in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]: return @@ -1026,10 +1035,10 @@ class SubcontractingController(StockController): self.raw_material_table = raw_material_table self.__identify_change_in_item_table() - self.__prepare_supplied_items() - self.__validate_supplied_items() + self.__prepare_supplied_or_received_items() + self.__validate_supplied_or_received_items() - def create_raw_materials_supplied(self, raw_material_table="supplied_items"): + def create_raw_materials_supplied_or_received(self, raw_material_table="supplied_items"): self.set_materials_for_subcontracted_items(raw_material_table) if self.doctype in ["Subcontracting Receipt", "Purchase Receipt", "Purchase Invoice"]: @@ -1282,14 +1291,16 @@ def get_item_details(items): return item_details -def get_pending_subcontracted_quantity(po_name): - table = frappe.qb.DocType("Purchase Order Item") +def get_pending_subcontracted_quantity(doctype, name): + table = frappe.qb.DocType( + "Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item" + ) query = ( frappe.qb.from_(table) - .select(table.name, table.qty, table.subcontracted_quantity) - .where(table.parent == po_name) + .select(table.name, table.stock_qty, table.subcontracted_qty) + .where(table.parent == name) ) - return {item.name: item.qty - item.subcontracted_quantity for item in query.run(as_dict=True)} + return {item.name: item.stock_qty - item.subcontracted_qty for item in query.run(as_dict=True)} @frappe.whitelist() @@ -1447,7 +1458,7 @@ def make_return_stock_entry_for_subcontract( else: rm_detail_field = "sco_rm_detail" - for key, value in available_materials.items(): + for _key, value in available_materials.items(): if not value.qty: continue diff --git a/erpnext/controllers/subcontracting_inward_controller.py b/erpnext/controllers/subcontracting_inward_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..b6954e26d91a024ebcfc1d2a37614b41a9820c8d --- /dev/null +++ b/erpnext/controllers/subcontracting_inward_controller.py @@ -0,0 +1,1024 @@ +import frappe +from frappe import _, bold +from frappe.query_builder import Case +from frappe.utils import flt, get_link_to_form + +from erpnext.stock.serial_batch_bundle import get_serial_batch_list_from_item + + +class SubcontractingInwardController: + def validate_subcontracting_inward(self): + self.validate_inward_order() + self.validate_customer_provided_item_for_inward() + self.validate_warehouse_() + self.validate_serial_batch_for_return_or_delivery() + self.validate_delivery() + self.update_customer_provided_item_cost() + + def on_submit_subcontracting_inward(self): + self.update_inward_order_item() + self.update_inward_order_received_items() + self.update_inward_order_scrap_items() + self.create_stock_reservation_entries_for_inward() + self.update_inward_order_status() + + def on_cancel_subcontracting_inward(self): + self.update_inward_order_item() + self.validate_manufacture_entry_cancel() + self.validate_delivery() + self.validate_receive_from_customer_cancel() + self.update_inward_order_received_items() + self.update_inward_order_scrap_items() + self.remove_reference_for_additional_items() + self.update_inward_order_status() + + def validate_purpose(self): + if self.subcontracting_inward_order and self.purpose not in [ + "Receive from Customer", + "Return Raw Material to Customer", + "Manufacture", + "Subcontracting Delivery", + "Subcontracting Return", + "Material Transfer for Manufacture", + ]: + self.subcontracting_inward_order = None + + def validate_inward_order(self): + if self.subcontracting_inward_order: + match self.purpose: + case "Receive from Customer": + self.validate_material_receipt() + case purpose if purpose in ["Return Raw Material to Customer", "Subcontracting Return"]: + self.validate_returns() + case "Manufacture": + self.validate_manufacture() + + def validate_material_receipt(self): + for item in self.items: + if ( + item.scio_detail + and frappe.get_cached_value( + "Subcontracting Inward Order Received Item", item.scio_detail, "rm_item_code" + ) + != item.item_code + ): + frappe.throw( + _( + "Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead." + ).format(item.idx, bold(item.item_code)) + ) + + def validate_returns(self): + for item in self.items: + if not item.scio_detail: + frappe.throw( + _("Row #{0}: Item {1} is not a part of Subcontracting Inward Order {2}").format( + item.idx, + bold(item.item_code), + bold(self.subcontracting_inward_order), + ) + ) + elif item.item_code != ( + frappe.get_cached_value( + "Subcontracting Inward Order Received Item", item.scio_detail, "rm_item_code" + ) + or frappe.get_cached_value("Subcontracting Inward Order Item", item.scio_detail, "item_code") + ): + frappe.throw( + _("Row #{0}: Item {1} mismatch. Changing of item code is not permitted.").format( + item.idx, bold(item.item_code) + ) + ) + + if self.purpose == "Return Raw Material to Customer": + data = frappe.get_value( + "Subcontracting Inward Order Received Item", + item.scio_detail, + ["received_qty", "returned_qty", "work_order_qty"], + as_dict=True, + ) + if data.returned_qty + item.transfer_qty > data.received_qty - data.work_order_qty: + frappe.throw( + _( + "Row #{0}: Returned quantity cannot be greater than available quantity for Item {1}" + ).format(item.idx, bold(item.item_code)) + ) + else: + data = frappe.get_value( + "Subcontracting Inward Order Item", + item.scio_detail, + ["returned_qty", "delivered_qty"], + as_dict=True, + ) + if item.transfer_qty > data.delivered_qty - data.returned_qty: + frappe.throw( + _( + "Row #{0}: Returned quantity cannot be greater than available quantity to return for Item {1}" + ).format(item.idx, bold(item.item_code)) + ) + + def validate_manufacture(self): + skip_transfer, customer_warehouse, wip_warehouse = frappe.get_cached_value( + "Work Order", + self.work_order, + ["skip_transfer", "source_warehouse", "wip_warehouse"], + ) + warehouse = customer_warehouse if skip_transfer else wip_warehouse + + items = [ + item + for item in self.get("items") + if not item.is_finished_item + and not item.is_scrap_item + and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") + ] + + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + query = ( + frappe.qb.from_(table) + .select( + table.rm_item_code, + (table.received_qty - table.returned_qty).as_("total_qty"), + table.consumed_qty, + table.name, + ) + .where( + (table.docstatus == 1) + & (table.parent == self.subcontracting_inward_order) + & (table.main_item_code == frappe.get_cached_value("BOM", self.bom_no, "item")) + & (table.warehouse == warehouse) + & (table.rm_item_code.isin([item.item_code for item in items])) + ) + ) + rm_item_dict = frappe._dict( + { + d.rm_item_code: frappe._dict( + {"name": d.name, "total_qty": d.total_qty, "qty": d.consumed_qty} + ) + for d in query.run(as_dict=True) + } + ) + + for item in items: + if rm := rm_item_dict.get(item.item_code): + if rm.qty + item.transfer_qty > rm.total_qty: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} exceeds quantity available through Subcontracting Inward Order" + ).format(item.idx, bold(item.item_code), item.transfer_qty) + ) + elif item.s_warehouse != warehouse: + frappe.throw( + _("Row #{0}: For Customer Provided Item {1}, Source Warehouse must be {2}").format( + item.idx, + bold(item.item_code), + bold(warehouse), + ) + ) + else: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} is not a part of Subcontracting Inward Order {2}" + ).format( + item.idx, + bold(item.item_code), + bold(self.subcontracting_inward_order), + ) + ) + + def validate_customer_provided_item_for_inward(self): + if self.subcontracting_inward_order: + if self.purpose in ["Subcontracting Delivery", "Subcontracting Return"]: + for item in self.items: + if (item.is_finished_item or item.is_scrap_item) and item.valuation_rate == 0: + item.allow_zero_valuation_rate = 1 + elif self.purpose == "Receive from Customer": + for item in self.items: + if not frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item"): + frappe.throw( + _("Row #{0}: Item {1} is not a customer provided item.").format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + + def validate_warehouse_(self): + if self.subcontracting_inward_order and self.purpose in [ + "Receive from Customer", + "Return Raw Material to Customer", + "Material Transfer for Manufacture", + ]: + customer_warehouse = frappe.get_cached_value( + "Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse" + ) + for item in self.items: + if self.purpose == "Material Transfer for Manufacture" and not frappe.get_cached_value( + "Item", item.item_code, "is_customer_provided_item" + ): + continue + + if (item.s_warehouse or item.t_warehouse) != customer_warehouse: + if item.t_warehouse: + frappe.throw( + _( + "Row #{0}: Target Warehouse must be same as Customer Warehouse {1} from the linked Subcontracting Inward Order" + ).format(item.idx, bold(customer_warehouse)) + ) + else: + frappe.throw( + _( + "Row #{0}: Source Warehouse must be same as Customer Warehouse {1} from the linked Subcontracting Inward Order" + ).format(item.idx, bold(customer_warehouse)) + ) + + def validate_serial_batch_for_return_or_delivery(self): + if self.subcontracting_inward_order and self.purpose in [ + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", + ]: + for item in self.items: + serial_nos, batch_nos = self.get_serial_nos_and_batches_from_sres( + item.scio_detail, only_pending=self.purpose != "Subcontracting Return" + ) + serial_list, batch_list = get_serial_batch_list_from_item(item) + + if serial_list and ( + incorrect_serial_nos := [sn for sn in serial_list if sn not in serial_nos] + ): + frappe.throw( + _( + "Row #{0}: Serial No(s) {1} are not a part of the linked Subcontracting Inward Order. Please select valid Serial No(s)." + ).format(item.idx, ", ".join([bold(sn) for sn in incorrect_serial_nos])) + ) + if batch_list and ( + incorrect_batch_nos := [bn for bn in batch_list if bn not in list(batch_nos.keys())] + ): + frappe.throw( + _( + "Row #{0}: Batch No(s) {1} is not a part of the linked Subcontracting Inward Order. Please select valid Batch No(s)." + ).format(item.idx, ", ".join([bold(bn) for bn in incorrect_batch_nos])) + ) + + def get_serial_nos_and_batches_from_sres(self, scio_detail, only_pending=True): + serial_nos, batch_nos = [], frappe._dict() + + table = frappe.qb.DocType("Stock Reservation Entry") + child_table = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select(child_table.serial_no, child_table.batch_no, child_table.qty) + .where((table.docstatus == 1) & (table.voucher_detail_no == scio_detail)) + ) + + if only_pending: + query = query.where(child_table.qty != child_table.delivered_qty) + else: + query = query.where(child_table.delivered_qty > 0) + + for d in query.run(as_dict=True): + if d.serial_no and d.serial_no not in serial_nos: + serial_nos.append(d.serial_no) + if d.batch_no and d.batch_no not in batch_nos: + batch_nos[d.batch_no] = d.qty + + return serial_nos, batch_nos + + def validate_delivery(self): + if self.purpose == "Subcontracting Delivery": + if self._action in ["save", "submit"]: + self.validate_delivery_on_save() + else: + for item in self.items: + if not item.is_scrap_item: + delivered_qty, returned_qty = frappe.get_value( + "Subcontracting Inward Order Item", + item.scio_detail, + ["delivered_qty", "returned_qty"], + ) + if returned_qty > delivered_qty: + frappe.throw( + _( + "Row #{0}: Cannot cancel this Stock Entry as returned quantity cannot be greater than delivered quantity for Item {1} in the linked Subcontracting Inward Order" + ).format(item.idx, bold(item.item_code)) + ) + + def validate_delivery_on_save(self): + allow_delivery_of_overproduced_qty = frappe.get_single_value( + "Selling Settings", "allow_delivery_of_overproduced_qty" + ) + + for item in self.items: + if not item.scio_detail: + frappe.throw( + _("Row #{0}: Item {1} is not a part of Subcontracting Inward Order {2}").format( + item.idx, + bold(item.item_code), + bold(self.subcontracting_inward_order), + ) + ) + + from pypika.terms import ValueWrapper + + table = frappe.qb.DocType("Subcontracting Inward Order Item") + query = ( + frappe.qb.from_(table) + .select( + ( + Case() + .when( + (table.produced_qty < table.qty) + | ValueWrapper(allow_delivery_of_overproduced_qty), + table.produced_qty, + ) + .else_(table.qty) + - table.delivered_qty + - table.returned_qty + ).as_("max_allowed_qty") + ) + .where((table.name == item.scio_detail) & (table.docstatus == 1)) + ) + max_allowed_qty = query.run(pluck="max_allowed_qty") + + if max_allowed_qty: + max_allowed_qty = max_allowed_qty[0] + else: + table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item") + query = ( + frappe.qb.from_(table) + .select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty")) + .where((table.name == item.scio_detail) & (table.docstatus == 1)) + ) + max_allowed_qty = query.run(pluck="max_allowed_qty")[0] + + if item.transfer_qty > max_allowed_qty: + frappe.throw( + _( + "Row #{0}: Quantity of Item {1} cannot be more than {2} {3} against Subcontracting Inward Order {4}" + ).format( + item.idx, + bold(item.item_code), + bold(max_allowed_qty), + bold( + frappe.get_cached_value( + "Subcontracting Inward Order Item" + if not item.is_scrap_item + else "Subcontracting Inward Order Scrap Item", + item.scio_detail, + "stock_uom", + ) + ), + bold(self.subcontracting_inward_order), + ) + ) + + def update_customer_provided_item_cost(self): + if self.purpose == "Receive from Customer": + for item in self.items: + item.valuation_rate = 0 + item.customer_provided_item_cost = flt( + item.basic_rate + (item.additional_cost / item.transfer_qty), item.precision("basic_rate") + ) + + def update_sre_for_subcontracting_delivery(self) -> None: + if self.purpose == "Subcontracting Delivery": + if self._action == "submit": + self.update_sre_for_subcontracting_delivery_submit() + elif self._action == "cancel": + self.update_sre_for_subcontracting_delivery_cancel() + + def update_sre_for_subcontracting_delivery_submit(self): + for item in self.get("items"): + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.voucher_type == "Subcontracting Inward Order") + & (table.voucher_no == self.subcontracting_inward_order) + & (table.voucher_detail_no == item.scio_detail) + & (table.warehouse == item.s_warehouse) + ) + .orderby(table.creation) + ) + sre_list = query.run(pluck="name") + + if not sre_list: + continue + + qty_to_deliver = item.transfer_qty + for sre in sre_list: + if qty_to_deliver <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + qty_can_be_deliver = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + delivered_serial_nos = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in delivered_serial_nos: + entry.delivered_qty = 1 + entry.db_update() + qty_can_be_deliver += 1 + delivered_serial_nos.remove(entry.serial_no) + else: + delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in delivered_batch_qty: + delivered_qty = min( + (entry.qty - entry.delivered_qty), + delivered_batch_qty[entry.batch_no], + ) + entry.delivered_qty += delivered_qty + entry.db_update() + qty_can_be_deliver += delivered_qty + delivered_batch_qty[entry.batch_no] -= delivered_qty + else: + qty_can_be_deliver = min((sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver) + + sre_doc.delivered_qty += qty_can_be_deliver + sre_doc.db_update() + sre_doc.update_status() + sre_doc.update_reserved_stock_in_bin() + + qty_to_deliver -= qty_can_be_deliver + + def update_sre_for_subcontracting_delivery_cancel(self): + for item in self.get("items"): + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.voucher_type == "Subcontracting Inward Order") + & (table.voucher_no == self.subcontracting_inward_order) + & (table.voucher_detail_no == item.scio_detail) + & (table.warehouse == item.s_warehouse) + ) + .orderby(table.creation) + ) + sre_list = query.run(pluck="name") + + if not sre_list: + continue + + qty_to_undelivered = item.transfer_qty + for sre in sre_list: + if qty_to_undelivered <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + qty_can_be_undelivered = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + serial_nos_to_undelivered = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in serial_nos_to_undelivered: + entry.delivered_qty = 0 + entry.db_update() + qty_can_be_undelivered += 1 + serial_nos_to_undelivered.remove(entry.serial_no) + else: + batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in batch_qty_to_undelivered: + undelivered_qty = min( + entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no] + ) + entry.delivered_qty -= undelivered_qty + entry.db_update() + qty_can_be_undelivered += undelivered_qty + batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty + else: + qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered) + + sre_doc.delivered_qty -= qty_can_be_undelivered + sre_doc.db_update() + sre_doc.update_status() + sre_doc.update_reserved_stock_in_bin() + + qty_to_undelivered -= qty_can_be_undelivered + + def validate_receive_from_customer_cancel(self): + if self.purpose == "Receive from Customer": + for item in self.items: + scio_rm_item = frappe.get_value( + "Subcontracting Inward Order Received Item", + item.scio_detail, + ["received_qty", "returned_qty", "work_order_qty"], + as_dict=True, + ) + if ( + scio_rm_item.received_qty - scio_rm_item.returned_qty - item.transfer_qty + ) < scio_rm_item.work_order_qty: + frappe.throw( + _("Row #{0}: Work Order exists against full or partial quantity of Item {1}").format( + item.idx, bold(item.item_code) + ) + ) + + def validate_manufacture_entry_cancel(self): + if self.subcontracting_inward_order and self.purpose == "Manufacture": + fg_item_name = frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ) + produced_qty, delivered_qty = frappe.get_value( + "Subcontracting Inward Order Item", fg_item_name, ["produced_qty", "delivered_qty"] + ) + if produced_qty < delivered_qty: + frappe.throw( + _( + "Cannot cancel this Manufacturing Stock Entry as quantity of Finished Good produced cannot be less than quantity delivered in the linked Subcontracting Inward Order." + ) + ) + + for item in [item for item in self.items if not item.is_finished_item]: + if item.is_scrap_item: + scio_scrap_item = frappe.get_value( + "Subcontracting Inward Order Scrap Item", + { + "docstatus": 1, + "item_code": item.item_code, + "warehouse": item.t_warehouse, + "reference_name": fg_item_name, + }, + ["produced_qty", "delivered_qty"], + as_dict=True, + ) + if ( + scio_scrap_item + and scio_scrap_item.delivered_qty > scio_scrap_item.produced_qty - item.transfer_qty + ): + frappe.throw( + _( + "Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Scrap Item {1} produced cannot be less than quantity delivered." + ).format(item.idx, bold(item.item_code)) + ) + else: + scio_rm_item = frappe.get_value( + "Subcontracting Inward Order Received Item", + { + "docstatus": 1, + "rm_item_code": item.item_code, + "warehouse": item.s_warehouse, + "reference_name": fg_item_name, # if this field is set then the additional item is NOT customer provided + "is_additional_item": 1, + }, + ["consumed_qty", "billed_qty", "returned_qty"], + as_dict=True, + ) + if scio_rm_item and (scio_rm_item.billed_qty - scio_rm_item.returned_qty) > ( + scio_rm_item.consumed_qty - item.transfer_qty + ): + frappe.throw( + _( + "Row #{0}: Cannot cancel this Manufacturing Stock Entry as billed quantity of Item {1} cannot be greater than consumed quantity." + ).format(item.idx, bold(item.item_code)) + ) + + def update_inward_order_item(self): + if self.purpose == "Manufacture" and ( + scio_item_name := frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ) + ): + if scio_item_name: + frappe.get_doc( + "Subcontracting Inward Order Item", scio_item_name + ).update_manufacturing_qty_fields() + elif self.purpose in ["Subcontracting Delivery", "Subcontracting Return"]: + fieldname = "delivered_qty" if self.purpose == "Subcontracting Delivery" else "returned_qty" + for item in self.items: + doctype = ( + "Subcontracting Inward Order Item" + if not item.is_scrap_item + else "Subcontracting Inward Order Scrap Item" + ) + frappe.db.set_value( + doctype, + item.scio_detail, + fieldname, + frappe.get_value(doctype, item.scio_detail, fieldname) + + (item.transfer_qty if self._action == "submit" else -item.transfer_qty), + ) + + def update_inward_order_received_items(self): + if self.subcontracting_inward_order: + match self.purpose: + case "Receive from Customer": + self.update_inward_order_received_items_for_raw_materials_receipt() + case "Manufacture": + self.update_inward_order_received_items_for_manufacture() + case "Return Raw Material to Customer": + scio_rm_names = { + item.scio_detail: item.transfer_qty + if self._action == "submit" + else -item.transfer_qty + for item in self.items + } + case_expr = Case() + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + for scio_rm_name, qty in scio_rm_names.items(): + case_expr = case_expr.when(table.name == scio_rm_name, table.returned_qty + qty) + + frappe.qb.update(table).set(table.returned_qty, case_expr).where( + (table.name.isin(list(scio_rm_names.keys()))) & (table.docstatus == 1) + ).run() + + def update_inward_order_received_items_for_raw_materials_receipt(self): + data = frappe._dict() + for item in self.items: + if item.scio_detail: + data[item.scio_detail] = item.transfer_qty if self._action == "submit" else -item.transfer_qty + else: + scio_rm = frappe.new_doc( + "Subcontracting Inward Order Received Item", + parent=self.subcontracting_inward_order, + parenttype="Subcontracting Inward Order", + parentfield="received_items", + idx=frappe.db.count( + "Subcontracting Inward Order Received Item", + {"parent": self.subcontracting_inward_order}, + ) + + 1, + rm_item_code=item.item_code, + stock_uom=item.stock_uom, + warehouse=item.t_warehouse, + received_qty=item.transfer_qty, + consumed_qty=0, + work_order_qty=0, + returned_qty=0, + is_additional_item=True, + ) + scio_rm.insert() + item.db_set("scio_detail", scio_rm.name) + + if data: + result = frappe.get_all( + "Subcontracting Inward Order Received Item", + filters={ + "parent": self.subcontracting_inward_order, + "name": ["in", list(data.keys())], + "docstatus": 1, + }, + fields=["name", "required_qty", "received_qty"], + ) + + deleted_docs = [] + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + case_expr = Case() + for d in result: + d.received_qty += data[d.name] + + if not d.required_qty and not d.received_qty: + deleted_docs.append(d.name) + frappe.delete_doc("Subcontracting Inward Order Received Item", d.name) + else: + case_expr = case_expr.when(table.name == d.name, d.received_qty) + + if len(list(set(data.keys()) - set(deleted_docs))) > 0: + frappe.qb.update(table).set(table.received_qty, case_expr).where( + (table.name.isin(list(set(data.keys()) - set(deleted_docs)))) & (table.docstatus == 1) + ).run() + + def update_inward_order_received_items_for_manufacture(self): + items = [item for item in self.items if not item.is_finished_item and not item.is_scrap_item] + item_code_wh = frappe._dict( + { + (item.item_code, item.s_warehouse): item.transfer_qty + if self._action == "submit" + else -item.transfer_qty + for item in items + } + ) + item_codes, warehouses = zip(*list(item_code_wh.keys()), strict=True) + item_codes = list(item_codes) + warehouses = list(warehouses) + + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + data = ( + frappe.qb.from_(table) + .select( + table.name, + table.rm_item_code, + table.is_customer_provided_item, + table.consumed_qty, + table.required_qty, + table.warehouse, + ) + .where( + (table.docstatus == 1) + & (table.rm_item_code.isin(item_codes)) + & ((table.warehouse.isin(warehouses)) | (table.warehouse.isnull())) + & (table.parent == self.subcontracting_inward_order) + & ( + ( + table.reference_name + == frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ) + ) + | (table.reference_name.isnull()) + ) + ) + ) + + if data := data.run(as_dict=True): + deleted_docs, used_item_wh = [], [] + case_expr = Case() + for d in data: + if not d.warehouse: + d.warehouse = next( + key[1] + for key in item_code_wh.keys() + if key[0] == d.rm_item_code and key not in used_item_wh + ) + used_item_wh.append((d.rm_item_code, d.warehouse)) + + qty = d.consumed_qty + item_code_wh[(d.rm_item_code, d.warehouse)] + if qty or d.is_customer_provided_item: + case_expr = case_expr.when((table.name == d.name), qty) + else: + deleted_docs.append(d.name) + frappe.delete_doc("Subcontracting Inward Order Received Item", d.name) + + final_name_list = list(set([d.name for d in data]) - set(deleted_docs)) + if len(final_name_list) > 0: + frappe.qb.update(table).set(table.consumed_qty, case_expr).where( + (table.name.isin(final_name_list)) & (table.docstatus == 1) + ).run() + + main_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code + for extra_item in [ + item + for item in items + if (item.item_code, item.s_warehouse) not in [(d.rm_item_code, d.warehouse) for d in data] + ]: + frappe.new_doc( + "Subcontracting Inward Order Received Item", + parent=self.subcontracting_inward_order, + parenttype="Subcontracting Inward Order", + parentfield="received_items", + idx=frappe.db.count( + "Subcontracting Inward Order Received Item", + {"parent": self.subcontracting_inward_order}, + ) + + 1, + main_item_code=main_item_code, + rm_item_code=extra_item.item_code, + stock_uom=extra_item.stock_uom, + reference_name=frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ), + required_qty=0, + consumed_qty=extra_item.transfer_qty, + warehouse=extra_item.s_warehouse, + is_additional_item=True, + ).insert() + + def update_inward_order_scrap_items(self): + if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture": + scrap_items_list = [item for item in self.items if item.is_scrap_item] + scrap_items = frappe._dict( + { + (item.item_code, item.t_warehouse): item.transfer_qty + if self._action == "submit" + else -item.transfer_qty + for item in scrap_items_list + } + ) + if scrap_items: + item_codes, warehouses = zip(*list(scrap_items.keys()), strict=True) + item_codes = list(item_codes) + warehouses = list(warehouses) + + result = frappe.get_all( + "Subcontracting Inward Order Scrap Item", + filters={ + "item_code": ["in", item_codes], + "warehouse": ["in", warehouses], + "reference_name": frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ), + "docstatus": 1, + }, + fields=["name", "item_code", "warehouse", "produced_qty"], + ) + + if result: + scrap_item_dict = frappe._dict( + { + (d.item_code, d.warehouse): frappe._dict( + {"name": d.name, "produced_qty": d.produced_qty} + ) + for d in result + } + ) + deleted_docs = [] + case_expr = Case() + table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item") + for key, value in scrap_item_dict.items(): + if self._action == "cancel" and value.produced_qty - abs(scrap_items.get(key)) == 0: + deleted_docs.append(value.name) + frappe.delete_doc("Subcontracting Inward Order Scrap Item", value.name) + else: + case_expr = case_expr.when( + table.name == value.name, value.produced_qty + scrap_items.get(key) + ) + + final_list = list(set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs)) + if len(final_list) > 0: + frappe.qb.update(table).set(table.produced_qty, case_expr).where( + (table.name.isin(final_list)) & (table.docstatus == 1) + ).run() + + fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code + for scrap_item in [ + item + for item in scrap_items_list + if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result] + ]: + frappe.new_doc( + "Subcontracting Inward Order Scrap Item", + parent=scio, + parenttype="Subcontracting Inward Order", + parentfield="scrap_items", + idx=frappe.db.count("Subcontracting Inward Order Scrap Item", {"parent": scio}) + 1, + item_code=scrap_item.item_code, + fg_item_code=fg_item_code, + stock_uom=scrap_item.stock_uom, + warehouse=scrap_item.t_warehouse, + produced_qty=scrap_item.transfer_qty, + delivered_qty=0, + reference_name=frappe.get_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ), + ).insert() + + def cancel_stock_reservation_entries_for_inward(self): + if self.purpose == "Receive from Customer": + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.voucher_detail_no.isin([item.scio_detail for item in self.items])) + ) + ) + for sre in query.run(pluck="name"): + frappe.get_doc("Stock Reservation Entry", sre).cancel() + + def remove_reference_for_additional_items(self): + if self.subcontracting_inward_order: + items = [ + item + for item in self.items + if item.scio_detail + and ( + not frappe.db.exists("Subcontracting Inward Order Received Item", item.scio_detail) + and not frappe.db.exists("Subcontracting Inward Order Item", item.scio_detail) + and not frappe.db.exists("Subcontracting Inward Order Scrap Item", item.scio_detail) + ) + ] + for item in items: + item.db_set("scio_detail", None) + + def create_stock_reservation_entries_for_inward(self): + if self.purpose == "Receive from Customer": + for item in self.items: + item.reload() + sre = frappe.new_doc("Stock Reservation Entry") + sre.company = self.company + sre.voucher_type = "Subcontracting Inward Order" + sre.voucher_qty = sre.reserved_qty = sre.available_qty = item.transfer_qty + sre.voucher_no = self.subcontracting_inward_order + sre.voucher_detail_no = item.scio_detail + sre.item_code = item.item_code + sre.stock_uom = item.stock_uom + sre.warehouse = item.t_warehouse or item.s_warehouse + sre.has_serial_no = frappe.get_cached_value("Item", item.item_code, "has_serial_no") + sre.has_batch_no = frappe.get_cached_value("Item", item.item_code, "has_batch_no") + sre.reservation_based_on = "Qty" if not item.serial_and_batch_bundle else "Serial and Batch" + if item.serial_and_batch_bundle: + sabb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + for entry in sabb.entries: + sre.append( + "sb_entries", + { + "serial_no": entry.serial_no, + "batch_no": entry.batch_no, + "qty": entry.qty, + "warehouse": entry.warehouse, + }, + ) + sre.submit() + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + + def adjust_stock_reservation_entries_for_return(self): + if self.purpose == "Return Raw Material to Customer": + for item in self.items: + serial_list, batch_list = get_serial_batch_list_from_item(item) + + if serial_list or batch_list: + table = frappe.qb.DocType("Stock Reservation Entry") + child_table = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select( + table.name.as_("sre_name"), + child_table.name.as_("sbe_name"), + child_table.batch_no, + child_table.qty, + ) + .where((table.docstatus == 1) & (table.voucher_detail_no == item.scio_detail)) + ) + if serial_list: + query = query.where(child_table.serial_no.isin(serial_list)) + if batch_list: + query = query.where(child_table.batch_no.isin(batch_list)) + result = query.run(as_dict=True) + + qty_to_deliver = {row.sre_name: 0 for row in result} + consumed_qty = {batch: 0 for batch in batch_list} + for row in result: + if serial_list: + frappe.get_doc("Serial and Batch Entry", row.sbe_name).db_set( + "delivered_qty", 1 if self._action == "submit" else 0 + ) + qty_to_deliver[row.sre_name] += row.qty + elif batch_list and not serial_list: + sabe_qty = abs( + frappe.get_value( + "Serial and Batch Entry", + {"parent": item.serial_and_batch_bundle, "batch_no": row.batch_no}, + "qty", + ) + ) + + qty = min(row.qty, sabe_qty) + sbe_doc = frappe.get_doc("Serial and Batch Entry", row.sbe_name) + sbe_doc.db_set( + "delivered_qty", + sbe_doc.delivered_qty + (qty if self._action == "submit" else -qty), + ) + qty_to_deliver[row.sre_name] += qty + consumed_qty[row.batch_no] += qty + + for sre_name, qty in qty_to_deliver.items(): + sre_doc = frappe.get_doc("Stock Reservation Entry", sre_name) + sre_doc.db_set( + "delivered_qty", + sre_doc.delivered_qty + (qty if self._action == "submit" else -qty), + ) + sre_doc.update_status() + sre_doc.update_reserved_stock_in_bin() + else: + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select( + table.name, + (table.reserved_qty - table.delivered_qty).as_("qty"), + ) + .where( + (table.docstatus == 1) + & (table.voucher_detail_no == item.scio_detail) + & (table.delivered_qty < table.reserved_qty) + ) + .orderby(table.creation) + ) + sre_list = query.run(as_dict=True) + + voucher_qty = item.transfer_qty + for sre in sre_list: + qty = min(sre.qty, voucher_qty) + sre_doc = frappe.get_doc("Stock Reservation Entry", sre.name) + sre_doc.db_set( + "delivered_qty", + sre_doc.delivered_qty + (qty if self._action == "submit" else -qty), + ) + sre_doc.update_status() + sre_doc.update_reserved_stock_in_bin() + voucher_qty -= qty + if voucher_qty <= 0: + break + + def update_inward_order_status(self): + if self.subcontracting_inward_order: + from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import ( + update_subcontracting_inward_order_status, + ) + + update_subcontracting_inward_order_status(self.subcontracting_inward_order) diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index 94ec192779eed924f386de8bdd8c6d0a1272ab39..b3e06d4db6cae664104bb6a49529c017f128cd69 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -72,7 +72,7 @@ class TestSubcontractingController(IntegrationTestCase): def test_create_raw_materials_supplied(self): sco = get_subcontracting_order() sco.supplied_items = None - sco.create_raw_materials_supplied() + sco.create_raw_materials_supplied_or_received() self.assertIsNotNone(sco.supplied_items) def test_sco_with_bom(self): diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index f9e9250b8b6e89148b2362ec3f6a732a55f87399..f4e2b00ea57f8a138fba0777a45ca9236494458d 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -220,9 +220,9 @@ class BOM(WebsiteGenerator): def onload(self): super().onload() - self.set_onload_for_muulti_level_bom() + self.set_onload_for_multi_level_bom() - def set_onload_for_muulti_level_bom(self): + def set_onload_for_multi_level_bom(self): use_multi_level_bom = frappe.db.get_value( "Property Setter", {"field_name": "use_multi_level_bom", "doc_type": "Work Order", "property": "default"}, diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index cc7387acd289ac4851d36f1fd4e40f69a56734c7..2803c7a8021d7dd060dd9877cf080cee8d2d2d32 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -938,7 +938,7 @@ class ProductionPlan(Document): material_request_type = item.material_request_type or item_doc.default_material_request_type # key for Sales Order:Material Request Type:Customer - key = "{}:{}:{}".format(item.sales_order, material_request_type, item_doc.customer or "") + key = "{}:{}:{}".format(item.sales_order, material_request_type, "") schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days)) if key not in material_request_map: @@ -951,7 +951,6 @@ class ProductionPlan(Document): "status": "Draft", "company": self.company, "material_request_type": material_request_type, - "customer": item_doc.customer or "", } ) material_request_list.append(material_request) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 7fd7127a01ed782f2224f3b5e7dc5da34622a590..e7fe26c0ce54d8538fb28de95cf0f7f042aebc83 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -692,7 +692,6 @@ class TestProductionPlan(IntegrationTestCase): mr = frappe.get_doc("Material Request", material_request) self.assertTrue(mr.material_request_type, "Customer Provided") - self.assertTrue(mr.customer, "_Test Customer") def test_production_plan_with_multi_level_bom(self): """ @@ -2524,4 +2523,7 @@ def make_bom(**args): if not args.do_not_submit: bom.submit() + if args.set_as_default_bom and not args.do_not_save and not args.do_not_submit: + frappe.set_value("Item", args.item, "default_bom", bom.name) + return bom diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 77ef6012435efa0f90024ff37dd720d1404c1431..7ccbccaa8781a48fe2d22e11553ece40f52ae857 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -15,6 +15,8 @@ "image", "bom_no", "mps", + "subcontracting_inward_order", + "subcontracting_inward_order_item", "sales_order", "column_break1", "company", @@ -23,6 +25,7 @@ "track_semi_finished_goods", "reserve_stock", "column_break_agjv", + "max_producible_qty", "material_transferred_for_manufacturing", "additional_transferred_qty", "produced_qty", @@ -154,6 +157,7 @@ }, { "default": "0", + "depends_on": "eval:!doc.subcontracting_inward_order", "fieldname": "allow_alternative_item", "fieldtype": "Check", "label": "Allow Alternative Item" @@ -164,7 +168,8 @@ "fieldname": "use_multi_level_bom", "fieldtype": "Check", "label": "Use Multi-Level BOM", - "print_hide": 1 + "print_hide": 1, + "read_only_depends_on": "eval:doc.subcontracting_inward_order" }, { "default": "0", @@ -219,6 +224,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.subcontracting_inward_order", "fieldname": "sales_order", "fieldtype": "Link", "in_global_search": 1, @@ -235,7 +241,7 @@ }, { "default": "0", - "depends_on": "skip_transfer", + "depends_on": "eval:doc.skip_transfer && !doc.subcontracting_inward_order", "fieldname": "from_wip_warehouse", "fieldtype": "Check", "label": "Backflush Raw Materials From Work-in-Progress Warehouse" @@ -247,6 +253,7 @@ "options": "fa fa-building" }, { + "depends_on": "eval:!(doc.skip_transfer && doc.subcontracting_inward_order)", "description": "This is a location where operations are executed.", "fieldname": "wip_warehouse", "fieldtype": "Link", @@ -259,7 +266,8 @@ "fieldname": "fg_warehouse", "fieldtype": "Link", "label": "Target Warehouse", - "options": "Warehouse" + "options": "Warehouse", + "read_only_depends_on": "subcontracting_inward_order" }, { "fieldname": "column_break_12", @@ -418,6 +426,7 @@ "width": "50%" }, { + "depends_on": "eval:!doc.subcontracting_inward_order", "description": "Manufacture against Material Request", "fieldname": "material_request", "fieldtype": "Link", @@ -495,7 +504,8 @@ "fieldname": "source_warehouse", "fieldtype": "Link", "label": "Source Warehouse", - "options": "Warehouse" + "options": "Warehouse", + "read_only_depends_on": "eval:doc.subcontracting_inward_order" }, { "description": "In Mins", @@ -595,7 +605,8 @@ "default": "0", "fieldname": "reserve_stock", "fieldtype": "Check", - "label": " Reserve Stock" + "label": "Reserve Stock", + "read_only_depends_on": "subcontracting_inward_order" }, { "depends_on": "eval:doc.docstatus==1", @@ -622,6 +633,32 @@ "label": "Additional Transferred Qty", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "subcontracting_inward_order", + "fieldname": "subcontracting_inward_order", + "fieldtype": "Link", + "label": "Subcontracting Inward Order", + "options": "Subcontracting Inward Order", + "read_only": 1 + }, + { + "fieldname": "subcontracting_inward_order_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Subcontracting Inward Order Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "max_producible_qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Max Producible Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 } ], "grid_page_length": 50, @@ -630,7 +667,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2025-09-29 15:57:47.022616", + "modified": "2025-10-12 14:24:57.699749", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 37a9111a8f238942289c3d0edccf3ac50e616019..85b573cfca72ca009e681fd27969f6b826b7f02c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -102,6 +102,7 @@ class WorkOrder(Document): material_request: DF.Link | None material_request_item: DF.Data | None material_transferred_for_manufacturing: DF.Float + max_producible_qty: DF.Float mps: DF.Link | None naming_series: DF.Literal["MFG-WO-.YYYY.-"] operations: DF.Table[WorkOrderOperation] @@ -138,6 +139,8 @@ class WorkOrder(Document): "Cancelled", ] stock_uom: DF.Link | None + subcontracting_inward_order: DF.Link | None + subcontracting_inward_order_item: DF.Data | None total_operating_cost: DF.Currency track_semi_finished_goods: DF.Check transfer_material_against: DF.Literal["", "Work Order", "Job Card"] @@ -180,7 +183,11 @@ class WorkOrder(Document): if self.bom_no: validate_bom_no(self.production_item, self.bom_no) - self.validate_sales_order() + if not self.subcontracting_inward_order: + self.validate_sales_order() + else: + self.validate_self_rm_warehouse() + self.set_default_warehouse() self.validate_warehouse_belongs_to_company() self.check_wip_warehouse_skip() @@ -203,6 +210,7 @@ class WorkOrder(Document): self.set_required_items(reset_only_qty=len(self.get("required_items"))) self.enable_auto_reserve_stock() self.validate_operations_sequence() + self.validate_subcontracting_inward_order() def validate_dates(self): if self.actual_start_date and self.actual_end_date: @@ -210,7 +218,7 @@ class WorkOrder(Document): frappe.throw(_("Actual End Date cannot be before Actual Start Date")) def validate_fg_warehouse_for_reservation(self): - if self.reserve_stock and self.sales_order: + if self.reserve_stock and self.sales_order and not self.subcontracting_inward_order: warehouses = frappe.get_all( "Sales Order Item", filters={"parent": self.sales_order, "item_code": self.production_item}, @@ -257,6 +265,24 @@ class WorkOrder(Document): ) sequence_id = op.sequence_id + def validate_subcontracting_inward_order(self): + if scio := self.subcontracting_inward_order: + if self.source_warehouse != ( + rm_receipt_warehouse := frappe.get_cached_value( + "Subcontracting Inward Order", + scio, + "customer_warehouse", + ) + ): + frappe.throw( + _( + "Source Warehouse {0} must be same as Customer Warehouse {1} in the Subcontracting Inward Order" + ).format( + frappe.bold(self.source_warehouse), + frappe.bold(rm_receipt_warehouse), + ) + ) + def set_warehouses(self): for row in self.required_items: if not row.source_warehouse: @@ -330,6 +356,15 @@ class WorkOrder(Document): else: frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order)) + def validate_self_rm_warehouse(self): + for item in [item for item in self.required_items if not item.is_customer_provided_item]: + if frappe.get_cached_value("Warehouse", item.source_warehouse, "customer"): + frappe.throw( + _("Row #{0}: Source Warehouse {1} for item {2} cannot be a customer warehouse.").format( + item.idx, frappe.bold(item.source_warehouse), frappe.bold(item.item_code) + ) + ) + def check_sales_order_on_hold_or_close(self): status = frappe.db.get_value("Sales Order", self.sales_order, "status") if status in ("Closed", "On Hold"): @@ -636,6 +671,8 @@ class WorkOrder(Document): if self.reserve_stock: self.update_stock_reservation() + self.update_subcontracting_inward_order_received_items() + def on_cancel(self): self.validate_cancel() self.db_set("status", "Cancelled") @@ -657,10 +694,68 @@ class WorkOrder(Document): if self.reserve_stock: self.update_stock_reservation() + self.update_subcontracting_inward_order_received_items() + def update_stock_reservation(self): + self.set_qty_change() make_stock_reservation_entries(self) self.db_set("status", self.get_status()) + def set_qty_change(self): + if scio_item_name := self.get("subcontracting_inward_order_item"): + scio_rm_item_names = frappe.db.get_all( + "Subcontracting Inward Order Received Item", + filters={"reference_name": scio_item_name, "docstatus": 1, "is_customer_provided_item": 1}, + pluck="name", + ) + self.qty_change = frappe._dict() + + data = frappe.get_all( + "Subcontracting Inward Order Received Item", + {"name": ["in", scio_rm_item_names]}, + ["rm_item_code", "required_qty as bom_qty", "work_order_qty", "received_qty"], + ) + for d in data: + wo_item = next( + wo_item for wo_item in self.get("required_items") if wo_item.item_code == d.rm_item_code + ) + + if ( + d.work_order_qty + (wo_item.required_qty if self._action == "submit" else 0) + ) == d.bom_qty and d.received_qty > d.bom_qty: + self.qty_change[wo_item.name] = d.received_qty - d.bom_qty + + def update_subcontracting_inward_order_received_items(self): + if scio_item_name := self.get("subcontracting_inward_order_item"): + scio_rm_data = frappe.get_all( + "Subcontracting Inward Order Received Item", + filters={"reference_name": scio_item_name, "docstatus": 1}, + fields=["name", "rm_item_code"], + ) + + required_qty = { + wo_item.item_code: wo_item.required_qty + for wo_item in self.get("required_items") + if wo_item.item_code in [d.rm_item_code for d in scio_rm_data] + } + + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + case_expr = Case() + for item in scio_rm_data: + case_expr = case_expr.when( + table.rm_item_code == item.rm_item_code, + table.work_order_qty + + ( + required_qty[item.rm_item_code] + if self._action == "submit" + else -required_qty[item.rm_item_code] + ), + ) + + frappe.qb.update(table).set(table.work_order_qty, case_expr).where( + (table.name.isin([d.name for d in scio_rm_data])) & (table.docstatus == 1) + ).run() + def create_serial_no_batch_no(self): if not (self.has_serial_no or self.has_batch_no): return @@ -1229,6 +1324,15 @@ class WorkOrder(Document): OverProductionError, ) + if self.subcontracting_inward_order and self.qty > self.max_producible_qty: + frappe.msgprint( + _( + "Warning: Quantity exceeds maximum producible quantity based on quantity of raw materials received through the Subcontracting Inward Order {0}." + ).format(frappe.bold(self.subcontracting_inward_order)), + alert=True, + indicator="orange", + ) + def validate_transfer_against(self): if self.docstatus != 1: # let user configure operations until they're ready to submit @@ -1330,6 +1434,11 @@ class WorkOrder(Document): }, ) + if self.subcontracting_inward_order and not frappe.get_cached_value( + "Item", item.item_code, "is_customer_provided_item" + ): + self.required_items[-1].source_warehouse = item.default_warehouse + if not self.project: self.project = item.get("project") @@ -1532,7 +1641,7 @@ class WorkOrder(Document): stock_entry.reload() if stock_entry.purpose == "Manufacture" and ( - self.sales_order or self.production_plan_sub_assembly_item + self.sales_order or self.production_plan_sub_assembly_item or self.subcontracting_inward_order ): items = self.get_finished_goods_for_reservation(stock_entry) elif stock_entry.purpose == "Material Transfer for Manufacture": @@ -1577,6 +1686,8 @@ class WorkOrder(Document): if self.production_plan_sub_assembly_item: # Reserve the sub-assembly item for the final product for the work order. item_details = self.get_wo_details() + elif self.subcontracting_inward_order: + item_details = self.get_scio_details() else: # Reserve the final product for the sales order. item_details = self.get_so_details() @@ -1665,6 +1776,25 @@ class WorkOrder(Document): return query.run(as_dict=1) + def get_scio_details(self): + return frappe.get_all( + "Subcontracting Inward Order Item", + filters={ + "name": self.subcontracting_inward_order_item, + "docstatus": 1, + }, + fields=[ + "item_code", + "name", + "qty as stock_qty", + "produced_qty as stock_reserved_qty", + "delivery_warehouse as warehouse", + "parent as voucher_no", + "parenttype as voucher_type", + "delivered_qty", + ], + ) + def get_so_details(self): return frappe.get_all( "Sales Order Item", @@ -1767,7 +1897,8 @@ class WorkOrder(Document): @frappe.whitelist() -def make_stock_reservation_entries(doc, items=None, table_name=None, is_transfer=True, notify=False): +def make_stock_reservation_entries(doc, items=None, is_transfer=True, notify=False): + is_transfer = cint(is_transfer) if isinstance(doc, str): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) @@ -1781,6 +1912,14 @@ def make_stock_reservation_entries(doc, items=None, table_name=None, is_transfer sre.transfer_reservation_entries_to( doc.production_plan, from_doctype="Production Plan", to_doctype="Work Order" ) + elif doc.subcontracting_inward_order and is_transfer: + sre.transfer_reservation_entries_to( + doc.subcontracting_inward_order, + from_doctype="Subcontracting Inward Order", + to_doctype="Work Order", + against_fg_item=doc.subcontracting_inward_order_item, + qty_change=doc.qty_change, + ) else: sre_created = sre.make_stock_reservation_entries() if sre_created: @@ -2045,6 +2184,9 @@ def make_stock_entry( qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty)) ) + if purpose == "Manufacture" and work_order.subcontracting_inward_order: + stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order + if work_order.bom_no: stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required") diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index 9e48c2c2590bdf61b472af73fbc8dc9159f65e0b..57ae526913e88f561b4b094c856f7a3aeff4a376 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -29,6 +29,7 @@ "column_break_jash", "stock_reserved_qty", "is_additional_item", + "is_customer_provided_item", "voucher_detail_reference" ], "fields": [ @@ -52,7 +53,8 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Source Warehouse", - "options": "Warehouse" + "options": "Warehouse", + "read_only_depends_on": "eval:parent.subcontracting_inward_order && doc.is_customer_provided_item" }, { "fieldname": "column_break_3", @@ -91,6 +93,7 @@ }, { "default": "0", + "depends_on": "eval:!parent.subcontracting_inward_order", "fieldname": "allow_alternative_item", "fieldtype": "Check", "label": "Allow Alternative Item" @@ -190,12 +193,20 @@ "label": "Voucher Detail Reference", "no_copy": 1, "read_only": 1 + }, + { + "default": "0", + "fetch_from": "item_code.is_customer_provided_item", + "fieldname": "is_customer_provided_item", + "fieldtype": "Check", + "label": "Is Customer Provided Item", + "read_only": 1 } ], "grid_page_length": 50, "istable": 1, "links": [], - "modified": "2025-05-12 17:36:00.115181", + "modified": "2025-10-12 14:27:16.721532", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py index 6f48de253587de55be91a4b7d48df5f35ff39f13..4c40e9d688a6e95665fe6813b1755a2e5f32c102 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py @@ -23,6 +23,7 @@ class WorkOrderItem(Document): description: DF.Text | None include_item_in_manufacturing: DF.Check is_additional_item: DF.Check + is_customer_provided_item: DF.Check item_code: DF.Link | None item_name: DF.Data | None operation: DF.Link | None diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3256c590a028d3c44d98507a6ff8e28b283148ef..2b6af394dbe650f6fed150a7f5d1f9f966486299 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -478,3 +478,5 @@ erpnext.patches.v16_0.make_workstation_operating_components #1 erpnext.patches.v16_0.set_reporting_currency erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes erpnext.patches.v16_0.update_serial_no_reference_name +erpnext.patches.v16_0.rename_subcontracted_quantity +erpnext.patches.v16_0.add_new_stock_entry_types \ No newline at end of file diff --git a/erpnext/patches/v16_0/add_new_stock_entry_types.py b/erpnext/patches/v16_0/add_new_stock_entry_types.py new file mode 100644 index 0000000000000000000000000000000000000000..d069e3cf7ff82191755547ed4a1b64310f704b3a --- /dev/null +++ b/erpnext/patches/v16_0/add_new_stock_entry_types.py @@ -0,0 +1,14 @@ +import frappe + + +def execute(): + for stock_entry_type in [ + "Receive from Customer", + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", + ]: + if not frappe.db.exists("Stock Entry Type", stock_entry_type): + frappe.new_doc("Stock Entry Type", purpose=stock_entry_type, is_standard=1).insert( + set_name=stock_entry_type, ignore_permissions=True + ) diff --git a/erpnext/patches/v16_0/rename_subcontracted_quantity.py b/erpnext/patches/v16_0/rename_subcontracted_quantity.py new file mode 100644 index 0000000000000000000000000000000000000000..b819e9a8ce3cf6c1ef67e92cbd25b37448ecb958 --- /dev/null +++ b/erpnext/patches/v16_0/rename_subcontracted_quantity.py @@ -0,0 +1,7 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + if frappe.db.has_column("Purchase Order Item", "subcontracted_quantity"): + rename_field("Purchase Order Item", "subcontracted_quantity", "subcontracted_qty") diff --git a/erpnext/public/js/stock_reservation.js b/erpnext/public/js/stock_reservation.js index 4af158a132b45b3f616b643584f7ebaf9de28e66..2cafd576f546c46fae123ef7471983cdded38972 100644 --- a/erpnext/public/js/stock_reservation.js +++ b/erpnext/public/js/stock_reservation.js @@ -195,6 +195,7 @@ $.extend(erpnext.stock_reservation, { args: { doc: frm.doc, items: data.items, + is_transfer: 0, table_name: table_name, notify: true, }, diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 24a6717663c06df115b7f7a62dcd906e52633adf..f1e8dadfdafdf1551136eb02342beb24a2cde4e6 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -648,7 +648,7 @@ erpnext.utils.update_child_items = function (opts) { get_query: function () { let filters; if (frm.doc.doctype == "Sales Order") { - filters = { is_sales_item: 1 }; + filters = { is_sales_item: 1, is_stock_item: !frm.doc.is_subcontracted }; } else if (frm.doc.doctype == "Purchase Order") { if (frm.doc.is_subcontracted) { if (frm.doc.is_old_subcontracting_flow) { @@ -804,7 +804,7 @@ erpnext.utils.update_child_items = function (opts) { } if ( - frm.doc.doctype == "Purchase Order" && + ["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow ) { @@ -860,7 +860,7 @@ erpnext.utils.update_child_items = function (opts) { }, ], primary_action: function () { - if (frm.doctype == "Sales Order" && has_reserved_stock) { + if (frm.doctype == "Sales Order" && has_reserved_stock && frm.doc.is_subcontracted == 0) { this.hide(); frappe.confirm( __( diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 996ee949a1335c3cf72476c8044927e7f3825488..5cc7238d6ddddec0011a925e8aaa8fc238689c4e 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -543,6 +543,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { based_on: based_on, posting_date: this.frm.doc.posting_date, posting_time: this.frm.doc.posting_time, + scio_detail: this.item.scio_detail, }, callback: (r) => { if (r.message) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 2ff8be1be2be555e7076ba19eb108b9d3dc04198..0150477dab735f95e51e4935686c32ba9c23cdab 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -63,7 +63,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({ @@ -93,7 +94,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"), @@ -102,16 +104,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) { @@ -121,7 +128,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) { @@ -795,10 +802,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; @@ -806,6 +831,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) { @@ -836,6 +905,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 { @@ -849,6 +962,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 @@ -901,11 +1030,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"), @@ -934,7 +1076,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(), @@ -944,7 +1086,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(), @@ -956,13 +1101,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(), @@ -971,7 +1119,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(), @@ -1045,7 +1197,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 () { @@ -1668,5 +1824,13 @@ 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 })); diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 93405f39a83a10adaf1c696a484ae5a309709c3e..4b5a0a0321f948aaadcde24d4fe12ad7b4453e27 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -27,6 +27,7 @@ "company", "skip_delivery_note", "has_unit_price_items", + "is_subcontracted", "amended_from", "accounting_dimensions_section", "cost_center", @@ -1041,8 +1042,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, @@ -1625,7 +1626,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 Submit", "fieldname": "reserve_stock", "fieldtype": "Check", @@ -1747,13 +1748,21 @@ "fieldname": "shipping_section", "fieldtype": "Section Break", "label": "Shipping" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted", + "print_hide": 1 } ], - "icon": "uil uil-file-alt", + "grid_page_length": 50, + "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-08-08 10:51:20.521057", + "modified": "2025-10-12 12:14:29.760988", "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 b663b3b8e2a724149590616acef5fc30a2d7218c..5bf1184cb1b254564e5c547fc74c5f9fc5a591c5 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -129,6 +129,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 last_scanned_warehouse: DF.Link | None @@ -208,6 +209,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) @@ -215,6 +220,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 @@ -248,6 +262,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: @@ -258,7 +273,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"): @@ -466,7 +513,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() if self.base_grand_total == 0.0 and not frappe.flags.in_test: @@ -557,9 +604,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 = [] @@ -1387,6 +1448,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, @@ -1424,6 +1525,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") ) @@ -2170,3 +2274,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 diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py index f3fae44330d082c5a70b88436be78e5bb379fd2f..ea9c8d2f96e57f642545f0a1bf1cdec72cdc9dcc 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py +++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py @@ -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"]}, ], } diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index c9631ee21958f2fb20f721148c8bd3cf6a1eac99..2bf17058431e9d68c3692355c7aed0b47c3d1d24 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2625,6 +2625,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 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 59a5c65cb7327b113f57ff82dc8fb98ef11eb9ad..26ff772c974c69c33865fc2993a6a559e0f96b06 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -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", @@ -26,6 +28,7 @@ "quantity_and_rate", "qty", "stock_uom", + "subcontracted_qty", "col_break2", "uom", "conversion_factor", @@ -485,6 +488,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", @@ -507,6 +511,7 @@ }, { "collapsible": 1, + "depends_on": "eval:!parent.is_subcontracted", "fieldname": "item_weight_details", "fieldtype": "Section Break", "label": "Item Weight Details" @@ -534,6 +539,7 @@ "options": "UOM" }, { + "depends_on": "eval:!parent.is_subcontracted", "fieldname": "warehouse_and_reference", "fieldtype": "Section Break", "label": "Warehouse and Reference" @@ -894,7 +900,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", @@ -950,6 +956,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!parent.is_subcontracted", "fieldname": "available_quantity_section", "fieldtype": "Section Break", "label": "Available Quantity" @@ -1080,6 +1087,17 @@ "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 + }, { "fieldname": "product_bundle_name", "fieldtype": "Link", @@ -1087,12 +1105,28 @@ "label": "Product Bundle", "options": "Product Bundle", "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", 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 90216faef3f48bfa3bd34c7c038a4a231f0d1421..dedccd4c9bc94cf6de9addbec10adf2bc5994fee 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -45,6 +45,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 gross_profit_calculation_rule: DF.Literal[ @@ -95,6 +97,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 supplier_unit_cost_price: DF.Currency target_warehouse: DF.Link | None diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index ab0223d716c889afe1453faa89d5d7c5e0be1afe..02148a24f1f36f4b667a34581daae6d4c0b8e4bb 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -11,6 +11,7 @@ "customer_group", "column_break_4", "territory", + "item_price_tab", "item_price_settings_section", "selling_price_list", "maintain_same_rate_action", @@ -23,6 +24,7 @@ "validate_selling_price", "editable_bundle_item_rates", "allow_negative_rates_for_items", + "transaction_tab", "sales_transactions_settings_section", "so_required", "dn_required", @@ -41,7 +43,12 @@ "allow_zero_qty_in_sales_order", "experimental_section", "use_legacy_js_reactivity", - "validate_project_in_so_si" + "validate_project_in_so_si", + "subcontracting_inward_tab", + "section_break_zwh6", + "allow_delivery_of_overproduced_qty", + "column_break_mla9", + "deliver_scrap_items" ], "fields": [ { @@ -255,6 +262,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", @@ -274,7 +319,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", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py index 79932af7ecd10a5b7805a7c9a538ac45670c8e92..b3fbffe0598095115921c0a4616df7c98e3fc031 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.py +++ b/erpnext/selling/doctype/selling_settings/selling_settings.py @@ -24,6 +24,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 @@ -34,6 +35,7 @@ class SellingSettings(Document): create_tasks_from_sales_order: DF.Check 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 diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index a9758a92c44150a8177329f3423a3d28dcade256..f3cda6c6af6ac4bce1f30e155f9835643eddd905 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -122,6 +122,30 @@ def install(country=None): "purpose": "Material Consumption for Manufacture", "is_standard": 1, }, + { + "doctype": "Stock Entry Type", + "name": _("Receive from Customer"), + "purpose": "Receive from Customer", + "is_standard": 1, + }, + { + "doctype": "Stock Entry Type", + "name": _("Return Raw Material to Customer"), + "purpose": "Return Raw Material to Customer", + "is_standard": 1, + }, + { + "doctype": "Stock Entry Type", + "name": _("Subcontracting Delivery"), + "purpose": "Subcontracting Delivery", + "is_standard": 1, + }, + { + "doctype": "Stock Entry Type", + "name": _("Subcontracting Return"), + "purpose": "Subcontracting Return", + "is_standard": 1, + }, # territory: with two default territories, one for home country and one named Rest of the World { "doctype": "Territory", diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 255b54c543267d776752f9d1046e3be5f0264786..a3db18abfbecc4c7be9818785799f476051555dc 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -217,7 +217,13 @@ frappe.ui.form.on("Item", { const stock_exists = frm.doc.__onload && frm.doc.__onload.stock_exists ? 1 : 0; - ["is_stock_item", "has_serial_no", "has_batch_no", "has_variants"].forEach((fieldname) => { + [ + "is_stock_item", + "is_customer_provided_item", + "has_serial_no", + "has_batch_no", + "has_variants", + ].forEach((fieldname) => { frm.set_df_property(fieldname, "read_only", stock_exists); }); diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 03f96701d30d509503a4b6856fab0257cf40cef3..3e4edbdc8d0c3033111e78dac79ff3d8bd373b07 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -88,7 +88,6 @@ "lead_time_days", "last_purchase_rate", "is_customer_provided_item", - "customer", "supplier_details", "delivered_by_supplier", "column_break2", @@ -214,10 +213,10 @@ }, { "default": "0", - "depends_on": "eval:!doc.is_down_payment_item&&!doc.is_fixed_asset", "fieldname": "allow_alternative_item", "fieldtype": "Check", - "label": "Allow Alternative Item" + "label": "Allow Alternative Item", + "read_only_depends_on": "eval:doc.is_customer_provided_item" }, { "allow_in_quick_entry": 1, @@ -598,7 +597,7 @@ "label": "Is Customer Provided Item" }, { - "depends_on": "eval:doc.is_customer_provided_item==1", + "depends_on": "eval:doc.is_customer_provided_item", "fieldname": "customer", "fieldtype": "Link", "label": "Customer", @@ -783,10 +782,9 @@ }, { "default": "0", - "description": "If subcontracted to a vendor", "fieldname": "is_sub_contracted_item", "fieldtype": "Check", - "label": "Supply Raw Materials for Purchase", + "label": "Is Subcontracted Item", "oldfieldname": "is_sub_contracted_item", "oldfieldtype": "Select" }, @@ -1013,7 +1011,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2025-10-01 16:58:40.946604", + "modified": "2025-10-13 16:58:40.946604", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 9a5b61a83fae4e1e8e3cfdc7af54d9e4e9b22657..6479ea134df645506e0c8094850b4b8c3342cef9 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -88,7 +88,6 @@ class Item(Document): country_of_origin: DF.Link | None create_new_batch: DF.Check cumulative_time: DF.Int - customer: DF.Link | None customer_code: DF.SmallText | None customer_items: DF.Table[ItemCustomerDetail] customs_tariff_number: DF.Link | None @@ -983,7 +982,12 @@ class Item(Document): if self.is_new(): return - restricted_fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no") + restricted_fields = ( + "has_serial_no", + "is_stock_item", + "valuation_method", + "has_batch_no", + ) values = frappe.db.get_value("Item", self.name, restricted_fields, as_dict=True) if not values: diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 3cf1cb0b331211224025c83a197de894df61dfb0..d4374fd9df9d881c341ac6bfd2367ceee2bca118 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -71,6 +71,10 @@ class PackedItem(Document): def make_packing_list(doc): "Make/Update packing list for Product Bundle Item." + + if doc.get("is_subcontracted"): + return + if doc.get("_action") and doc._action == "update_after_submit": return diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 2505fbcaa21881b189116ea2acf3945d62f4922a..c89f21a0e03bd78ea5b9af06c4b444e6dccacbf6 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -7,6 +7,8 @@ import json from collections import Counter, defaultdict import frappe +import frappe.query_builder +import frappe.query_builder.functions from frappe import _, _dict, bold from frappe.model.document import Document from frappe.model.naming import make_autoname @@ -1928,10 +1930,9 @@ def get_serial_and_batch_ledger(**kwargs): def get_auto_data(**kwargs): kwargs = frappe._dict(kwargs) if cint(kwargs.has_serial_no): - return get_available_serial_nos(kwargs) - + return get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs) elif cint(kwargs.has_batch_no): - return get_auto_batch_nos(kwargs) + return get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs) def get_available_batches_qty(available_batches): @@ -2033,6 +2034,28 @@ def get_available_serial_nos(kwargs): ) +def get_serial_nos_from_sre(kwargs): + table = frappe.qb.DocType("Stock Reservation Entry") + child_table = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select(child_table.serial_no, child_table.batch_no, child_table.warehouse) + .where( + (table.docstatus == 1) + & (table.voucher_detail_no == kwargs.scio_detail) + & (child_table.qty != child_table.delivered_qty) + ) + .limit(cint(kwargs.qty) or 10000000) + ) + if kwargs.based_on == "LIFO": + query = query.orderby(child_table.creation, order=frappe.query_builder.Order.desc) + else: + query = query.orderby(child_table.creation) + return query.run(as_dict=True) + + def get_non_expired_batches(batches): filters = {} if isinstance(batches, list): @@ -2106,13 +2129,13 @@ def get_bundle_wise_serial_nos(data, kwargs): def get_reserved_voucher_details(kwargs): reserved_voucher_details = [] - value = { - "Delivery Note": ["Delivery Note Item", "against_sales_order"], - "Stock Entry": ["Stock Entry", "work_order"], - "Work Order": ["Work Order", "production_plan"], + field_mapper = { + "Delivery Note": [["Delivery Note Item", "against_sales_order"]], + "Stock Entry": [["Stock Entry", "work_order"], ["Stock Entry", "subcontracting_inward_order"]], + "Work Order": [["Work Order", "production_plan"], ["Work Order", "subcontracting_inward_order"]], }.get(kwargs.get("sabb_voucher_type")) - if not value or not kwargs.get("sabb_voucher_no"): + if not field_mapper or not kwargs.get("sabb_voucher_no"): return reserved_voucher_details voucher_based_filters = { @@ -2131,11 +2154,15 @@ def get_reserved_voucher_details(kwargs): }, }.get(kwargs.get("sabb_voucher_type")) - reserved_voucher_details = frappe.get_all( - value[0], - pluck=value[1], - filters=voucher_based_filters, - ) + reserved_voucher_details = [] + for row in field_mapper: + reserved_voucher_details.extend( + frappe.get_all( + row[0], + pluck=row[1], + filters=voucher_based_filters, + ) + ) return reserved_voucher_details @@ -2441,6 +2468,43 @@ def get_auto_batch_nos(kwargs): return get_qty_based_available_batches(available_batches, qty) +def get_batch_nos_from_sre(kwargs): + from frappe.query_builder.functions import Max, Min, Sum + + table = frappe.qb.DocType("Stock Reservation Entry") + child_table = frappe.qb.DocType("Serial and Batch Entry") + + if kwargs.based_on == "LIFO": + creation_field = Max(child_table.creation).as_("sort_creation") + order = frappe.query_builder.Order.desc + else: + creation_field = Min(child_table.creation).as_("sort_creation") + order = frappe.query_builder.Order.asc + + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select( + child_table.batch_no, + child_table.warehouse, + Sum(child_table.qty - child_table.delivered_qty).as_("qty"), + creation_field, + ) + .where( + (table.docstatus == 1) + & (table.voucher_detail_no == kwargs.scio_detail) + & (child_table.qty != child_table.delivered_qty) + ) + .groupby(child_table.batch_no, child_table.warehouse) + .orderby("sort_creation", order=order) + .orderby(child_table.batch_no, order=frappe.query_builder.Order.asc) + ) + + result = query.run(as_dict=True) + return get_qty_based_available_batches(result, flt(kwargs.qty)) if flt(kwargs.qty) else result + + def get_batches_to_be_considered(sales_order_name): parent = frappe.qb.DocType("Stock Reservation Entry") child = frappe.qb.DocType("Serial and Batch Entry") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 8b3523c2163f323fa60dfa25400cae96e81cc56f..8c2bf1ae545755864754c46b59b2c2449d14329b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -87,15 +87,13 @@ frappe.ui.form.on("Stock Entry", { frappe.throw(__("Please enter Item Code to get Batch Number")); } else { if ( - in_list( - [ - "Material Transfer for Manufacture", - "Manufacture", - "Repack", - "Send to Subcontractor", - ], - doc.purpose - ) + [ + "Material Transfer for Manufacture", + "Manufacture", + "Repack", + "Send to Subcontractor", + "Receive from Customer", + ].includes(doc.purpose) ) { filters = { item_code: item.item_code, @@ -214,7 +212,7 @@ frappe.ui.form.on("Stock Entry", { refresh: function (frm) { frm.trigger("get_items_from_transit_entry"); - if (!frm.doc.docstatus) { + if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) { frm.trigger("validate_purpose_consumption"); frm.add_custom_button( __("Material Request"), @@ -299,7 +297,7 @@ frappe.ui.form.on("Stock Entry", { } } - if (frm.doc.docstatus === 0) { + if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) { frm.add_custom_button( __("Purchase Invoice"), function () { @@ -375,7 +373,11 @@ frappe.ui.form.on("Stock Entry", { ); } - if (frm.doc.docstatus === 0 && frm.doc.purpose == "Material Issue") { + if ( + frm.doc.docstatus === 0 && + frm.doc.purpose == "Material Issue" && + !frm.doc.subcontracting_inward_order + ) { frm.add_custom_button( __("Expired Batches"), function () { @@ -428,6 +430,17 @@ frappe.ui.form.on("Stock Entry", { } frm.events.set_route_options_for_new_doc(frm); + + frm.set_df_property( + "items", + "cannot_add_rows", + frm.doc.subcontracting_inward_order && + [ + "Return Raw Material to Customer", + "Subcontracting Return", + "Subcontracting Delivery", + ].includes(frm.doc.purpose) + ); }, set_route_options_for_new_doc(frm) { @@ -453,7 +466,7 @@ frappe.ui.form.on("Stock Entry", { }, get_items_from_transit_entry: function (frm) { - if (frm.doc.docstatus === 0) { + if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) { frm.add_custom_button( __("Transit Entry"), function () { @@ -617,7 +630,8 @@ frappe.ui.form.on("Stock Entry", { frm.doc.docstatus === 0 && ["Material Issue", "Material Receipt", "Material Transfer", "Send to Subcontractor"].includes( frm.doc.purpose - ) + ) && + !frm.doc.subcontracting_inward_order ) { frm.add_custom_button( __("Bill of Materials"), @@ -630,10 +644,6 @@ frappe.ui.form.on("Stock Entry", { }, get_items_from_bom: function (frm) { - let filters = function () { - return { filters: { docstatus: 1 } }; - }; - let fields = [ { fieldname: "bom", @@ -1166,10 +1176,28 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle this.frm.add_fetch("purchase_order", "supplier", "supplier"); } else { this.frm.add_fetch("subcontracting_order", "supplier", "supplier"); + this.frm.add_fetch("subcontracting_inward_order", "customer", "customer"); } frappe.dynamic_link = { doc: this.frm.doc, fieldname: "supplier", doctype: "Supplier" }; this.frm.set_query("supplier_address", erpnext.queries.address_query); + + const operator = this.frm.doc.subcontracting_inward_order ? "in" : "not in"; + this.frm.set_query("stock_entry_type", function () { + return { + filters: { + purpose: [ + operator, + [ + "Receive from Customer", + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", + ], + ], + }, + }; + }); } onload_post_render() { @@ -1182,7 +1210,6 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle } refresh() { - var me = this; erpnext.toggle_naming_series(); this.toggle_related_fields(this.frm.doc); this.toggle_enable_bom(); @@ -1453,6 +1480,8 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle doc.delivery_note_no = doc.sales_invoice_no = null; + } else if (doc.purpose === "Receive from Customer") { + doc.supplier = doc.supplier_name = doc.supplier_address = doc.purchase_receipt_no = null; } else { doc.customer = doc.customer_name = diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 05de07b3f6ab78a154a7cd21a6afcc0a6480de6c..5f73dc1c442db21d22e4aae93967274c3e02af26 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -17,6 +17,7 @@ "job_card", "purchase_order", "subcontracting_order", + "subcontracting_inward_order", "delivery_note_no", "sales_invoice_no", "pick_list", @@ -129,7 +130,7 @@ "label": "Purpose", "oldfieldname": "purpose", "oldfieldtype": "Select", - "options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble", + "options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble\nReceive from Customer\nReturn Raw Material to Customer\nSubcontracting Delivery\nSubcontracting Return", "read_only": 1, "search_index": 1 }, @@ -708,9 +709,18 @@ "fieldtype": "Check", "label": "Is Additional Transfer Entry", "read_only": 1 + }, + { + "depends_on": "subcontracting_inward_order", + "fieldname": "subcontracting_inward_order", + "fieldtype": "Link", + "label": "Subcontracting Inward Order", + "options": "Subcontracting Inward Order", + "read_only": 1 } ], - "icon": "uil uil-file-alt", + "grid_page_length": 50, + "icon": "fa fa-file-text", "idx": 1, "index_web_pages_for_search": 1, "is_submittable": 1, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 443aa050bf5af038c3b9c09f261771973ea14d30..828710bc8f7416bbc845317fdeea27ee474a2d18 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2,7 +2,6 @@ # License: GNU General Public License v3. See license.txt -import copy import json from collections import defaultdict @@ -79,11 +78,12 @@ class MaxSampleAlreadyRetainedError(frappe.ValidationError): from erpnext.controllers.stock_controller import StockController +from erpnext.controllers.subcontracting_inward_controller import SubcontractingInwardController form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"} -class StockEntry(StockController): +class StockEntry(StockController, SubcontractingInwardController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -139,6 +139,10 @@ class StockEntry(StockController): "Repack", "Send to Subcontractor", "Disassemble", + "Receive from Customer", + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", ] remarks: DF.Text | None sales_invoice_no: DF.Link | None @@ -148,6 +152,7 @@ class StockEntry(StockController): source_address_display: DF.TextEditor | None source_warehouse_address: DF.Link | None stock_entry_type: DF.Link + subcontracting_inward_order: DF.Link | None subcontracting_order: DF.Link | None supplier: DF.Link | None supplier_address: DF.Link | None @@ -175,6 +180,15 @@ class StockEntry(StockController): "order_supplied_items_field": "Purchase Order Item Supplied", } ) + elif self.subcontracting_inward_order: + self.subcontract_data = frappe._dict( + { + "order_doctype": "Subcontracting Inward Order", + "order_field": "subcontracting_inward_order", + "rm_detail_field": "scio_detail", + "order_received_items_field": "Subcontracting Inward Order Received Item", + } + ) else: self.subcontract_data = frappe._dict( { @@ -249,15 +263,20 @@ class StockEntry(StockController): self.reset_default_field_value("from_warehouse", "items", "s_warehouse") self.reset_default_field_value("to_warehouse", "items", "t_warehouse") - def on_submit(self): self.validate_closed_subcontracting_order() + self.validate_subcontract_order() + + super().validate_subcontracting_inward() + + def on_submit(self): self.make_bundle_using_old_serial_batch_fields() self.update_work_order() self.update_disassembled_order() + self.adjust_stock_reservation_entries_for_return() + self.update_sre_for_subcontracting_delivery() self.update_stock_ledger() self.make_stock_reserve_for_wip_and_fg() - self.validate_subcontract_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() self.update_pick_list_status() @@ -274,6 +293,8 @@ class StockEntry(StockController): if self.purpose == "Material Transfer" and self.outgoing_stock_entry: self.set_material_request_transfer_status("Completed") + super().on_submit_subcontracting_inward() + def on_cancel(self): self.delink_asset_repair_sabb() self.validate_closed_subcontracting_order() @@ -285,7 +306,8 @@ class StockEntry(StockController): self.validate_work_order_status() self.update_work_order() - self.update_disassembled_order(is_cancel=True) + self.update_disassembled_order() + self.cancel_stock_reservation_entries_for_inward() self.update_stock_ledger() self.ignore_linked_doctypes = ( @@ -300,6 +322,8 @@ class StockEntry(StockController): self.update_cost_in_project() self.update_transferred_qty() self.update_quality_inspection() + self.adjust_stock_reservation_entries_for_return() + self.update_sre_for_subcontracting_delivery() self.delete_auto_created_batches() self.delete_linked_stock_entry() @@ -308,6 +332,8 @@ class StockEntry(StockController): if self.purpose == "Material Transfer" and self.outgoing_stock_entry: self.set_material_request_transfer_status("In Transit") + super().on_cancel_subcontracting_inward() + def on_update(self): super().on_update() self.set_serial_and_batch_bundle() @@ -370,11 +396,17 @@ class StockEntry(StockController): "Send to Subcontractor", "Material Consumption for Manufacture", "Disassemble", + "Receive from Customer", + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", ] if self.purpose not in valid_purposes: frappe.throw(_("Purpose must be one of {0}").format(comma_or(valid_purposes))) + super().validate_purpose() + def delete_linked_stock_entry(self): if self.purpose == "Send to Warehouse": for d in frappe.get_all( @@ -508,6 +540,9 @@ class StockEntry(StockController): flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) ) + if self.purpose == "Subcontracting Delivery": + item.expense_account = frappe.get_value("Company", self.company, "default_expense_account") + def validate_fg_completed_qty(self): if self.purpose != "Manufacture": return @@ -577,7 +612,10 @@ class StockEntry(StockController): title=_("Difference Account in Items Table"), ) - if self.purpose != "Material Issue" and acc_details.account_type == "Cost of Goods Sold": + if ( + self.purpose not in ["Material Issue", "Subcontracting Delivery"] + and acc_details.account_type == "Cost of Goods Sold" + ): frappe.msgprint( _( "At row #{0}: you have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account" @@ -596,6 +634,8 @@ class StockEntry(StockController): "Send to Subcontractor", "Material Transfer for Manufacture", "Material Consumption for Manufacture", + "Return Raw Material to Customer", + "Subcontracting Delivery", ] target_mandatory = [ @@ -603,6 +643,8 @@ class StockEntry(StockController): "Material Transfer", "Send to Subcontractor", "Material Transfer for Manufacture", + "Receive from Customer", + "Subcontracting Return", ] validate_for_manufacture = any([d.bom_no for d in self.get("items")]) @@ -863,7 +905,7 @@ class StockEntry(StockController): if d.s_warehouse or d.set_basic_rate_manually: continue - if d.allow_zero_valuation_rate: + if d.allow_zero_valuation_rate and self.purpose != "Receive from Customer": d.basic_rate = 0.0 items.append(d.item_code) @@ -1087,13 +1129,15 @@ class StockEntry(StockController): self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose") def make_serial_and_batch_bundle_for_outward(self): - if self.docstatus == 0: - return - serial_or_batch_items = get_serial_or_batch_items(self.items) if not serial_or_batch_items: return + serial_nos, batch_nos = self.set_serial_batch_fields_for_subcontracting_inward() + + if self.docstatus == 0: + return + already_picked_serial_nos = [] for row in self.items: @@ -1119,7 +1163,9 @@ class StockEntry(StockController): "ignore_serial_nos": already_picked_serial_nos, "qty": row.transfer_qty * -1, } - ).update_serial_and_batch_entries() + ).update_serial_and_batch_entries( + serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name) + ) elif not row.serial_and_batch_bundle: bundle_doc = SerialBatchCreation( { @@ -1134,7 +1180,9 @@ class StockEntry(StockController): "company": self.company, "do_not_submit": True, } - ).make_serial_and_batch_bundle() + ).make_serial_and_batch_bundle( + serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name) + ) if not bundle_doc: continue @@ -1147,6 +1195,32 @@ class StockEntry(StockController): row.serial_and_batch_bundle = bundle_doc.name + def set_serial_batch_fields_for_subcontracting_inward(self): + serial_nos, batch_nos = frappe._dict(), frappe._dict() + for row in self.items: + if self.purpose in [ + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", + ]: + if not row.serial_and_batch_bundle: + serial_nos_list, batch_nos_list = self.get_serial_nos_and_batches_from_sres( + row.scio_detail, only_pending=self.purpose != "Subcontracting Return" + ) + + if len(batch_nos_list) > 1: + row.use_serial_batch_fields = 0 + + if row.use_serial_batch_fields: + if serial_nos_list and not row.serial_no: + row.serial_no = "\n".join(serial_nos_list) + if batch_nos_list and not row.batch_no: + row.batch_no = next(iter(batch_nos_list.keys())) + + serial_nos[row.name], batch_nos[row.name] = serial_nos_list, batch_nos_list + + return serial_nos, batch_nos + def validate_subcontract_order(self): """Throw exception if more raw material is transferred against Subcontract Order than in the raw materials supplied table""" @@ -1330,8 +1404,12 @@ class StockEntry(StockController): ) def validate_closed_subcontracting_order(self): - if self.get("subcontracting_order"): - check_on_hold_or_closed_status("Subcontracting Order", self.subcontracting_order) + order = self.get("subcontracting_order") or self.get("subcontracting_inward_order") + if order: + check_on_hold_or_closed_status( + "Subcontracting Order" if self.get("subcontracting_order") else "Subcontracting Inward Order", + order, + ) def mark_finished_and_scrap_items(self): if self.purpose != "Repack" and any( @@ -1741,12 +1819,12 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() - def update_disassembled_order(self, is_cancel=False): + def update_disassembled_order(self): if not self.work_order: return if self.purpose == "Disassemble" and self.fg_completed_qty: pro_doc = frappe.get_doc("Work Order", self.work_order) - pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel) + pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, self._action == "cancel") def make_stock_reserve_for_wip_and_fg(self): if self.is_stock_reserve_for_work_order(): @@ -1755,6 +1833,7 @@ class StockEntry(StockController): self.purpose == "Manufacture" and not pro_doc.sales_order and not pro_doc.production_plan_sub_assembly_item + and not pro_doc.subcontracting_inward_order ): return @@ -1775,7 +1854,7 @@ class StockEntry(StockController): def is_stock_reserve_for_work_order(self): if ( self.work_order - and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"] + and self.purpose in ["Material Transfer for Manufacture", "Manufacture"] and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") ): return True @@ -2829,7 +2908,8 @@ class StockEntry(StockController): child_qty = flt(item_row["qty"], precision) if not self.is_return and child_qty <= 0 and not item_row.get("is_scrap_item"): - continue + if self.purpose != "Receive from Customer": + continue se_child = self.append("items") stock_uom = item_row.get("stock_uom") or frappe.db.get_value("Item", d, "stock_uom") @@ -2838,7 +2918,7 @@ class StockEntry(StockController): se_child.item_code = item_row.get("item_code") or cstr(d) se_child.uom = item_row["uom"] if item_row.get("uom") else stock_uom se_child.stock_uom = stock_uom - se_child.qty = child_qty + se_child.qty = child_qty if child_qty > 0 else 0 se_child.allow_alternative_item = item_row.get("allow_alternative_item", 0) se_child.subcontracted_item = item_row.get("main_item_code") se_child.cost_center = item_row.get("cost_center") or get_default_cost_center( @@ -2848,6 +2928,7 @@ class StockEntry(StockController): se_child.is_scrap_item = item_row.get("is_scrap_item", 0) se_child.po_detail = item_row.get("po_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail") + se_child.scio_detail = item_row.get("scio_detail") for field in [ self.subcontract_data.rm_detail_field, diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index d142cb80adb493f172f6b127bd7547ea8f65386f..f9d501c1b95a1372a8aac242488230177e0ed17e 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -38,6 +38,7 @@ "sample_quantity", "rates_section", "basic_rate", + "customer_provided_item_cost", "additional_cost", "landed_cost_voucher_amount", "valuation_rate", @@ -75,6 +76,7 @@ "ste_detail", "po_detail", "sco_rm_detail", + "scio_detail", "putaway_rule", "column_break_51", "reference_purchase_receipt", @@ -97,7 +99,8 @@ "label": "Source Warehouse", "oldfieldname": "s_warehouse", "oldfieldtype": "Link", - "options": "Warehouse" + "options": "Warehouse", + "read_only_depends_on": "eval:in_list([\"Subcontracting Delivery\", \"Return Raw Material to Customer\"], parent.purpose)" }, { "fieldname": "col_break1", @@ -560,7 +563,8 @@ "default": "0", "fieldname": "is_finished_item", "fieldtype": "Check", - "label": "Is Finished Item" + "label": "Is Finished Item", + "read_only_depends_on": "eval:in_list([\"Subcontracting Delivery\", \"Subcontracting Return\"], parent.purpose)" }, { "fieldname": "job_card_item", @@ -617,6 +621,26 @@ "label": "Landed Cost Voucher Amount", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "eval:parent.purpose == \"Receive from Customer\"", + "fieldname": "customer_provided_item_cost", + "fieldtype": "Currency", + "label": "Customer Provided Item Cost", + "no_copy": 1, + "non_negative": 1, + "options": "Company:company:default_currency", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "scio_detail", + "fieldtype": "Data", + "hidden": 1, + "label": "SCIO Detail", + "print_hide": 1, + "read_only": 1, + "search_index": 1 } ], "grid_page_length": 50, diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index 149cd6ae64d185b3df7d1bb1f9ba65f49b43c329..9ff64d1f777a9faa6ee2e3ba8aa505fbd7793170 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -28,6 +28,7 @@ class StockEntryDetail(Document): bom_no: DF.Link | None conversion_factor: DF.Float cost_center: DF.Link | None + customer_provided_item_cost: DF.Currency description: DF.TextEditor | None expense_account: DF.Link | None has_item_scanned: DF.Check @@ -54,6 +55,7 @@ class StockEntryDetail(Document): retain_sample: DF.Check s_warehouse: DF.Link | None sample_quantity: DF.Int + scio_detail: DF.Data | None sco_rm_detail: DF.Data | None serial_and_batch_bundle: DF.Link | None serial_no: DF.Text | None diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json index d78c658f2bb03b07de0e30719574453d611a3b42..8b52dcd30ca942f0a32e370b5c18fd1c314537dd 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json @@ -17,7 +17,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Purpose", - "options": "\nMaterial Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble", + "options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble\nReceive from Customer\nReturn Raw Material to Customer\nSubcontracting Delivery\nSubcontracting Return", "reqd": 1, "set_only_once": 1 }, @@ -38,7 +38,7 @@ ], "grid_page_length": 50, "links": [], - "modified": "2025-04-11 15:20:07.555295", + "modified": "2025-09-04 13:03:31.283348", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Type", @@ -87,7 +87,6 @@ } ], "quick_entry": 1, - "restrict_to_domain": "Stocks", "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index 27ad10aa46b0bd0a557dd856d8058b2e0f5b7fac..1e05b680fb120a0dd9312107a77ec4f2c8af0b09 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -21,7 +21,6 @@ class StockEntryType(Document): add_to_transit: DF.Check is_standard: DF.Check purpose: DF.Literal[ - "", "Material Issue", "Material Receipt", "Material Transfer", @@ -31,6 +30,10 @@ class StockEntryType(Document): "Repack", "Send to Subcontractor", "Disassemble", + "Receive from Customer", + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", ] # end: auto-generated types @@ -50,6 +53,10 @@ class StockEntryType(Document): "Repack", "Send to Subcontractor", "Disassemble", + "Receive from Customer", + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", ]: frappe.throw(f"Stock Entry Type {self.name} cannot be set as standard") diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index c3e99c5addde1ec791727fe15ebbea4416f64e4b..5f81d391b59bb10a2e4743354efe8fa290f91c62 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -84,7 +84,7 @@ "no_copy": 1, "oldfieldname": "voucher_type", "oldfieldtype": "Data", - "options": "\nSales Order\nWork Order\nProduction Plan", + "options": "\nSales Order\nWork Order\nSubcontracting Inward Order\nProduction Plan", "print_width": "150px", "read_only": 1, "width": "150px" @@ -288,7 +288,7 @@ "fieldtype": "Select", "label": "From Voucher Type", "no_copy": 1, - "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order\nProduction Plan", + "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order\nProduction Plan\nSubcontracting Inward Order", "print_hide": 1, "read_only": 1, "report_hide": 1 @@ -344,7 +344,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-08-25 19:48:33.170835", + "modified": "2025-10-12 19:48:33.170835", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index bcf926dd5983f76de6d566fcf6888510424ce191..84a6fbf851d399ece50257bfed0915156f3a1f88 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -33,7 +33,13 @@ class StockReservationEntry(Document): from_voucher_detail_no: DF.Data | None from_voucher_no: DF.DynamicLink | None from_voucher_type: DF.Literal[ - "", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order", "Production Plan" + "", + "Pick List", + "Purchase Receipt", + "Stock Entry", + "Work Order", + "Production Plan", + "Subcontracting Inward Order", ] has_batch_no: DF.Check has_serial_no: DF.Check @@ -57,7 +63,9 @@ class StockReservationEntry(Document): voucher_detail_no: DF.Data | None voucher_no: DF.DynamicLink | None voucher_qty: DF.Float - voucher_type: DF.Literal["", "Sales Order", "Work Order", "Production Plan"] + voucher_type: DF.Literal[ + "", "Sales Order", "Work Order", "Subcontracting Inward Order", "Production Plan" + ] warehouse: DF.Link | None # end: auto-generated types @@ -236,12 +244,11 @@ class StockReservationEntry(Document): def validate_reservation_based_on_qty(self) -> None: """Validates `Reserved Qty` when `Reservation Based On` is `Qty`.""" - if self.reservation_based_on == "Qty": + if self.reservation_based_on == "Qty" and self.voucher_type != "Subcontracting Inward Order": self.validate_with_allowed_qty(self.reserved_qty) def auto_reserve_serial_and_batch(self, based_on: str = None) -> None: """Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`.""" - if ( not self.from_voucher_type and (self.get("_action") == "submit") @@ -409,7 +416,8 @@ class StockReservationEntry(Document): frappe.throw(msg) # Should be called after validating Serial and Batch Nos. - self.validate_with_allowed_qty(qty_to_be_reserved) + if self.voucher_type != "Subcontracting Inward Order": + self.validate_with_allowed_qty(qty_to_be_reserved) self.db_set("reserved_qty", qty_to_be_reserved) def update_reserved_qty_in_voucher( @@ -1211,15 +1219,25 @@ class StockReservation: return available_qty - def transfer_reservation_entries_to(self, docnames, from_doctype, to_doctype): + def transfer_reservation_entries_to( + self, docnames, from_doctype, to_doctype, against_fg_item=None, qty_change=None + ): if isinstance(docnames, str): docnames = [docnames] items_to_reserve = self.get_items_to_reserve(docnames, from_doctype, to_doctype) + + if qty_change: + for key, value in qty_change.items(): + row = next((item for item in items_to_reserve if item.voucher_detail_no == key), None) + if row: + row.qty += value + row.required_qty += value + if not items_to_reserve: return - reservation_entries = self.get_reserved_entries(from_doctype, docnames) + reservation_entries = self.get_reserved_entries(from_doctype, docnames, against_fg_item) if not reservation_entries: return @@ -1382,7 +1400,7 @@ class StockReservation: sre.save() sre.submit() - def get_reserved_entries(self, doctype, docnames): + def get_reserved_entries(self, doctype, docnames, against_fg_item=None): if isinstance(docnames, str): docnames = [docnames] @@ -1422,6 +1440,17 @@ class StockReservation: .orderby(sabb_entry.idx) ) + if against_fg_item: + query = query.where( + sre.voucher_detail_no.isin( + frappe.get_all( + "Subcontracting Inward Order Received Item", + {"reference_name": against_fg_item, "docstatus": 1}, + pluck="name", + ) + ) + ) + return query.run(as_dict=True) def get_items_to_reserve(self, docnames, from_doctype, to_doctype): diff --git a/erpnext/stock/doctype/warehouse/warehouse.js b/erpnext/stock/doctype/warehouse/warehouse.js index 6606a4f11a07ec16dff31d87db3a38724de3cf7a..562f45b08ccd4f099295c99a44a824fe115fedeb 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.js +++ b/erpnext/stock/doctype/warehouse/warehouse.js @@ -84,6 +84,10 @@ frappe.ui.form.on("Warehouse", { } frm.toggle_enable(["is_group", "company"], false); + + if (frm.doc.customer) { + frm.set_df_property("customer", "read_only", frm.doc.__onload.stock_exists); + } }, }); diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index 9ff2fa2fa2f1ef858b0752e1138a71a5a0df21b1..9b673be581e17552acea23c64c2ca14e8a7741f0 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -18,6 +18,7 @@ "column_break_4", "account", "company", + "customer", "address_and_contact", "address_html", "column_break_10", @@ -258,6 +259,15 @@ "fieldname": "is_rejected_warehouse", "fieldtype": "Check", "label": "Is Rejected Warehouse" + }, + { + "allow_in_quick_entry": 1, + "depends_on": "eval:!doc.disabled", + "description": "Only to be used for Subcontracting Inward.", + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" } ], "grid_page_length": 50, @@ -265,7 +275,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2025-06-26 11:19:04.673115", + "modified": "2025-09-05 14:47:17.140099", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 64de22d743092625b6bed7df74b518f7a65fe4fc..3230a9258cba497fde3db30e539796b3ffa5d80d 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -29,6 +29,7 @@ class Warehouse(NestedSet): address_line_2: DF.Data | None city: DF.Data | None company: DF.Link + customer: DF.Link | None default_in_transit_warehouse: DF.Link | None disabled: DF.Check email_id: DF.Data | None @@ -58,13 +59,13 @@ class Warehouse(NestedSet): self.name = self.warehouse_name def onload(self): - """load account name for General Ledger Report""" if self.company and cint(frappe.db.get_value("Company", self.company, "enable_perpetual_inventory")): account = self.account or get_warehouse_account(self) if account: self.set_onload("account", account) load_address_and_contact(self) + self.set_onload("stock_exists", self.check_if_sle_exists(non_cancelled_only=True)) def validate(self): self.warn_about_multiple_warehouse_account() @@ -151,8 +152,11 @@ class Warehouse(NestedSet): indicator="orange", ) - def check_if_sle_exists(self): - return frappe.db.exists("Stock Ledger Entry", {"warehouse": self.name}) + def check_if_sle_exists(self, non_cancelled_only=False): + filters = {"warehouse": self.name} + if non_cancelled_only: + filters["is_cancelled"] = 0 + return frappe.db.exists("Stock Ledger Entry", filters) def check_if_child_exists(self): return frappe.db.exists("Warehouse", {"parent_warehouse": self.name}) @@ -228,7 +232,7 @@ def get_child_warehouses(warehouse): from frappe.utils.nestedset import get_descendants_of children = get_descendants_of("Warehouse", warehouse, ignore_permissions=True, order_by="lft") - return children + [warehouse] # append self for backward compatibility + return [*children, warehouse] # append self for backward compatibility def get_warehouses_based_on_account(account, company=None): diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index bd905f21dd00014df238fec6fb37e149719fe0cf..7e3c5004deee53a4728736cc59cd6e47901090ac 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -345,7 +345,7 @@ def validate_item_details(ctx: ItemDetailsCtx, item): throw(_(msg), title=_("Template Item Selected")) - elif ctx.transaction_type == "buying" and ctx.doctype != "Material Request": + elif ctx.doctype != "Material Request": if ctx.is_subcontracted: if ctx.is_old_subcontracting_flow: if item.is_sub_contracted_item != 1: diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index b29c8e34651a735877aaff7ef9fa6e1f7a41f62e..5731af8fbc187c417c45a1e65ab4c207afe32867 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -1044,13 +1044,23 @@ class SerialBatchCreation: for d in remove_list: package.remove(d) - def make_serial_and_batch_bundle(self): + def make_serial_and_batch_bundle( + self, serial_nos=None, batch_nos=None + ): # passing None instead of [] due to ruff linter error B006 + serial_nos = serial_nos or [] + batch_nos = batch_nos or [] + doc = frappe.new_doc("Serial and Batch Bundle") valid_columns = doc.meta.get_valid_columns() for key, value in self.__dict__.items(): if key in valid_columns: doc.set(key, value) + if serial_nos: + self.serial_nos = serial_nos + if batch_nos: + self.batches = batch_nos + if self.type_of_transaction == "Outward": self.set_auto_serial_batch_entries_for_outward() elif self.type_of_transaction == "Inward": @@ -1099,10 +1109,21 @@ class SerialBatchCreation: self.batch_no = batches[0] self.serial_nos = self.get_auto_created_serial_nos() - def update_serial_and_batch_entries(self): + def update_serial_and_batch_entries( + self, serial_nos=None, batch_nos=None + ): # passing None instead of [] due to ruff linter error B006 + serial_nos = serial_nos or [] + batch_nos = batch_nos or [] + doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle) doc.type_of_transaction = self.type_of_transaction doc.set("entries", []) + + if serial_nos: + self.serial_nos = serial_nos + if batch_nos: + self.batch_nos = batch_nos + self.set_auto_serial_batch_entries_for_outward() self.set_serial_batch_entries(doc) if not doc.get("entries"): @@ -1449,3 +1470,28 @@ def get_batchwise_qty(voucher_type, voucher_no): return frappe._dict({}) return frappe._dict(batches) + + +def get_serial_batch_list_from_item(item): + serial_list, batch_list = [], [] + if item.serial_and_batch_bundle: + table = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(table) + .select(table.serial_no, table.batch_no) + .where(table.parent == item.serial_and_batch_bundle) + ) + result = query.run(as_dict=True) + + for row in result: + if row.serial_no and row.serial_no not in serial_list: + serial_list.append(row.serial_no) + if row.batch_no and row.batch_no not in batch_list: + batch_list.append(row.batch_no) + else: + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + serial_list = get_serial_nos(item.serial_no) if item.serial_no else [] + batch_list = [item.batch_no] if item.batch_no else [] + + return serial_list, batch_list diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/__init__.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js new file mode 100644 index 0000000000000000000000000000000000000000..358e03078412a8d18b3d27234f3b5cd6cfef72ae --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js @@ -0,0 +1,246 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// client script for Subcontracting Inward Order Item is not necessarily required as the server side code will do everything that is necessary. +// this is just so that the user does not get potentially confused +frappe.ui.form.on("Subcontracting Inward Order Item", { + qty(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + const service_item = frm.doc.service_items[row.idx - 1]; + frappe.model.set_value( + service_item.doctype, + service_item.name, + "qty", + row.qty * row.subcontracting_conversion_factor + ); + frappe.model.set_value(service_item.doctype, service_item.name, "fg_item_qty", row.qty); + }, + before_items_remove(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + frm.toggle_enable(["service_items"], true); + frm.get_field("service_items").grid.grid_rows[row.idx - 1].remove(); + frm.toggle_enable(["service_items"], false); + }, +}); + +frappe.ui.form.on("Subcontracting Inward Order", { + setup: (frm) => { + frm.get_field("items").grid.cannot_add_rows = true; + + frm.set_query("customer_warehouse", () => { + return { + filters: { + is_group: 0, + is_rejected_warehouse: 0, + company: frm.doc.company, + customer: frm.doc.customer, + disabled: 0, + }, + }; + }); + + frm.set_query("sales_order", () => { + return { + filters: { + docstatus: 1, + is_subcontracted: 1, + }, + }; + }); + + frm.set_query("delivery_warehouse", "items", () => { + return { + filters: { + is_group: 0, + is_rejected_warehouse: 0, + company: frm.doc.company, + disabled: 0, + customer: ["is", "not set"], + }, + }; + }); + + frm.set_query("set_delivery_warehouse", () => { + return { + filters: { + is_group: 0, + is_rejected_warehouse: 0, + company: frm.doc.company, + disabled: 0, + customer: ["is", "not set"], + }, + }; + }); + }, + + set_delivery_warehouse: (frm) => { + frm.doc.items.forEach((item) => + frappe.model.set_value( + item.doctype, + item.name, + "delivery_warehouse", + frm.doc.set_delivery_warehouse + ) + ); + }, + + sales_order: (frm) => { + frm.set_value("service_items", null); + frm.set_value("items", null); + frm.set_value("received_items", null); + + if (frm.doc.sales_order) { + erpnext.utils.map_current_doc({ + method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order", + source_name: frm.doc.sales_order, + target_doc: frm, + freeze: true, + freeze_message: __("Mapping Subcontracting Inward Order ..."), + }); + } + }, + + refresh: function (frm) { + if (frm.doc.docstatus == 1) { + if (frm.has_perm("submit")) { + if (frm.doc.status == "Closed") { + frm.add_custom_button( + __("Re-open"), + () => frm.events.update_subcontracting_inward_order_status(frm), + __("Status") + ); + } else { + frm.add_custom_button( + __("Close"), + () => frm.events.update_subcontracting_inward_order_status(frm, "Closed"), + __("Status") + ); + } + } + if (frm.doc.status != "Closed") { + const is_raw_materials_received = frm.doc.received_items.some((item) => + item.is_customer_provided_item + ? item.received_qty - item.work_order_qty - item.returned_qty > 0 + : false + ); + if (is_raw_materials_received) { + frm.add_custom_button( + __("Raw Materials to Customer"), + () => frm.trigger("make_rm_return"), + __("Return") + ); + if (frm.doc.per_produced < 100) { + frm.add_custom_button( + __("Work Order"), + () => frm.events.make_work_order(frm), + __("Create") + ); + } + } + + if (frm.doc.per_produced < 100) { + frm.add_custom_button( + __("Material from Customer"), + () => frm.events.make_stock_entry(frm), + __("Receive") + ); + } + if (frm.doc.per_produced > 0 && frm.doc.per_delivered < 100) { + frm.add_custom_button( + __("Subcontracting Delivery"), + () => frm.events.make_subcontracting_delivery(frm), + __("Create") + ); + } + if (frm.doc.per_delivered > 0 && frm.doc.per_returned < 100) { + frm.add_custom_button( + __("Finished Goods Return"), + () => frm.events.make_subcontracting_return(frm), + __("Return") + ); + } + if (frm.doc.per_produced < 100) { + frm.page.set_inner_btn_group_as_primary(__("Receive")); + } else if (frm.doc.per_delivered < 100) { + frm.page.set_inner_btn_group_as_primary(__("Create")); + } else if (frm.doc.per_delivered >= 100 && frm.doc.per_returned < 100) { + frm.page.set_inner_btn_group_as_primary(__("Return")); + } + } + } + }, + + update_subcontracting_inward_order_status(frm, status) { + frappe.call({ + method: "erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order.update_subcontracting_inward_order_status", + args: { + scio: frm.doc.name, + status: status, + }, + callback: function (r) { + if (!r.exc) { + frm.reload_doc(); + } + }, + }); + }, + + make_work_order(frm) { + frappe.call({ + method: "make_work_order", + freeze: true, + doc: frm.doc, + callback: function () { + frm.reload_doc(); + }, + }); + }, + + make_stock_entry(frm) { + frappe.call({ + method: "make_rm_stock_entry_inward", + freeze: true, + doc: frm.doc, + callback: (r) => { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + }, + }); + }, + + make_rm_return(frm) { + frappe.call({ + method: "make_rm_return", + freeze: true, + doc: frm.doc, + callback: (r) => { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + }, + }); + }, + + make_subcontracting_delivery(frm) { + frappe.call({ + method: "make_subcontracting_delivery", + freeze: true, + doc: frm.doc, + callback: (r) => { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + }, + }); + }, + + make_subcontracting_return(frm) { + frappe.call({ + method: "make_subcontracting_return", + freeze: true, + doc: frm.doc, + callback: (r) => { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + }, + }); + }, +}); diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json new file mode 100644 index 0000000000000000000000000000000000000000..a3b6b10a012de44b9d07333423fb7d8a39eb17f2 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json @@ -0,0 +1,374 @@ +{ + "actions": [], + "allow_auto_repeat": 1, + "allow_import": 1, + "autoname": "naming_series:", + "creation": "2025-03-24 12:50:26.464612", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "title", + "naming_series", + "sales_order", + "customer", + "customer_name", + "currency", + "column_break_7", + "company", + "transaction_date", + "customer_warehouse", + "amended_from", + "items_section", + "set_delivery_warehouse", + "items", + "raw_materials_received_section", + "received_items", + "scrap_items_generated_section", + "scrap_items", + "service_items_section", + "service_items", + "tab_other_info", + "order_status_section", + "status", + "per_raw_material_received", + "per_produced", + "per_delivered", + "column_break_39", + "per_raw_material_returned", + "per_process_loss", + "per_returned", + "tab_connections" + ], + "fields": [ + { + "allow_on_submit": 1, + "default": "{customer_name}", + "fieldname": "title", + "fieldtype": "Data", + "hidden": 1, + "label": "Title", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "SCI-ORD-.YYYY.-", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Subcontracting Sales Order", + "options": "Sales Order", + "reqd": 1 + }, + { + "bold": 1, + "fieldname": "customer", + "fieldtype": "Link", + "in_global_search": 1, + "in_standard_filter": 1, + "label": "Customer", + "options": "Customer", + "print_hide": 1, + "read_only": 1, + "reqd": 1, + "search_index": 1 + }, + { + "bold": 1, + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Customer Name", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break", + "print_width": "50%", + "width": "50%" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "print_hide": 1, + "remember_last_selected_value": 1, + "reqd": 1 + }, + { + "default": "Today", + "fetch_from": "sales_order.transaction_date", + "fetch_if_empty": 1, + "fieldname": "transaction_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Amended From", + "no_copy": 1, + "options": "Subcontracting Inward Order", + "print_hide": 1, + "read_only": 1 + }, + { + "allow_bulk_edit": 1, + "depends_on": "sales_order", + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Subcontracting Inward Order Item", + "reqd": 1 + }, + { + "collapsible": 1, + "fieldname": "service_items_section", + "fieldtype": "Section Break", + "label": "Service Items" + }, + { + "fieldname": "service_items", + "fieldtype": "Table", + "label": "Service Items", + "options": "Subcontracting Inward Order Service Item", + "read_only": 1, + "reqd": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "received_items", + "depends_on": "received_items", + "fieldname": "raw_materials_received_section", + "fieldtype": "Section Break", + "label": "Raw Materials Required" + }, + { + "allow_on_submit": 1, + "fieldname": "received_items", + "fieldtype": "Table", + "label": "Required Items", + "no_copy": 1, + "options": "Subcontracting Inward Order Received Item", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "order_status_section", + "fieldtype": "Section Break", + "label": "Order Status" + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "options": "Draft\nOpen\nOngoing\nProduced\nDelivered\nCancelled\nClosed", + "print_hide": 1, + "read_only": 1, + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "column_break_39", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "per_delivered", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "% Delivered", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "tab_other_info", + "fieldtype": "Tab Break", + "label": "Other Info" + }, + { + "fieldname": "tab_connections", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "per_produced", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "% Produced", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "items_section", + "fieldtype": "Section Break", + "label": "Items" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "per_process_loss", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "% Process Loss", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "set_delivery_warehouse", + "fieldtype": "Link", + "label": "Set Delivery Warehouse", + "no_copy": 1, + "options": "Warehouse" + }, + { + "fieldname": "customer_warehouse", + "fieldtype": "Link", + "label": "Customer Warehouse", + "options": "Warehouse", + "reqd": 1 + }, + { + "depends_on": "scrap_items", + "fieldname": "scrap_items_generated_section", + "fieldtype": "Section Break", + "label": "Scrap Items Generated" + }, + { + "fieldname": "scrap_items", + "fieldtype": "Table", + "label": "Scrap Items", + "no_copy": 1, + "options": "Subcontracting Inward Order Scrap Item" + }, + { + "fieldname": "per_returned", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "% Returned", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "per_raw_material_returned", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "% Raw Material Returned", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "per_raw_material_received", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "% Raw Material Received", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fetch_from": "customer.default_currency", + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Customer Currency", + "options": "Currency", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2025-09-05 14:41:46.859510", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting Inward Order", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "search_fields": "status, transaction_date, customer", + "show_name_in_global_search": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "timeline_field": "customer", + "title_field": "customer_name", + "track_changes": 1 +} diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py new file mode 100644 index 0000000000000000000000000000000000000000..0b0ffd675896e01485c4cc6cc10c7f3dac157038 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py @@ -0,0 +1,548 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.mapper import get_mapped_doc +from frappe.utils import comma_and, flt, get_link_to_form + +from erpnext.buying.utils import check_on_hold_or_closed_status +from erpnext.controllers.subcontracting_controller import SubcontractingController + + +class SubcontractingInwardOrder(SubcontractingController): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.subcontracting.doctype.subcontracting_inward_order_item.subcontracting_inward_order_item import ( + SubcontractingInwardOrderItem, + ) + from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import ( + SubcontractingInwardOrderReceivedItem, + ) + from erpnext.subcontracting.doctype.subcontracting_inward_order_scrap_item.subcontracting_inward_order_scrap_item import ( + SubcontractingInwardOrderScrapItem, + ) + from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import ( + SubcontractingInwardOrderServiceItem, + ) + + amended_from: DF.Link | None + company: DF.Link + currency: DF.Link | None + customer: DF.Link + customer_name: DF.Data + customer_warehouse: DF.Link + items: DF.Table[SubcontractingInwardOrderItem] + naming_series: DF.Literal["SCI-ORD-.YYYY.-"] + per_delivered: DF.Percent + per_process_loss: DF.Percent + per_produced: DF.Percent + per_raw_material_received: DF.Percent + per_raw_material_returned: DF.Percent + per_returned: DF.Percent + received_items: DF.Table[SubcontractingInwardOrderReceivedItem] + sales_order: DF.Link + scrap_items: DF.Table[SubcontractingInwardOrderScrapItem] + service_items: DF.Table[SubcontractingInwardOrderServiceItem] + set_delivery_warehouse: DF.Link | None + status: DF.Literal["Draft", "Open", "Ongoing", "Produced", "Delivered", "Cancelled", "Closed"] + title: DF.Data | None + transaction_date: DF.Date + # end: auto-generated types + + pass + + def validate(self): + super().validate() + self.set_is_customer_provided_item() + self.validate_customer_provided_items() + self.validate_customer_warehouse() + self.validate_service_items() + self.set_missing_values() + + def on_submit(self): + self.update_status() + self.update_subcontracted_quantity_in_so() + + def on_cancel(self): + self.update_status() + self.update_subcontracted_quantity_in_so() + + def update_status(self, status=None, update_modified=True): + if self.status == "Closed" and self.status != status: + check_on_hold_or_closed_status("Sales Order", self.sales_order) + + total_to_be_received = total_received = total_rm_returned = 0 + for rm in self.get("received_items"): + if rm.get("is_customer_provided_item"): + total_to_be_received += flt(rm.required_qty) + total_received += flt(rm.received_qty) + total_rm_returned += flt(rm.returned_qty) + + total_to_be_produced = total_produced = total_process_loss = total_delivered = total_fg_returned = 0 + for item in self.get("items"): + total_to_be_produced += flt(item.qty) + total_produced += flt(item.produced_qty) + total_process_loss += flt(item.process_loss_qty) + total_delivered += flt(item.delivered_qty) + total_fg_returned += flt(item.returned_qty) + + per_raw_material_received = flt(total_received / total_to_be_received * 100, 2) + per_raw_material_returned = flt(total_rm_returned / total_received * 100, 2) if total_received else 0 + per_produced = flt(total_produced / total_to_be_produced * 100, 2) + per_process_loss = flt(total_process_loss / total_produced * 100, 2) if total_produced else 0 + per_delivered = flt(total_delivered / total_to_be_produced * 100, 2) + per_returned = flt(total_fg_returned / total_delivered * 100, 2) if total_delivered else 0 + + self.db_set("per_raw_material_received", per_raw_material_received, update_modified=update_modified) + self.db_set("per_raw_material_returned", per_raw_material_returned, update_modified=update_modified) + self.db_set("per_produced", per_produced, update_modified=update_modified) + self.db_set("per_process_loss", per_process_loss, update_modified=update_modified) + self.db_set("per_delivered", per_delivered, update_modified=update_modified) + self.db_set("per_returned", per_returned, update_modified=update_modified) + + if self.docstatus >= 1 and not status: + if self.docstatus == 1: + if self.status == "Draft": + status = "Open" + elif self.per_delivered == 100: + status = "Delivered" + elif self.per_produced == 100: + status = "Produced" + elif self.per_raw_material_received > 0: + status = "Ongoing" + else: + status = "Open" + elif self.docstatus == 2: + status = "Cancelled" + + if status and self.status != status: + self.db_set("status", status, update_modified=update_modified) + + def update_subcontracted_quantity_in_so(self): + for service_item in self.service_items: + doc = frappe.get_doc("Sales Order Item", service_item.sales_order_item) + doc.subcontracted_qty = ( + (doc.subcontracted_qty + service_item.qty) + if self._action == "submit" + else (doc.subcontracted_qty - service_item.qty) + ) + doc.save() + + def validate_customer_warehouse(self): + if frappe.get_cached_value("Warehouse", self.customer_warehouse, "customer") != self.customer: + frappe.throw( + _("Customer Warehouse {0} does not belong to Customer {1}.").format( + frappe.bold(self.customer_warehouse), frappe.bold(self.customer) + ) + ) + + def validate_service_items(self): + sales_order_items = [item.sales_order_item for item in self.items] + self.service_items = [ + service_item + for service_item in self.service_items + if service_item.sales_order_item in sales_order_items + ] + + for service_item in self.service_items: + item = next(item for item in self.items if item.sales_order_item == service_item.sales_order_item) + service_item.qty = item.qty * item.subcontracting_conversion_factor + service_item.fg_item_qty = item.qty + service_item.amount = service_item.qty * service_item.rate + + def populate_items_table(self): + items = [] + + for si in self.service_items: + if si.fg_item: + item = frappe.get_doc("Item", si.fg_item) + + so_item = frappe.get_doc("Sales Order Item", si.sales_order_item) + available_qty = so_item.stock_qty - so_item.subcontracted_qty + + if available_qty == 0: + continue + + si.required_qty = available_qty + conversion_factor = so_item.stock_qty / so_item.fg_item_qty + si.fg_item_qty = flt( + available_qty / conversion_factor, frappe.get_precision("Sales Order Item", "qty") + ) + si.amount = available_qty * si.rate + + bom = ( + frappe.db.get_value( + "Subcontracting BOM", + {"finished_good": item.name, "is_active": 1}, + "finished_good_bom", + ) + or item.default_bom + ) + + items.append( + { + "item_code": item.name, + "item_name": item.item_name, + "expected_delivery_date": frappe.get_cached_value( + "Sales Order Item", si.sales_order_item, "delivery_date" + ), + "description": item.description, + "qty": si.fg_item_qty, + "subcontracting_conversion_factor": conversion_factor, + "stock_uom": item.stock_uom, + "bom": bom, + "sales_order_item": si.sales_order_item, + } + ) + else: + frappe.throw( + _("Please select Finished Good Item for Service Item {0}").format( + si.item_name or si.item_code + ) + ) + + if items: + for item in items: + self.append("items", item) + + def validate_customer_provided_items(self): + """Check if atleast one raw material is customer provided""" + for item in self.get("items"): + raw_materials = [rm for rm in self.get("received_items") if rm.main_item_code == item.item_code] + if not any([rm.is_customer_provided_item for rm in raw_materials]): + frappe.throw( + _( + "Atleast one raw material for Finished Good Item {0} should be customer provided." + ).format(frappe.bold(item.item_code)) + ) + + def set_is_customer_provided_item(self): + for item in self.get("received_items"): + item.is_customer_provided_item = frappe.get_cached_value( + "Item", item.rm_item_code, "is_customer_provided_item" + ) + + @frappe.whitelist() + def make_work_order(self): + """Create Work Order from Subcontracting Inward Order.""" + wo_list = [] + + for item in self.get_production_items(): + work_order = self.create_work_order(item) + if work_order: + wo_list.append(work_order) + + self.show_list_created_message("Work Order", wo_list) + + if not wo_list: + frappe.msgprint(_("No Work Orders were created")) + + return wo_list + + def get_production_items(self): + item_list = [] + + for d in self.items: + if d.produced_qty >= d.qty: + continue + + item_details = { + "production_item": d.item_code, + "use_multi_level_bom": d.include_exploded_items, + "subcontracting_inward_order": self.name, + "bom_no": d.bom, + "stock_uom": d.stock_uom, + "company": self.company, + "project": frappe.get_cached_value("Sales Order", self.sales_order, "project"), + "source_warehouse": self.customer_warehouse, + "subcontracting_inward_order_item": d.name, + "reserve_stock": 1, + "fg_warehouse": d.delivery_warehouse, + } + + qty = min( + [ + flt( + (item.received_qty - item.returned_qty - item.work_order_qty) + / flt(item.required_qty / d.qty, d.precision("qty")), + d.precision("qty"), + ) + for item in self.get("received_items") + if item.reference_name == d.name and item.is_customer_provided_item + ] + ) + qty = int(qty) if frappe.get_cached_value("UOM", d.stock_uom, "must_be_whole_number") else qty + + item_details.update({"qty": qty, "max_producible_qty": qty}) + item_list.append(item_details) + + return item_list + + def create_work_order(self, item): + from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError + + if flt(item.get("qty")) <= 0: + return + + wo = frappe.new_doc("Work Order") + wo.update(item) + + wo.set_work_order_operations() + wo.set_required_items() + + try: + wo.flags.ignore_mandatory = True + wo.flags.ignore_validate = True + wo.insert() + return wo.name + except OverProductionError: + pass + + def show_list_created_message(self, doctype, doc_list=None): + if not doc_list: + return + + frappe.flags.mute_messages = False + if doc_list: + doc_list = [get_link_to_form(doctype, p) for p in doc_list] + frappe.msgprint(_("{0} created").format(comma_and(doc_list))) + + @frappe.whitelist() + def make_rm_stock_entry_inward(self, target_doc=None): + def calculate_qty_as_per_bom(rm_item): + data = frappe.get_value( + "Subcontracting Inward Order Item", + {"name": rm_item.reference_name}, + ["process_loss_qty", "include_exploded_items"], + as_dict=True, + ) + stock_qty = frappe.get_value( + "BOM Explosion Item" if data.include_exploded_items else "BOM Item", + {"name": rm_item.bom_detail_no}, + "stock_qty", + ) + qty = flt( + stock_qty * data.process_loss_qty, + frappe.get_precision("Subcontracting Inward Order Received Item", "required_qty"), + ) + return rm_item.required_qty - rm_item.received_qty + rm_item.returned_qty + qty + + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Receive from Customer" + stock_entry.subcontracting_inward_order = self.name + + stock_entry.set_stock_entry_type() + + for rm_item in self.received_items: + if not rm_item.required_qty or not rm_item.is_customer_provided_item: + continue + + items_dict = { + rm_item.get("rm_item_code"): { + "scio_detail": rm_item.get("name"), + "qty": calculate_qty_as_per_bom(rm_item), + "to_warehouse": rm_item.get("warehouse"), + "stock_uom": rm_item.get("stock_uom"), + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + @frappe.whitelist() + def make_rm_return(self, target_doc=None): + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Return Raw Material to Customer" + stock_entry.set_stock_entry_type() + stock_entry.subcontracting_inward_order = self.name + + for rm_item in self.received_items: + items_dict = { + rm_item.get("rm_item_code"): { + "scio_detail": rm_item.get("name"), + "qty": rm_item.received_qty - rm_item.work_order_qty - rm_item.returned_qty, + "from_warehouse": rm_item.get("warehouse"), + "stock_uom": rm_item.get("stock_uom"), + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + @frappe.whitelist() + def make_subcontracting_delivery(self, target_doc=None): + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Subcontracting Delivery" + stock_entry.set_stock_entry_type() + stock_entry.subcontracting_inward_order = self.name + scio_details = [] + + allow_over = frappe.get_single_value("Selling Settings", "allow_delivery_of_overproduced_qty") + for fg_item in self.items: + qty = ( + fg_item.produced_qty + if allow_over + else min(fg_item.qty, fg_item.produced_qty) - fg_item.delivered_qty - fg_item.returned_qty + ) + if qty < 0: + continue + + scio_details.append(fg_item.name) + items_dict = { + fg_item.item_code: { + "qty": qty, + "from_warehouse": fg_item.delivery_warehouse, + "stock_uom": fg_item.stock_uom, + "scio_detail": fg_item.name, + "is_finished_item": 1, + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if ( + frappe.get_single_value("Selling Settings", "deliver_scrap_items") + and self.scrap_items + and scio_details + ): + scrap_items = [ + scrap_item for scrap_item in self.scrap_items if scrap_item.reference_name in scio_details + ] + for scrap_item in scrap_items: + qty = scrap_item.produced_qty - scrap_item.delivered_qty + if qty > 0: + items_dict = { + scrap_item.item_code: { + "qty": scrap_item.produced_qty - scrap_item.delivered_qty, + "from_warehouse": scrap_item.warehouse, + "stock_uom": scrap_item.stock_uom, + "scio_detail": scrap_item.name, + "is_scrap_item": 1, + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + @frappe.whitelist() + def make_subcontracting_return(self, target_doc=None): + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + "field_map": {"name": "subcontracting_inward_order"}, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Subcontracting Return" + stock_entry.set_stock_entry_type() + + for fg_item in self.items: + qty = fg_item.delivered_qty - fg_item.returned_qty + if qty < 0: + continue + + items_dict = { + fg_item.item_code: { + "qty": qty, + "stock_uom": fg_item.stock_uom, + "scio_detail": fg_item.name, + "is_finished_item": 1, + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + +@frappe.whitelist() +def update_subcontracting_inward_order_status(scio, status=None): + if isinstance(scio, str): + scio = frappe.get_doc("Subcontracting Inward Order", scio) + + scio.update_status(status) diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order_dashboard.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order_dashboard.py new file mode 100644 index 0000000000000000000000000000000000000000..45fe3f95e9366e856030efebf5c42aa4d0e8a652 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order_dashboard.py @@ -0,0 +1,17 @@ +from frappe import _ + + +def get_data(): + return { + "fieldname": "subcontracting_inward_order", + "transactions": [ + { + "label": _("Transactions"), + "items": ["Stock Entry"], + }, + { + "label": _("Manufacturing"), + "items": ["Work Order"], + }, + ], + } diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order_list.js b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order_list.js new file mode 100644 index 0000000000000000000000000000000000000000..37f61fc5da00c147e43ffe55d89a25ce32fa037c --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order_list.js @@ -0,0 +1,17 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.listview_settings["Subcontracting Inward Order"] = { + get_indicator: function (doc) { + const status_colors = { + Draft: "red", + Open: "orange", + Ongoing: "yellow", + Produced: "blue", + Delivered: "green", + Closed: "grey", + Cancelled: "red", + }; + return [__(doc.status), status_colors[doc.status], "status,=," + doc.status]; + }, +}; diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py new file mode 100644 index 0000000000000000000000000000000000000000..baffce48e8fa70e0afae29868f4eb40ddfeae849 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -0,0 +1,562 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + +from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_stock_entry_from_wo +from erpnext.selling.doctype.sales_order.sales_order import make_subcontracting_inward_order +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + +class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase): + """ + Integration tests for SubcontractingInwardOrder. + Use this class for testing interactions between multiple components. + """ + + def setUp(self): + create_test_data() + make_stock_entry( + item_code="Self RM", qty=100, to_warehouse="Stores - _TC", purpose="Material Receipt" + ) + return super().setUp() + + def test_customer_provided_item_cost_field(self): + so, scio = create_so_scio() + + rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in.save() + for item in rm_in.get("items"): + item.basic_rate = 10 + rm_in.append( + "additional_costs", + { + "expense_account": "Freight and Forwarding Charges - _TC", + "description": "Test", + "amount": 100, + }, + ) + rm_in.submit() + + for item in rm_in.get("items"): + self.assertEqual(item.customer_provided_item_cost, 15) + + def test_add_extra_customer_provided_item(self): + so, scio = create_so_scio() + + rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in.save() + rm_in.append( + "items", + { + "item_code": "Basic RM 2", + "qty": 5, + "t_warehouse": rm_in.items[0].t_warehouse, + "basic_rate": 10, + "transfer_qty": 5, + "uom": "Nos", + "conversion_factor": 1, + }, + ) + rm_in.submit() + + scio.reload() + self.assertTrue( + next((item for item in scio.received_items if item.rm_item_code == "Basic RM 2"), None) + ) + + def test_add_extra_item_during_manufacture(self): + make_stock_entry( + item_code="Self RM 2", qty=5, to_warehouse="Stores - _TC", purpose="Material Receipt" + ) + so, scio = create_so_scio() + frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit() + + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + next( + item for item in wo.required_items if item.item_code == "Self RM" + ).source_warehouse = "Stores - _TC" + wo.submit() + + manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")) + manufacture.save() + frappe.new_doc( + "Stock Entry Detail", + parent=manufacture.name, + parenttype="Stock Entry", + parentfield="items", + idx=6, + item_code="Self RM 2", + qty=5, + s_warehouse="Stores - _TC", + basic_rate=10, + transfer_qty=5, + uom="Nos", + conversion_factor=1, + cost_center="Main - _TC", + ).insert() + manufacture.reload() + manufacture.submit() + scio.reload() + self.assertTrue( + next((item for item in scio.received_items if item.rm_item_code == "Self RM 2"), None) + ) + + def test_work_order_creation_qty(self): + new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001")) + new_bom.items = new_bom.items[:3] + new_bom.items[1].qty = 2 + new_bom.items[2].qty = 3 + new_bom.submit() + + sc_bom_name = frappe.db.get_value("Subcontracting BOM", {"finished_good": "Basic FG Item"}) # @dokos + sc_bom = frappe.get_doc("Subcontracting BOM", sc_bom_name) # @dokos + sc_bom.finished_good_bom = new_bom.name + sc_bom.save() + + so, scio = create_so_scio() + + rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in.items[0].qty = 3 + rm_in.items[1].qty = 5 + rm_in.items[2].qty = 12 + rm_in.submit() + + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + self.assertEqual(wo.qty, 2) + + def test_rm_return(self): + from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos + + so, scio = create_so_scio() + + rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in.items[3].qty = 2 + rm_in.submit() + + serial_nos = get_serial_nos(rm_in.items[3].serial_and_batch_bundle) + batch_nos = list(get_batch_nos(rm_in.items[3].serial_and_batch_bundle).keys()) + + scio.reload() + rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + backup = rm_in.items[-1] + rm_in.items.clear() + rm_in.items.append(backup) + + rm_in.items[0].qty = 1 + rm_in.submit() + + serial_nos += get_serial_nos(rm_in.items[0].serial_and_batch_bundle) + batch_nos += list(get_batch_nos(rm_in.items[0].serial_and_batch_bundle).keys()) + + scio.reload() + rm_return = frappe.new_doc("Stock Entry").update(scio.make_rm_return()) + rm_return.submit() + + self.assertEqual( + sorted(get_serial_nos(rm_return.items[-1].serial_and_batch_bundle)), sorted(serial_nos) + ) + self.assertEqual( + sorted(list(get_batch_nos(rm_return.items[-1].serial_and_batch_bundle).keys())), sorted(batch_nos) + ) + + def test_subcontracting_delivery(self): + from erpnext.stock.serial_batch_bundle import get_serial_batch_list_from_item + + extra_serial, _ = get_serial_batch_list_from_item( + make_stock_entry( + item_code="FG Item with Serial", + qty=1, + to_warehouse="Stores - _TC", + purpose="Material Receipt", + ).items[0] + ) + so, scio = create_so_scio(service_item="Service Item 2", fg_item="FG Item with Serial") + frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit() + + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.submit() + + manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")) + manufacture.submit() + + serial_list, _ = get_serial_batch_list_from_item( + next(item for item in manufacture.items if item.is_finished_item) + ) + + scio.reload() + delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) + delivery.items[0].use_serial_batch_fields = 1 + delivery.save() + delivery_serial_list, _ = get_serial_batch_list_from_item(delivery.items[0]) + self.assertEqual(sorted(serial_list), sorted(delivery_serial_list)) + + delivery_serial_list[-1] = extra_serial[0] + delivery.items[0].serial_no = "\n".join(delivery_serial_list) + self.assertRaises(frappe.ValidationError, delivery.submit) + + def test_fg_item_fields(self): + so, scio = create_so_scio() + frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit() + + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.submit() + + manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")) + manufacture.save() + manufacture.fg_completed_qty = 5 + manufacture.process_loss_qty = 1 + manufacture.items[-1].qty = 4 + manufacture.submit() + + scio.reload() + self.assertEqual(scio.items[0].qty, 5) + self.assertEqual(scio.items[0].process_loss_qty, 1) + self.assertEqual(scio.items[0].produced_qty, 4) + rm_in = scio.make_rm_stock_entry_inward() + for item in rm_in.get("items"): + self.assertEqual(item.qty, 1) + + delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) + delivery.items[0].qty = 5 + self.assertRaises(frappe.ValidationError, delivery.submit) + delivery.items[0].qty = 2 + delivery.submit() + + scio.reload() + fg_return = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_return()) + self.assertEqual(fg_return.items[0].qty, 2) + fg_return.items[0].qty = 1 + fg_return.items[0].t_warehouse = "Stores - _TC" + fg_return.submit() + + scio.reload() + self.assertEqual(scio.items[0].delivered_qty, 2) + self.assertEqual(scio.items[0].returned_qty, 1) + + @IntegrationTestCase.change_settings("Selling Settings", {"allow_delivery_of_overproduced_qty": 1}) + @IntegrationTestCase.change_settings( + "Manufacturing Settings", {"overproduction_percentage_for_work_order": 20} + ) + def test_over_production_delivery(self): + so, scio = create_so_scio() + frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit() + + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.submit() + + manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")) + manufacture.items[-1].qty = 6 + manufacture.fg_completed_qty = 6 + manufacture.submit() + + scio.reload() + self.assertEqual(scio.items[0].produced_qty, 6) + + delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) + self.assertEqual(delivery.items[0].qty, 6) + delivery.submit() + + frappe.db.set_single_value("Selling Settings", "allow_delivery_of_overproduced_qty", 0) + delivery.cancel() + scio.reload() + delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) + self.assertEqual(delivery.items[0].qty, 5) + delivery.items[0].qty = 6 + self.assertRaises(frappe.ValidationError, delivery.submit) + + @IntegrationTestCase.change_settings("Selling Settings", {"deliver_scrap_items": 1}) + def test_scrap_delivery(self): + new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001")) + new_bom.scrap_items.append(frappe.new_doc("BOM Scrap Item", item_code="Basic RM 2", qty=1)) + new_bom.submit() + sc_bom_name = frappe.db.exists("Subcontracting BOM", {"finished_good": "Basic FG Item"}) # @dokos + sc_bom = frappe.get_doc("Subcontracting BOM", sc_bom_name) # @dokos + sc_bom.finished_good_bom = new_bom.name + sc_bom.save() + + so, scio = create_so_scio() + frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit() + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.submit() + + frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit() + + scio.reload() + self.assertEqual(scio.scrap_items[0].item_code, "Basic RM 2") + + delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) + self.assertEqual(delivery.items[-1].item_code, "Basic RM 2") + + frappe.db.set_single_value("Selling Settings", "deliver_scrap_items", 0) + delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) + self.assertNotEqual(delivery.items[-1].item_code, "Basic RM 2") + + def test_self_rm_billed_qty(self): + so, scio = create_so_scio() + frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit() + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.submit() + frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit() + scio.reload() + frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()).submit() + scio.reload() + + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + si = make_sales_invoice(so.name) + self.assertEqual(si.items[-1].item_code, "Self RM") + self.assertEqual(si.items[-1].qty, 5) + si.items[-1].qty = 3 + si.submit() + scio.reload() + self.assertEqual(scio.received_items[-1].billed_qty, 3) + + si = make_sales_invoice(so.name) + self.assertEqual(si.items[-1].qty, 2) + si.submit() + scio.reload() + self.assertEqual(scio.received_items[-1].billed_qty, 5) + + scio.reload() + si = make_sales_invoice(so.name) + self.assertEqual(len(si.items), 1) + + def test_extra_items_reservation_transfer(self): + so, scio = create_so_scio() + rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in.items[-2].qty = 7 + rm_in.submit() + + wo_list = [] + scio.reload() + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.qty = 3 + wo.submit() + wo_list.append(wo.name) + self.assertEqual(wo.required_items[-2].stock_reserved_qty, 3) + + scio.reload() + self.assertEqual(scio.received_items[-2].work_order_qty, 3) + + wo = frappe.get_doc("Work Order", scio.make_work_order()[0]) + wo.skip_transfer = 1 + wo.required_items[-1].source_warehouse = "Stores - _TC" + wo.qty = 2 + wo.submit() + wo_list.append(wo.name) + + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select(Sum(table.reserved_qty)) + .where( + (table.voucher_type == "Work Order") + & (table.item_code == rm_in.items[-2].item_code) + & (table.voucher_no.isin(wo_list)) + ) + ) + reserved_qty = query.run()[0][0] + self.assertEqual(reserved_qty, 7) + + +def create_so_scio(service_item="Service Item 1", fg_item="Basic FG Item"): + item_list = [{"item_code": service_item, "qty": 5, "fg_item": fg_item, "fg_item_qty": 5}] + so = make_sales_order(is_subcontracted=1, item_list=item_list) + scio = make_subcontracting_inward_order(so.name) + scio.items[0].delivery_warehouse = "_Test Warehouse - _TC" + scio.submit() + scio.reload() + return so, scio + + +def create_test_data(): + make_subcontracted_items() + make_raw_materials() + make_service_items() + make_bom_for_subcontracted_items() + make_subcontracting_boms() + create_warehouse("_Test Customer Warehouse", {"customer": "_Test Customer"}) # @dokos + + +def make_subcontracted_items(): + sub_contracted_items = { + "Basic FG Item": {}, + "FG Item with Serial": { + "has_serial_no": 1, + "serial_no_series": "FGS.####", + }, + "FG Item with Batch": { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_series": "FGB.####", + }, + "FG Item with Serial and Batch": { + "has_serial_no": 1, + "serial_no_series": "FGS.####", + "has_batch_no": 1, + "create_new_batch": 1, + "batch_series": "FGB.####", + }, + } + + for item, properties in sub_contracted_items.items(): + if not frappe.db.exists("Item", item): + properties.update({"is_stock_item": 1, "is_sub_contracted_item": 1}) + make_item(item, properties) + + +def make_raw_materials(): + customer_provided_raw_materials = { + "Basic RM": {}, + "Basic RM 2": {}, + "RM with Serial": {"has_serial_no": 1, "serial_no_series": "RMS.####"}, + "RM with Batch": { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "RMB.####", + }, + "RM with Serial and Batch": { + "has_serial_no": 1, + "serial_no_series": "RMS.####", + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "RMB.####", + }, + } + + for item, properties in customer_provided_raw_materials.items(): + if not frappe.db.exists("Item", item): + properties.update({"is_stock_item": 1, "is_purchase_item": 0, "is_customer_provided_item": 1}) + make_item(item, properties) + + self_raw_materials = { + "Self RM": {}, + "Self RM 2": {}, + } + + for item, properties in self_raw_materials.items(): + if not frappe.db.exists("Item", item): + properties.update({"is_stock_item": 1, "valuation_rate": 10}) + make_item(item, properties) + + +def make_service_items(): + from erpnext.controllers.tests.test_subcontracting_controller import make_service_item + + service_items = { + "Service Item 1": {}, + "Service Item 2": {}, + "Service Item 3": {}, + "Service Item 4": {}, + } + + for item, properties in service_items.items(): + make_service_item(item, properties) + + +def make_bom_for_subcontracted_items(): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + boms = { + "Basic FG Item": [ + "Basic RM", + "RM with Serial", + "RM with Batch", + "RM with Serial and Batch", + "Self RM", + ], + "FG Item with Serial": [ + "Basic RM", + "RM with Serial", + "RM with Batch", + "RM with Serial and Batch", + "Self RM", + ], + "FG Item with Batch": [ + "Basic RM", + "RM with Serial", + "RM with Batch", + "RM with Serial and Batch", + "Self RM", + ], + "FG Item with Serial and Batch": [ + "Basic RM", + "RM with Serial", + "RM with Batch", + "RM with Serial and Batch", + "Self RM", + ], + } + + for item_code, raw_materials in boms.items(): + if not frappe.db.exists("BOM", {"item": item_code}): + make_bom( + item=item_code, raw_materials=raw_materials, rate=100, currency="INR", set_as_default_bom=1 + ) + + +def make_subcontracting_boms(): + subcontracting_boms = [ + { + "finished_good": "Basic FG Item", + "service_item": "Service Item 1", + }, + { + "finished_good": "FG Item with Serial", + "service_item": "Service Item 2", + }, + { + "finished_good": "FG Item with Batch", + "service_item": "Service Item 3", + }, + { + "finished_good": "FG Item with Serial and Batch", + "service_item": "Service Item 4", + }, + ] + + for subcontracting_bom in subcontracting_boms: + if not frappe.db.exists("Subcontracting BOM", {"finished_good": subcontracting_bom["finished_good"]}): + doc = frappe.get_doc( + { + "doctype": "Subcontracting BOM", + "finished_good": subcontracting_bom["finished_good"], + "service_item": subcontracting_bom["service_item"], + "is_active": 1, + } + ) + doc.insert() + doc.save() diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/__init__.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json new file mode 100644 index 0000000000000000000000000000000000000000..878918d3341adceb67a35b9a7b020c87023afd84 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json @@ -0,0 +1,202 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2025-03-24 12:53:33.849013", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "column_break_3", + "bom", + "delivery_warehouse", + "include_exploded_items", + "quantity_section", + "qty", + "produced_qty", + "returned_qty", + "column_break_13", + "stock_uom", + "process_loss_qty", + "delivered_qty", + "conversion_factor", + "sales_order_item", + "subcontracting_conversion_factor" + ], + "fields": [ + { + "bold": 1, + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1, + "search_index": 1 + }, + { + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Item Name", + "print_hide": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "bold": 1, + "columns": 1, + "default": "1", + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Quantity", + "non_negative": 1, + "print_width": "60px", + "reqd": 1, + "width": "60px" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break", + "print_hide": 1 + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "print_width": "100px", + "read_only": 1, + "reqd": 1, + "width": "100px" + }, + { + "default": "1", + "fieldname": "conversion_factor", + "fieldtype": "Float", + "hidden": 1, + "label": "Conversion Factor", + "read_only": 1 + }, + { + "depends_on": "item_code", + "fetch_from": "item_code.default_bom", + "fetch_if_empty": 1, + "fieldname": "bom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "BOM", + "options": "BOM", + "print_hide": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "include_exploded_items", + "fieldtype": "Check", + "label": "Include Exploded Items", + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "delivered_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Delivered Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "returned_qty", + "fieldtype": "Float", + "label": "Returned Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "sales_order_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Sales Order Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "subcontracting_conversion_factor", + "fieldtype": "Float", + "hidden": 1, + "label": "Subcontracting Conversion Factor", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "produced_qty", + "fieldtype": "Float", + "label": "Produced Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "process_loss_qty", + "fieldtype": "Float", + "label": "Process Loss Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "quantity_section", + "fieldtype": "Section Break", + "label": "Quantity" + }, + { + "fieldname": "delivery_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Delivery Warehouse", + "no_copy": 1, + "options": "Warehouse", + "reqd": 1 + } + ], + "grid_page_length": 50, + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-10-14 10:29:29.256455", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting Inward Order Item", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "row_format": "Dynamic", + "search_fields": "item_name", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.py new file mode 100644 index 0000000000000000000000000000000000000000..862a3698385d8187f216a0b527d1ecdd4b4d37d6 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.py @@ -0,0 +1,52 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.query_builder.functions import Sum + + +class SubcontractingInwardOrderItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + bom: DF.Link + conversion_factor: DF.Float + delivered_qty: DF.Float + delivery_warehouse: DF.Link + include_exploded_items: DF.Check + item_code: DF.Link + item_name: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + process_loss_qty: DF.Float + produced_qty: DF.Float + qty: DF.Float + returned_qty: DF.Float + sales_order_item: DF.Data | None + stock_uom: DF.Link + subcontracting_conversion_factor: DF.Float + # end: auto-generated types + + pass + + def update_manufacturing_qty_fields(self): + table = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(table) + .select( + Sum(table.produced_qty).as_("produced_qty"), + Sum(table.process_loss_qty).as_("process_loss_qty"), + ) + .where((table.subcontracting_inward_order_item == self.name) & (table.docstatus == 1)) + ) + result = query.run(as_dict=True)[0] + + self.db_set("produced_qty", result.produced_qty) + self.db_set("process_loss_qty", result.process_loss_qty) diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/__init__.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json new file mode 100644 index 0000000000000000000000000000000000000000..d4ed145129acdf83008608e2a2eb4369064abc00 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json @@ -0,0 +1,191 @@ +{ + "actions": [], + "creation": "2025-03-24 13:56:42.877800", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "main_item_code", + "rm_item_code", + "is_customer_provided_item", + "is_additional_item", + "column_break_3", + "stock_uom", + "warehouse", + "column_break_6", + "bom_detail_no", + "reference_name", + "section_break_13", + "required_qty", + "billed_qty", + "received_qty", + "column_break_16", + "consumed_qty", + "work_order_qty", + "returned_qty" + ], + "fields": [ + { + "columns": 2, + "fieldname": "main_item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "rm_item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Raw Material Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fieldname": "bom_detail_no", + "fieldtype": "Data", + "label": "BOM Detail No", + "read_only": 1 + }, + { + "fieldname": "reference_name", + "fieldtype": "Data", + "label": "Reference Name", + "read_only": 1 + }, + { + "fieldname": "section_break_13", + "fieldtype": "Section Break" + }, + { + "columns": 2, + "default": "0", + "depends_on": "eval:doc.required_qty", + "fieldname": "required_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Required Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.is_customer_provided_item", + "fieldname": "received_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Received Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "consumed_qty", + "fieldtype": "Float", + "label": "Consumed Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.returned_qty", + "fieldname": "returned_qty", + "fieldtype": "Float", + "label": "Returned Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "default": "0", + "depends_on": "eval:doc.work_order_qty", + "fieldname": "work_order_qty", + "fieldtype": "Float", + "label": "Work Order Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_customer_provided_item", + "fieldtype": "Check", + "label": "Is Customer Provided Item", + "read_only": 1, + "reqd": 1 + }, + { + "depends_on": "eval:!doc.is_customer_provided_item", + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "no_copy": 1, + "options": "Warehouse", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:!doc.is_customer_provided_item", + "fieldname": "billed_qty", + "fieldtype": "Float", + "label": "Billed Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:!doc.bom_detail_no", + "fieldname": "is_additional_item", + "fieldtype": "Check", + "label": "Is Additional Item", + "read_only": 1 + } + ], + "grid_page_length": 50, + "hide_toolbar": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-10-14 10:18:58.905093", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting Inward Order Received Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py new file mode 100644 index 0000000000000000000000000000000000000000..22994d9e24f86bd749d2c1c5a83af38ad69f3187 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SubcontractingInwardOrderReceivedItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + billed_qty: DF.Float + bom_detail_no: DF.Data | None + consumed_qty: DF.Float + is_additional_item: DF.Check + is_customer_provided_item: DF.Check + main_item_code: DF.Link | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + received_qty: DF.Float + reference_name: DF.Data | None + required_qty: DF.Float + returned_qty: DF.Float + rm_item_code: DF.Link + stock_uom: DF.Link + warehouse: DF.Link | None + work_order_qty: DF.Float + # end: auto-generated types + + pass diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/__init__.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.json new file mode 100644 index 0000000000000000000000000000000000000000..7890270153293ee714d1ef648925ad49c405305f --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.json @@ -0,0 +1,112 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-08-12 11:34:16.393300", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "fg_item_code", + "column_break_hoxe", + "stock_uom", + "warehouse", + "column_break_rptg", + "reference_name", + "section_break_gqk9", + "produced_qty", + "column_break_n4xc", + "delivered_qty" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_hoxe", + "fieldtype": "Column Break" + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_rptg", + "fieldtype": "Column Break" + }, + { + "fieldname": "reference_name", + "fieldtype": "Data", + "label": "Reference Name", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "section_break_gqk9", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "produced_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Produced Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "delivered_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Delivered Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "fieldname": "fg_item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Finished Good Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_n4xc", + "fieldtype": "Column Break" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-10-14 10:28:30.192350", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting Inward Order Scrap Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.py new file mode 100644 index 0000000000000000000000000000000000000000..d7aaae229dd401989398a56cf5b4c13ef03aee0c --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SubcontractingInwardOrderScrapItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + delivered_qty: DF.Float + fg_item_code: DF.Link + item_code: DF.Link + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + produced_qty: DF.Float + reference_name: DF.Data + stock_uom: DF.Link + warehouse: DF.Link + # end: auto-generated types + + pass diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/__init__.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/subcontracting_inward_order_service_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/subcontracting_inward_order_service_item.json new file mode 100644 index 0000000000000000000000000000000000000000..715f90105a714e8e48c990e81ab4bc2d3c3455b9 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/subcontracting_inward_order_service_item.json @@ -0,0 +1,147 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2025-03-24 14:01:02.572511", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "column_break_2", + "item_name", + "section_break_4", + "qty", + "uom", + "column_break_6", + "rate", + "amount", + "section_break_10", + "fg_item", + "column_break_12", + "fg_item_qty", + "sales_order_item" + ], + "fields": [ + { + "bold": 1, + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1, + "search_index": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "in_list_view": 1, + "label": "Item Name", + "print_hide": 1, + "reqd": 1 + }, + { + "bold": 1, + "columns": 1, + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Quantity", + "print_width": "60px", + "reqd": 1, + "width": "60px" + }, + { + "bold": 1, + "columns": 2, + "fetch_from": "item_code.standard_rate", + "fetch_if_empty": 1, + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "options": "currency", + "reqd": 1 + }, + { + "columns": 2, + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "currency", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "fg_item", + "fieldtype": "Link", + "label": "Finished Good Item", + "options": "Item", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "fg_item_qty", + "fieldtype": "Float", + "label": "Finished Good Item Quantity", + "reqd": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_10", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "sales_order_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Sales Order Item", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM", + "read_only": 1, + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-09-05 13:33:49.154869", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting Inward Order Service Item", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "row_format": "Dynamic", + "search_fields": "item_name", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/subcontracting_inward_order_service_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/subcontracting_inward_order_service_item.py new file mode 100644 index 0000000000000000000000000000000000000000..f157ba39b9afe9eb884a9468106886e0bafb0b58 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_service_item/subcontracting_inward_order_service_item.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SubcontractingInwardOrderServiceItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + amount: DF.Currency + fg_item: DF.Link + fg_item_qty: DF.Float + item_code: DF.Link + item_name: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + qty: DF.Float + rate: DF.Currency + sales_order_item: DF.Data | None + uom: DF.Link + # end: auto-generated types + + pass diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 96a0ed11931ab5cfb71dd1d2b5afe24cd4ab4e9b..5c596c676e921dea97ef4ca55b04aa7cb8863c2e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -252,12 +252,12 @@ class SubcontractingOrder(SubcontractingController): if si.fg_item: item = frappe.get_doc("Item", si.fg_item) - qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value( + qty, subcontracted_qty, fg_item_qty = frappe.db.get_value( "Purchase Order Item", si.purchase_order_item, - ["qty", "subcontracted_quantity", "fg_item_qty"], + ["qty", "subcontracted_qty", "fg_item_qty"], ) - available_qty = flt(qty) - flt(subcontracted_quantity) + available_qty = flt(qty) - flt(subcontracted_qty) if available_qty == 0: continue @@ -342,23 +342,23 @@ class SubcontractingOrder(SubcontractingController): def update_subcontracted_quantity_in_po(self, cancel=False): for service_item in self.service_items: - subcontracted_quantity = flt( + subcontracted_qty = flt( frappe.db.get_value( - "Purchase Order Item", service_item.purchase_order_item, "subcontracted_quantity" + "Purchase Order Item", service_item.purchase_order_item, "subcontracted_qty" ) ) - subcontracted_quantity = ( - (subcontracted_quantity + service_item.qty) + subcontracted_qty = ( + (subcontracted_qty + service_item.qty) if not cancel - else (subcontracted_quantity - service_item.qty) + else (subcontracted_qty - service_item.qty) ) frappe.db.set_value( "Purchase Order Item", service_item.purchase_order_item, - "subcontracted_quantity", - subcontracted_quantity, + "subcontracted_qty", + subcontracted_qty, )