diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 7c33056a91..f6a1951439 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -185,8 +185,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e if(!in_list(["Closed", "Delivered"], doc.status)) { if(this.frm.doc.status !== 'Closed' && flt(this.frm.doc.per_received, 2) < 100 && flt(this.frm.doc.per_billed, 2) < 100) { - // Don't add Update Items button if the PO is following the new subcontracting flow. - if (!(this.frm.doc.is_subcontracted && !this.frm.doc.is_old_subcontracting_flow)) { + if (!this.frm.doc.__onload || this.frm.doc.__onload.can_update_items) { this.frm.add_custom_button(__('Update Items'), () => { erpnext.utils.update_child_items({ frm: this.frm, diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 06b9d29e69..3576cd426d 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -52,6 +52,7 @@ class PurchaseOrder(BuyingController): def onload(self): supplier_tds = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") self.set_onload("supplier_tds", supplier_tds) + self.set_onload("can_update_items", self.can_update_items()) def validate(self): super(PurchaseOrder, self).validate() @@ -450,6 +451,17 @@ class PurchaseOrder(BuyingController): else: self.db_set("per_received", 0, update_modified=False) + def can_update_items(self) -> bool: + result = True + + if self.is_subcontracted and not self.is_old_subcontracting_flow: + if frappe.db.exists( + "Subcontracting Order", {"purchase_order": self.name, "docstatus": ["!=", 2]} + ): + result = False + + return result + def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=1.0): """get last purchase rate for an item""" diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 3edaffae2a..55c01e8513 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -901,6 +901,71 @@ class TestPurchaseOrder(FrappeTestCase): self.assertRaises(frappe.ValidationError, po.save) + def test_update_items_for_subcontracting_purchase_order(self): + from erpnext.controllers.tests.test_subcontracting_controller import ( + get_subcontracting_order, + make_bom_for_subcontracted_items, + make_raw_materials, + make_service_items, + make_subcontracted_items, + ) + + def update_items(po, qty): + trans_items = [po.items[0].as_dict()] + trans_items[0]["qty"] = qty + trans_items[0]["fg_item_qty"] = qty + trans_items = json.dumps(trans_items, default=str) + + return update_child_qty_rate( + po.doctype, + trans_items, + po.name, + ) + + make_subcontracted_items() + make_raw_materials() + make_service_items() + make_bom_for_subcontracted_items() + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 7", + "qty": 10, + "rate": 100, + "fg_item": "Subcontracted Item SA7", + "fg_item_qty": 10, + }, + ] + po = create_purchase_order( + rm_items=service_items, + is_subcontracted=1, + supplier_warehouse="_Test Warehouse 1 - _TC", + ) + + update_items(po, qty=20) + po.reload() + + # Test - 1: Items should be updated as there is no Subcontracting Order against PO + self.assertEqual(po.items[0].qty, 20) + self.assertEqual(po.items[0].fg_item_qty, 20) + + sco = get_subcontracting_order(po_name=po.name, warehouse="_Test Warehouse - _TC") + + # Test - 2: ValidationError should be raised as there is Subcontracting Order against PO + self.assertRaises(frappe.ValidationError, update_items, po=po, qty=30) + + sco.reload() + sco.cancel() + po.reload() + + update_items(po, qty=30) + po.reload() + + # Test - 3: Items should be updated as the Subcontracting Order is cancelled + self.assertEqual(po.items[0].qty, 30) + self.assertEqual(po.items[0].fg_item_qty, 30) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 1237fd694d..955ebef003 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2858,6 +2858,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil return update_supplied_items + def validate_fg_item_for_subcontracting(new_data, is_new): + if is_new: + if not new_data.get("fg_item"): + frappe.throw( + _("Finished Good Item is not specified for service item {0}").format(new_data["item_code"]) + ) + else: + is_sub_contracted_item, default_bom = frappe.db.get_value( + "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"] + ) + + if not is_sub_contracted_item: + frappe.throw( + _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"]) + ) + elif not default_bom: + frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"])) + + if not new_data.get("fg_item_qty"): + frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) + data = json.loads(trans_items) any_qty_changed = False # updated to true if any item's qty changes @@ -2889,6 +2910,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil prev_rate, new_rate = flt(child_item.get("rate")), flt(d.get("rate")) prev_qty, new_qty = flt(child_item.get("qty")), flt(d.get("qty")) + prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(d.get("fg_item_qty")) prev_con_fac, new_con_fac = flt(child_item.get("conversion_factor")), flt( d.get("conversion_factor") ) @@ -2901,6 +2923,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil rate_unchanged = prev_rate == new_rate qty_unchanged = prev_qty == new_qty + fg_qty_unchanged = prev_fg_qty == new_fg_qty uom_unchanged = prev_uom == new_uom conversion_factor_unchanged = prev_con_fac == new_con_fac any_conversion_factor_changed |= not conversion_factor_unchanged @@ -2910,6 +2933,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if ( rate_unchanged and qty_unchanged + and fg_qty_unchanged and conversion_factor_unchanged and uom_unchanged and date_unchanged @@ -2920,6 +2944,17 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True + if ( + parent.doctype == "Purchase Order" + and parent.is_subcontracted + and not parent.is_old_subcontracting_flow + ): + validate_fg_item_for_subcontracting(d, new_child_flag) + child_item.fg_item_qty = flt(d["fg_item_qty"]) + + if new_child_flag: + child_item.fg_item = d["fg_item"] + child_item.qty = flt(d.get("qty")) rate_precision = child_item.precision("rate") or 2 conv_fac_precision = child_item.precision("conversion_factor") or 2 @@ -3023,11 +3058,20 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.update_ordered_qty() parent.update_ordered_and_reserved_qty() parent.update_receiving_percentage() - if parent.is_old_subcontracting_flow: - if should_update_supplied_items(parent): - parent.update_reserved_qty_for_subcontract() - parent.create_raw_materials_supplied() - parent.save() + + if parent.is_subcontracted: + if parent.is_old_subcontracting_flow: + if should_update_supplied_items(parent): + parent.update_reserved_qty_for_subcontract() + parent.create_raw_materials_supplied() + parent.save() + else: + if not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." + ).format(frappe.bold(parent.name)) + ) else: # Sales Order parent.validate_warehouse() parent.update_reserved_qty() diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index eeb35c4d96..6b61ae949d 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -1090,7 +1090,7 @@ def get_subcontracting_order(**args): po = frappe.get_doc("Purchase Order", args.get("po_name")) if po.is_subcontracted: - return create_subcontracting_order(po_name=po.name, **args) + return create_subcontracting_order(**args) if not args.service_items: service_items = [ diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index f456e5e500..c11d123982 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -579,7 +579,9 @@ erpnext.utils.update_child_items = function(opts) { "conversion_factor": d.conversion_factor, "qty": d.qty, "rate": d.rate, - "uom": d.uom + "uom": d.uom, + "fg_item": d.fg_item, + "fg_item_qty": d.fg_item_qty, } }); @@ -678,6 +680,37 @@ erpnext.utils.update_child_items = function(opts) { }) } + if (frm.doc.doctype == 'Purchase Order' && frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) { + fields.push({ + fieldtype:'Link', + fieldname:'fg_item', + options: 'Item', + reqd: 1, + in_list_view: 0, + read_only: 0, + disabled: 0, + label: __('Finished Good Item'), + get_query: () => { + return { + filters: { + 'is_stock_item': 1, + 'is_sub_contracted_item': 1, + 'default_bom': ['!=', ''] + } + } + }, + }, { + fieldtype:'Float', + fieldname:'fg_item_qty', + reqd: 1, + default: 0, + read_only: 0, + in_list_view: 0, + label: __('Finished Good Item Qty'), + precision: get_precision('fg_item_qty') + }) + } + let dialog = new frappe.ui.Dialog({ title: __("Update Items"), size: "extra-large",