From c9977be5d4a4d7547bf6589e82dd6d241d9b7050 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Fri, 7 Aug 2026 23:02:29 +0530 Subject: [PATCH 1/3] fix: allow selecting a warehouse for new items in the update items dialog (#57876) (cherry picked from commit 55fe26904622273a0089695197e666237bc87c8b) # Conflicts: # erpnext/accounts/services/child_item_update.py # erpnext/public/js/utils.js --- .../accounts/services/child_item_update.py | 618 ++++++++++++++++++ .../purchase_order/test_purchase_order.py | 53 +- erpnext/public/js/utils.js | 30 + .../doctype/sales_order/test_sales_order.py | 111 ++++ 4 files changed, 809 insertions(+), 3 deletions(-) create mode 100644 erpnext/accounts/services/child_item_update.py diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py new file mode 100644 index 00000000000..d66c5621f7a --- /dev/null +++ b/erpnext/accounts/services/child_item_update.py @@ -0,0 +1,618 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Child item update service: ChildItemUpdater class and helpers for the update_child_qty_rate API.""" + +import frappe +from frappe import _ +from frappe.model.workflow import get_workflow_name +from frappe.utils import flt, get_link_to_form, getdate + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.buying.utils import update_last_purchase_rate +from erpnext.stock.doctype.packed_item.packed_item import make_packing_list +from erpnext.stock.get_item_details import ( + get_bin_details, + get_conversion_factor, + get_item_warehouse_, +) +from erpnext.stock.utils import ( + is_group_warehouse, + validate_disabled_warehouse, + validate_warehouse_company, +) + + +class ChildItemUpdater: + """Validates and applies item-level edits on submitted orders and quotations.""" + + def __init__(self, parent_doctype: str, parent_doctype_name: str, child_docname: str = "items"): + self.parent_doctype = parent_doctype + self.parent_doctype_name = parent_doctype_name + self.child_docname = child_docname + self.parent = frappe.get_doc(parent_doctype, parent_doctype_name) + self.allow_zero_qty = get_allow_zero_qty(parent_doctype) + self._ordered_items: dict | None = None + self._purchased_items: dict | None = None + + def update(self, trans_items: str | list) -> None: + """Process item additions, edits, and deletions from trans_items JSON.""" + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.mapper import get_ordered_items + + data = frappe.parse_json(trans_items) + any_qty_changed = False + items_added_or_removed = False + any_conversion_factor_changed = False + + self._check_permissions("write") + + if self.parent_doctype == "Quotation": + self._ordered_items = get_ordered_items(self.parent.name) + items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items) + elif self.parent_doctype == "Supplier Quotation": + self._purchased_items = get_purchased_items(self.parent.name) + items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items) + else: + items_added_or_removed |= validate_and_delete_children(self.parent, data) + + for d in data: + new_child_flag = False + rate_unchanged = None + + if not d.get("item_code"): + continue + + if not d.get("docname"): + new_child_flag = True + items_added_or_removed = True + self._check_permissions("create") + child_item = self._get_new_child_item(d) + else: + self._check_permissions("write") + child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname")) + + change_state = get_child_item_change_state(self.parent_doctype, child_item, d) + rate_unchanged = change_state.rate_unchanged + any_conversion_factor_changed |= not change_state.conversion_factor_unchanged + if is_child_item_unchanged(change_state): + continue + + self._validate_quantity_and_rate(child_item, d, rate_unchanged) + + if flt(child_item.get("qty")) != flt(d.get("qty")): + any_qty_changed = True + + if self.parent.doctype in ("Sales Order", "Purchase Order") and self.parent.is_subcontracted: + self._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")) + child_item.description = d.get("description") + update_child_item_rate_and_discount( + self.parent_doctype, child_item, d, self.allow_zero_qty, rate_unchanged=rate_unchanged + ) + update_child_item_uom_and_weight(child_item, d) + + if d.get("delivery_date") and self.parent_doctype == "Sales Order": + child_item.delivery_date = d.get("delivery_date") + + if d.get("schedule_date") and self.parent_doctype == "Purchase Order": + child_item.schedule_date = d.get("schedule_date") + + if d.get("bom_no") and self.parent_doctype == "Sales Order": + child_item.bom_no = d.get("bom_no") + + child_item.flags.ignore_validate_update_after_submit = True + if new_child_flag: + self.parent.load_from_db() + child_item.idx = len(self.parent.items) + 1 + child_item.insert() + else: + child_item.save(ignore_permissions=True) + + self._post_update(any_qty_changed, items_added_or_removed, any_conversion_factor_changed) + + def _post_update( + self, any_qty_changed: bool, items_added_or_removed: bool, any_conversion_factor_changed: bool + ) -> None: + parent = self.parent + parent.reload() + parent.flags.ignore_validate_update_after_submit = True + parent.set_qty_as_per_stock_uom() + parent.calculate_taxes_and_totals() + parent.set_total_in_words() + + if self.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( + parent.doctype, parent.company, parent.base_grand_total + ) + + if self.parent_doctype != "Supplier Quotation": + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(parent).set_payment_schedule() + + if self.parent_doctype == "Purchase Order": + parent.validate_minimum_order_qty() + parent.validate_budget() + if parent.is_against_so(): + parent.update_status_updater() + elif self.parent_doctype == "Sales Order": + parent.check_credit_limit() + + for idx, row in enumerate(parent.get(self.child_docname), start=1): + row.idx = idx + + parent.save() + + if self.parent_doctype == "Purchase Order": + update_last_purchase_rate(parent, is_submit=1) + + if any_qty_changed or items_added_or_removed or any_conversion_factor_changed: + parent.update_prevdoc_status() + + parent.update_requested_qty() + parent.update_ordered_qty() + parent.update_ordered_and_reserved_qty() + parent.update_receiving_percentage() + + if parent.is_subcontracted and 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)) + ) + + elif self.parent_doctype == "Sales Order": + if parent.is_subcontracted and not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." + ) + ) + parent.validate_selling_price() + parent.validate_for_duplicate_items() + parent.validate_warehouse() + parent.update_reserved_qty() + parent.update_project() + parent.update_prevdoc_status("submit") + parent.update_delivery_status() + + parent.reload() + self._validate_workflow() + + if self.parent_doctype in ("Purchase Order", "Sales Order"): + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() + + parent.validate_uom_is_integer("uom", "qty") + parent.validate_uom_is_integer("stock_uom", "stock_qty") + + if self.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, + ) + + if has_reserved_stock(parent.doctype, parent.name): + cancel_stock_reservation_entries(parent.doctype, parent.name) + if parent.per_picked == 0: + parent.create_stock_reservation_entries() + + def _check_permissions(self, perm_type: str = "create") -> None: + try: + self.parent.check_permission(perm_type) + except frappe.PermissionError: + actions = {"create": "add", "write": "update"} + frappe.throw( + _("You do not have permissions to {0} items in a {1}.").format( + actions[perm_type], self.parent_doctype + ), + title=_("Insufficient Permissions"), + ) + + def _validate_workflow(self) -> None: + workflow = get_workflow_name(self.parent.doctype) + if not workflow: + return + + workflow_doc = frappe.get_doc("Workflow", workflow) + current_state = self.parent.get(workflow_doc.workflow_state_field) + roles = frappe.get_roles() + + allowed = any( + state.state == current_state and (not state.allow_edit or state.allow_edit in roles) + for state in workflow_doc.states + ) + + if not allowed: + frappe.throw( + _("You are not allowed to update as per the conditions set in {0} Workflow.").format( + get_link_to_form("Workflow", workflow) + ), + title=_("Insufficient Permissions"), + ) + + def _get_new_child_item(self, item_row) -> "frappe.model.document.Document": + child_doctype = self.parent_doctype + " Item" + return set_order_defaults( + self.parent_doctype, + self.parent_doctype_name, + child_doctype, + self.child_docname, + item_row, + ) + + def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None: + if not flt(new_data.get("qty")) and not self.allow_zero_qty: + frappe.throw( + _("Row #{0}:Quantity for Item {1} cannot be zero.").format( + new_data.get("idx"), frappe.bold(new_data.get("item_code")) + ), + title=_("Invalid Qty"), + ) + + qty_limits = { + "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")), + "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")), + } + + if self.parent_doctype in qty_limits: + qty_field, error_message = qty_limits[self.parent_doctype] + if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): + frappe.throw( + _("Row #{0}:").format(new_data.get("idx")) + error_message, + title=_("Invalid Qty"), + ) + + if self.parent_doctype not in ("Quotation", "Supplier Quotation"): + return + + items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items + if not items_map: + return + + qty_to_check = items_map.get(child_item.name) + if not qty_to_check: + return + + if not rate_unchanged: + frappe.throw( + _( + "Cannot update rate as item {0} is already ordered or purchased against this quotation" + ).format(frappe.bold(new_data.get("item_code"))) + ) + + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + + def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None: + 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"] + ) + ) + + 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"])) + + +@frappe.whitelist() +def update_child_qty_rate( + parent_doctype: str, trans_items: str | list, parent_doctype_name: str, child_docname: str = "items" +) -> None: + ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items) + + +def set_order_defaults( + parent_doctype: str, + parent_doctype_name: str, + child_doctype: str, + child_docname: str, + trans_item: dict, +) -> "frappe.model.document.Document": + """Return a new child item populated with item master defaults.""" + from erpnext.accounts.services.taxes import add_taxes_from_tax_template, set_child_tax_template_and_map + + p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) + child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname) + item = frappe.get_doc("Item", trans_item.get("item_code")) + + for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"): + child_item.update({field: item.get(field)}) + + date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" + child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) + child_item.stock_uom = item.stock_uom + child_item.uom = trans_item.get("uom") or item.stock_uom + child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype) + conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) + child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor + child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + child_item.base_rate = 1 + child_item.base_amount = 1 + + set_child_tax_template_and_map(item, child_item, p_doc) + add_taxes_from_tax_template(child_item, p_doc) + return child_item + + +def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None: + """Return the warehouse picked in the Update Items dialog, else the configured default. + + Validates whichever warehouse was resolved, since a submitted parent skips validate(). + """ + warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True) + + if not warehouse: + if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): + frappe.throw( + _( + "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." + ).format(frappe.bold(item.item_code)) + ) + return None + + validate_warehouse_company(warehouse, p_doc.company) + validate_disabled_warehouse(warehouse) + is_group_warehouse(warehouse) + return warehouse + + +def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool: + """Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse.""" + if child_doctype == "Sales Order Item": + return True + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier) + + return False + + +def validate_child_on_delete(row, parent, ordered_item=None) -> None: + """Raise if a partially transacted child item is being deleted.""" + if parent.doctype == "Sales Order": + if flt(row.delivered_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been delivered").format( + row.idx, row.item_code + ) + ) + if flt(row.work_order_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format( + row.idx, row.item_code + ) + ) + if flt(row.ordered_qty): + frappe.throw( + _( + "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order." + ).format(row.idx, row.item_code) + ) + + if parent.doctype == "Purchase Order" and flt(row.received_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been received").format( + row.idx, row.item_code + ) + ) + + if parent.doctype in ("Purchase Order", "Sales Order") and flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) + ) + + if parent.doctype == "Quotation" and ordered_item and ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) + + +def update_bin_on_delete(row, doctype: str) -> None: + """Update bin quantities after a child item row is deleted.""" + from erpnext.stock.stock_balance import ( + get_indented_qty, + get_ordered_qty, + get_reserved_qty, + update_bin_qty, + ) + + qty_dict = {} + + if doctype == "Sales Order": + qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse) + else: + if row.material_request_item: + qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse) + qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) + + if row.warehouse: + update_bin_qty(row.item_code, row.warehouse, qty_dict) + + +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: + """Delete child rows not present in data; return True if any were removed.""" + updated_item_names = [d.get("docname") for d in data] + deleted_children = [item for item in parent.items if item.name not in updated_item_names] + + for d in deleted_children: + validate_child_on_delete(d, parent, ordered_item) + d.flags.ignore_permissions = True + d.cancel() + d.delete() + + if parent.doctype == "Purchase Order": + parent.update_ordered_qty_in_so_for_removed_items(deleted_children) + + if parent.doctype not in ("Quotation", "Supplier Quotation"): + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) + + return bool(deleted_children) + + +def get_allow_zero_qty(parent_doctype: str) -> bool: + if parent_doctype == "Sales Order": + return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False + if parent_doctype == "Purchase Order": + return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False + return False + + +def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict: + prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) + prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty")) + prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty")) + prev_con_fac = flt(child_item.get("conversion_factor")) + new_con_fac = flt(new_data.get("conversion_factor")) + + if parent_doctype == "Sales Order": + prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date") + elif parent_doctype == "Purchase Order": + prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date") + else: + prev_date, new_date = None, None + + if parent_doctype in ("Quotation", "Supplier Quotation"): + date_unchanged = False + else: + prev_date = getdate(prev_date) if prev_date else None + new_date = getdate(new_date) if new_date else None + date_unchanged = prev_date == new_date + + return frappe._dict( + rate_unchanged=prev_rate == new_rate, + qty_unchanged=prev_qty == new_qty, + fg_qty_unchanged=prev_fg_qty == new_fg_qty, + uom_unchanged=child_item.get("uom") == new_data.get("uom"), + conversion_factor_unchanged=prev_con_fac == new_con_fac, + date_unchanged=date_unchanged, + description_unchanged=child_item.get("description") == new_data.get("description"), + ) + + +def is_child_item_unchanged(change_state: frappe._dict) -> bool: + return ( + change_state.rate_unchanged + and change_state.qty_unchanged + and change_state.fg_qty_unchanged + and change_state.conversion_factor_unchanged + and change_state.uom_unchanged + and change_state.date_unchanged + and change_state.description_unchanged + ) + + +def update_child_item_rate_and_discount( + parent_doctype: str, + child_item, + new_data, + allow_zero_qty: bool, + rate_unchanged: bool | None = None, +) -> None: + rate_precision = child_item.precision("rate") or 2 + qty_precision = child_item.precision("qty") or 2 + + if rate_unchanged is None: + rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate")) + + if not rate_unchanged and not child_item.get("qty") and allow_zero_qty: + frappe.throw(_("Rate of '{0}' items cannot be changed").format(frappe.bold(_("Unit Price")))) + + row_rate = flt(new_data.get("rate"), rate_precision) + + if parent_doctype in ("Purchase Order", "Sales Order"): + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(new_data.get("qty"), qty_precision), rate_precision + ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + + child_item.rate = row_rate + + if parent_doctype not in ("Sales Order", "Purchase Order") or not flt(child_item.price_list_rate): + return + + if flt(child_item.rate) > flt(child_item.price_list_rate): + child_item.discount_percentage = 0 + child_item.discount_amount = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = child_item.price_list_rate + child_item.discount_percentage = 0 + child_item.discount_amount = flt(child_item.rate_with_margin) - flt(child_item.rate) + + +def update_child_item_uom_and_weight(child_item, new_data) -> None: + conv_fac_precision = child_item.precision("conversion_factor") or 2 + + if new_data.get("conversion_factor"): + if child_item.stock_uom == child_item.uom: + child_item.conversion_factor = 1 + else: + child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision) + + if new_data.get("uom"): + child_item.uom = new_data.get("uom") + conversion_factor = flt( + get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor") + ) + child_item.conversion_factor = ( + flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor + ) + + if child_item.get("weight_per_unit"): + child_item.total_weight = flt( + child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, + child_item.precision("total_weight"), + ) + + +def check_if_child_table_updated( + child_table_before_update, child_table_after_update, fields_to_check +) -> bool: + """Return True if any accounting-relevant field changed in a child table.""" + fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"] + + for index, item in enumerate(child_table_before_update): + for field in fields_to_check: + if child_table_after_update[index].get(field) != item.get(field): + return True + + return False diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index b021925bd9d..423338b39ac 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -266,6 +266,7 @@ class TestPurchaseOrder(ERPNextTestSuite): po.load_from_db() existing_ordered_qty = get_ordered_qty() + existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC") first_item_of_po = po.get("items")[0] trans_item = json.dumps( @@ -276,16 +277,62 @@ class TestPurchaseOrder(ERPNextTestSuite): "qty": first_item_of_po.qty, "docname": first_item_of_po.name, }, - {"item_code": "_Test Item", "rate": 200, "qty": 7}, + {"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"}, ] ) update_child_qty_rate("Purchase Order", trans_item, po.name) po.reload() self.assertEqual(len(po.get("items")), 2) + self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC") self.assertEqual(po.status, "To Receive and Bill") - # ordered qty should increase on row addition - self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7) + # ordered qty should increase on row addition, in the warehouse passed for the new row + self.assertEqual(get_ordered_qty(), existing_ordered_qty) + self.assertEqual( + get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"), + existing_ordered_qty_in_new_warehouse + 7, + ) + + def test_update_child_adding_new_item_without_any_default_warehouse(self): + stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name + service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name + + po = create_purchase_order(do_not_save=1) + po.save() + po.submit() + first_item_of_po = po.get("items")[0] + + company_default = frappe.db.get_value("Company", po.company, "default_warehouse") + frappe.db.set_value("Company", po.company, "default_warehouse", None) + self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default) + + def get_trans_items(item_code): + return json.dumps( + [ + { + "item_code": first_item_of_po.item_code, + "rate": first_item_of_po.rate, + "qty": first_item_of_po.qty, + "docname": first_item_of_po.name, + }, + {"item_code": item_code, "rate": 200, "qty": 7}, + ] + ) + + self.assertRaisesRegex( + frappe.ValidationError, + "Cannot find a default warehouse", + update_child_qty_rate, + "Purchase Order", + get_trans_items(stock_item), + po.name, + ) + + update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name) + + po.reload() + self.assertEqual(po.get("items")[-1].item_code, service_item) + self.assertFalse(po.get("items")[-1].warehouse) def test_update_child_removing_item(self): po = create_purchase_order(do_not_save=1) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 9f9da2d9a94..3514c89b469 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -730,6 +730,7 @@ erpnext.utils.update_child_items = function (opts) { qty: d.qty, rate: d.rate, uom: d.uom, + warehouse: d.warehouse, fg_item: d.fg_item, fg_item_qty: d.fg_item_qty, description: d.description, @@ -822,6 +823,7 @@ erpnext.utils.update_child_items = function (opts) { item_name, bom_no, description, + warehouse, } = r.message; const row = dialog.fields_dict.trans_items.df.data.find( (row) => row.name == me.doc.name @@ -835,6 +837,7 @@ erpnext.utils.update_child_items = function (opts) { item_name: item_name, bom_no: bom_no, description: me.doc.description || description, + warehouse: me.doc.docname ? me.doc.warehouse : warehouse, }); dialog.fields_dict.trans_items.grid.refresh(); } @@ -922,11 +925,38 @@ erpnext.utils.update_child_items = function (opts) { }); } +<<<<<<< HEAD if ( ["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow ) { +======= + const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse"); + if (warehouse_df) { + fields.splice(3, 0, { + fieldtype: "Link", + fieldname: "warehouse", + options: "Warehouse", + in_list_view: 1, + label: __(warehouse_df.label), + // only new rows may set it, existing rows would leave their + // reserved qty stranded in the previous warehouse's bin + read_only_depends_on: "eval:doc.docname", + get_query: () => { + return { + filters: { + company: frm.doc.company, + is_group: 0, + disabled: 0, + }, + }; + }, + }); + } + + if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) { +>>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) fields.push( { fieldtype: "Link", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index f45632573b0..25b1cf6887b 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -33,6 +33,7 @@ from erpnext.selling.doctype.sales_order.sales_order import ( 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.get_item_details import get_bin_details +from erpnext.stock.utils import InvalidWarehouseCompany from erpnext.tests.utils import ERPNextTestSuite @@ -584,6 +585,116 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(updated_total, prev_total + 1400) self.assertNotEqual(updated_total_in_words, prev_total_in_words) + def test_update_child_adding_new_item_with_warehouse(self): + so = make_sales_order(item_code="_Test Item", qty=4) + + first_item_of_so = so.get("items")[0] + self.assertNotEqual(first_item_of_so.warehouse, "_Test Warehouse 2 - _TC") + + def get_trans_item(warehouse): + return json.dumps( + [ + { + "item_code": first_item_of_so.item_code, + "rate": first_item_of_so.rate, + "qty": first_item_of_so.qty, + "docname": first_item_of_so.name, + "warehouse": warehouse, + }, + {"item_code": "_Test Item 2", "rate": 200, "qty": 7, "warehouse": warehouse}, + ] + ) + + self.assertRaises( + InvalidWarehouseCompany, + update_child_qty_rate, + "Sales Order", + get_trans_item("_Test Warehouse 2 - _TC1"), + so.name, + ) + + self.assertRaisesRegex( + frappe.ValidationError, + "Group node warehouse", + update_child_qty_rate, + "Sales Order", + get_trans_item("_Test Warehouse Group - _TC"), + so.name, + ) + + if not frappe.db.exists("Warehouse", "_Test Disabled Warehouse - _TC"): + frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "_Test Disabled Warehouse", + "company": "_Test Company", + "disabled": 1, + } + ).insert() + + self.assertRaisesRegex( + frappe.ValidationError, + "Disabled Warehouse", + update_child_qty_rate, + "Sales Order", + get_trans_item("_Test Disabled Warehouse - _TC"), + so.name, + ) + + update_child_qty_rate("Sales Order", get_trans_item("_Test Warehouse 2 - _TC"), so.name) + + so.reload() + # the new row picks up the warehouse selected in the dialog + self.assertEqual(so.get("items")[-1].item_code, "_Test Item 2") + self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC") + # existing rows keep theirs, so their reserved qty stays in the same bin + self.assertEqual(so.get("items")[0].warehouse, first_item_of_so.warehouse) + + def test_update_child_adding_new_item_without_any_default_warehouse(self): + item_code = make_item("_Test Item Without Default Warehouse", {"is_stock_item": 1}).name + so = make_sales_order(item_code="_Test Item", qty=4) + existing_item = so.get("items")[0] + + # a company gets a default warehouse when its warehouses are created + company_default = frappe.db.get_value("Company", so.company, "default_warehouse") + frappe.db.set_value("Company", so.company, "default_warehouse", None) + self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default) + + def get_trans_items(warehouse=None): + new_row = {"item_code": item_code, "rate": 200, "qty": 7} + if warehouse: + new_row["warehouse"] = warehouse + + return json.dumps( + [ + { + "item_code": existing_item.item_code, + "rate": existing_item.rate, + "qty": existing_item.qty, + "docname": existing_item.name, + }, + new_row, + ] + ) + + # no default in the Item Master, Item Group, Brand or Company + self.assertRaisesRegex( + frappe.ValidationError, + "Cannot find a default warehouse", + update_child_qty_rate, + "Sales Order", + get_trans_items(), + so.name, + ) + + update_child_qty_rate("Sales Order", get_trans_items("_Test Warehouse - _TC"), so.name) + + so.reload() + self.assertEqual(len(so.get("items")), 2) + self.assertEqual(so.get("items")[0].warehouse, existing_item.warehouse) + self.assertEqual(so.get("items")[-1].item_code, item_code) + self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse - _TC") + def test_update_child_removing_item(self): so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]}) create_dn_against_so(so.name, 2) From e0d39074be7a232feaa2e66e3f14a81cfbb293e8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 12:39:39 +0530 Subject: [PATCH 2/3] chore: resolve conflict --- .../accounts/services/child_item_update.py | 618 ------------------ erpnext/controllers/accounts_controller.py | 48 +- erpnext/public/js/utils.js | 14 +- 3 files changed, 44 insertions(+), 636 deletions(-) delete mode 100644 erpnext/accounts/services/child_item_update.py diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py deleted file mode 100644 index d66c5621f7a..00000000000 --- a/erpnext/accounts/services/child_item_update.py +++ /dev/null @@ -1,618 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Child item update service: ChildItemUpdater class and helpers for the update_child_qty_rate API.""" - -import frappe -from frappe import _ -from frappe.model.workflow import get_workflow_name -from frappe.utils import flt, get_link_to_form, getdate - -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions -from erpnext.buying.utils import update_last_purchase_rate -from erpnext.stock.doctype.packed_item.packed_item import make_packing_list -from erpnext.stock.get_item_details import ( - get_bin_details, - get_conversion_factor, - get_item_warehouse_, -) -from erpnext.stock.utils import ( - is_group_warehouse, - validate_disabled_warehouse, - validate_warehouse_company, -) - - -class ChildItemUpdater: - """Validates and applies item-level edits on submitted orders and quotations.""" - - def __init__(self, parent_doctype: str, parent_doctype_name: str, child_docname: str = "items"): - self.parent_doctype = parent_doctype - self.parent_doctype_name = parent_doctype_name - self.child_docname = child_docname - self.parent = frappe.get_doc(parent_doctype, parent_doctype_name) - self.allow_zero_qty = get_allow_zero_qty(parent_doctype) - self._ordered_items: dict | None = None - self._purchased_items: dict | None = None - - def update(self, trans_items: str | list) -> None: - """Process item additions, edits, and deletions from trans_items JSON.""" - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items - from erpnext.selling.doctype.quotation.mapper import get_ordered_items - - data = frappe.parse_json(trans_items) - any_qty_changed = False - items_added_or_removed = False - any_conversion_factor_changed = False - - self._check_permissions("write") - - if self.parent_doctype == "Quotation": - self._ordered_items = get_ordered_items(self.parent.name) - items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items) - elif self.parent_doctype == "Supplier Quotation": - self._purchased_items = get_purchased_items(self.parent.name) - items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items) - else: - items_added_or_removed |= validate_and_delete_children(self.parent, data) - - for d in data: - new_child_flag = False - rate_unchanged = None - - if not d.get("item_code"): - continue - - if not d.get("docname"): - new_child_flag = True - items_added_or_removed = True - self._check_permissions("create") - child_item = self._get_new_child_item(d) - else: - self._check_permissions("write") - child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname")) - - change_state = get_child_item_change_state(self.parent_doctype, child_item, d) - rate_unchanged = change_state.rate_unchanged - any_conversion_factor_changed |= not change_state.conversion_factor_unchanged - if is_child_item_unchanged(change_state): - continue - - self._validate_quantity_and_rate(child_item, d, rate_unchanged) - - if flt(child_item.get("qty")) != flt(d.get("qty")): - any_qty_changed = True - - if self.parent.doctype in ("Sales Order", "Purchase Order") and self.parent.is_subcontracted: - self._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")) - child_item.description = d.get("description") - update_child_item_rate_and_discount( - self.parent_doctype, child_item, d, self.allow_zero_qty, rate_unchanged=rate_unchanged - ) - update_child_item_uom_and_weight(child_item, d) - - if d.get("delivery_date") and self.parent_doctype == "Sales Order": - child_item.delivery_date = d.get("delivery_date") - - if d.get("schedule_date") and self.parent_doctype == "Purchase Order": - child_item.schedule_date = d.get("schedule_date") - - if d.get("bom_no") and self.parent_doctype == "Sales Order": - child_item.bom_no = d.get("bom_no") - - child_item.flags.ignore_validate_update_after_submit = True - if new_child_flag: - self.parent.load_from_db() - child_item.idx = len(self.parent.items) + 1 - child_item.insert() - else: - child_item.save(ignore_permissions=True) - - self._post_update(any_qty_changed, items_added_or_removed, any_conversion_factor_changed) - - def _post_update( - self, any_qty_changed: bool, items_added_or_removed: bool, any_conversion_factor_changed: bool - ) -> None: - parent = self.parent - parent.reload() - parent.flags.ignore_validate_update_after_submit = True - parent.set_qty_as_per_stock_uom() - parent.calculate_taxes_and_totals() - parent.set_total_in_words() - - if self.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( - parent.doctype, parent.company, parent.base_grand_total - ) - - if self.parent_doctype != "Supplier Quotation": - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(parent).set_payment_schedule() - - if self.parent_doctype == "Purchase Order": - parent.validate_minimum_order_qty() - parent.validate_budget() - if parent.is_against_so(): - parent.update_status_updater() - elif self.parent_doctype == "Sales Order": - parent.check_credit_limit() - - for idx, row in enumerate(parent.get(self.child_docname), start=1): - row.idx = idx - - parent.save() - - if self.parent_doctype == "Purchase Order": - update_last_purchase_rate(parent, is_submit=1) - - if any_qty_changed or items_added_or_removed or any_conversion_factor_changed: - parent.update_prevdoc_status() - - parent.update_requested_qty() - parent.update_ordered_qty() - parent.update_ordered_and_reserved_qty() - parent.update_receiving_percentage() - - if parent.is_subcontracted and 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)) - ) - - elif self.parent_doctype == "Sales Order": - if parent.is_subcontracted and not parent.can_update_items(): - frappe.throw( - _( - "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." - ) - ) - parent.validate_selling_price() - parent.validate_for_duplicate_items() - parent.validate_warehouse() - parent.update_reserved_qty() - parent.update_project() - parent.update_prevdoc_status("submit") - parent.update_delivery_status() - - parent.reload() - self._validate_workflow() - - if self.parent_doctype in ("Purchase Order", "Sales Order"): - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() - - parent.validate_uom_is_integer("uom", "qty") - parent.validate_uom_is_integer("stock_uom", "stock_qty") - - if self.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, - ) - - if has_reserved_stock(parent.doctype, parent.name): - cancel_stock_reservation_entries(parent.doctype, parent.name) - if parent.per_picked == 0: - parent.create_stock_reservation_entries() - - def _check_permissions(self, perm_type: str = "create") -> None: - try: - self.parent.check_permission(perm_type) - except frappe.PermissionError: - actions = {"create": "add", "write": "update"} - frappe.throw( - _("You do not have permissions to {0} items in a {1}.").format( - actions[perm_type], self.parent_doctype - ), - title=_("Insufficient Permissions"), - ) - - def _validate_workflow(self) -> None: - workflow = get_workflow_name(self.parent.doctype) - if not workflow: - return - - workflow_doc = frappe.get_doc("Workflow", workflow) - current_state = self.parent.get(workflow_doc.workflow_state_field) - roles = frappe.get_roles() - - allowed = any( - state.state == current_state and (not state.allow_edit or state.allow_edit in roles) - for state in workflow_doc.states - ) - - if not allowed: - frappe.throw( - _("You are not allowed to update as per the conditions set in {0} Workflow.").format( - get_link_to_form("Workflow", workflow) - ), - title=_("Insufficient Permissions"), - ) - - def _get_new_child_item(self, item_row) -> "frappe.model.document.Document": - child_doctype = self.parent_doctype + " Item" - return set_order_defaults( - self.parent_doctype, - self.parent_doctype_name, - child_doctype, - self.child_docname, - item_row, - ) - - def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None: - if not flt(new_data.get("qty")) and not self.allow_zero_qty: - frappe.throw( - _("Row #{0}:Quantity for Item {1} cannot be zero.").format( - new_data.get("idx"), frappe.bold(new_data.get("item_code")) - ), - title=_("Invalid Qty"), - ) - - qty_limits = { - "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")), - "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")), - } - - if self.parent_doctype in qty_limits: - qty_field, error_message = qty_limits[self.parent_doctype] - if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): - frappe.throw( - _("Row #{0}:").format(new_data.get("idx")) + error_message, - title=_("Invalid Qty"), - ) - - if self.parent_doctype not in ("Quotation", "Supplier Quotation"): - return - - items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items - if not items_map: - return - - qty_to_check = items_map.get(child_item.name) - if not qty_to_check: - return - - if not rate_unchanged: - frappe.throw( - _( - "Cannot update rate as item {0} is already ordered or purchased against this quotation" - ).format(frappe.bold(new_data.get("item_code"))) - ) - - if flt(new_data.get("qty")) < qty_to_check: - frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) - - def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None: - 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"] - ) - ) - - 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"])) - - -@frappe.whitelist() -def update_child_qty_rate( - parent_doctype: str, trans_items: str | list, parent_doctype_name: str, child_docname: str = "items" -) -> None: - ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items) - - -def set_order_defaults( - parent_doctype: str, - parent_doctype_name: str, - child_doctype: str, - child_docname: str, - trans_item: dict, -) -> "frappe.model.document.Document": - """Return a new child item populated with item master defaults.""" - from erpnext.accounts.services.taxes import add_taxes_from_tax_template, set_child_tax_template_and_map - - p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) - child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname) - item = frappe.get_doc("Item", trans_item.get("item_code")) - - for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"): - child_item.update({field: item.get(field)}) - - date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" - child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) - child_item.stock_uom = item.stock_uom - child_item.uom = trans_item.get("uom") or item.stock_uom - child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype) - conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) - child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) - - if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): - child_item.base_rate = 1 - child_item.base_amount = 1 - - set_child_tax_template_and_map(item, child_item, p_doc) - add_taxes_from_tax_template(child_item, p_doc) - return child_item - - -def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None: - """Return the warehouse picked in the Update Items dialog, else the configured default. - - Validates whichever warehouse was resolved, since a submitted parent skips validate(). - """ - warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True) - - if not warehouse: - if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): - frappe.throw( - _( - "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." - ).format(frappe.bold(item.item_code)) - ) - return None - - validate_warehouse_company(warehouse, p_doc.company) - validate_disabled_warehouse(warehouse) - is_group_warehouse(warehouse) - return warehouse - - -def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool: - """Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse.""" - if child_doctype == "Sales Order Item": - return True - - if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): - return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier) - - return False - - -def validate_child_on_delete(row, parent, ordered_item=None) -> None: - """Raise if a partially transacted child item is being deleted.""" - if parent.doctype == "Sales Order": - if flt(row.delivered_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been delivered").format( - row.idx, row.item_code - ) - ) - if flt(row.work_order_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format( - row.idx, row.item_code - ) - ) - if flt(row.ordered_qty): - frappe.throw( - _( - "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order." - ).format(row.idx, row.item_code) - ) - - if parent.doctype == "Purchase Order" and flt(row.received_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been received").format( - row.idx, row.item_code - ) - ) - - if parent.doctype in ("Purchase Order", "Sales Order") and flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code - ) - ) - - if parent.doctype == "Quotation" and ordered_item and ordered_item.get(row.name): - frappe.throw(_("Cannot delete an item which has been ordered")) - - -def update_bin_on_delete(row, doctype: str) -> None: - """Update bin quantities after a child item row is deleted.""" - from erpnext.stock.stock_balance import ( - get_indented_qty, - get_ordered_qty, - get_reserved_qty, - update_bin_qty, - ) - - qty_dict = {} - - if doctype == "Sales Order": - qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse) - else: - if row.material_request_item: - qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse) - qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - - if row.warehouse: - update_bin_qty(row.item_code, row.warehouse, qty_dict) - - -def validate_and_delete_children(parent, data, ordered_item=None) -> bool: - """Delete child rows not present in data; return True if any were removed.""" - updated_item_names = [d.get("docname") for d in data] - deleted_children = [item for item in parent.items if item.name not in updated_item_names] - - for d in deleted_children: - validate_child_on_delete(d, parent, ordered_item) - d.flags.ignore_permissions = True - d.cancel() - d.delete() - - if parent.doctype == "Purchase Order": - parent.update_ordered_qty_in_so_for_removed_items(deleted_children) - - if parent.doctype not in ("Quotation", "Supplier Quotation"): - parent.update_prevdoc_status() - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) - - return bool(deleted_children) - - -def get_allow_zero_qty(parent_doctype: str) -> bool: - if parent_doctype == "Sales Order": - return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False - if parent_doctype == "Purchase Order": - return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False - return False - - -def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict: - prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) - prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty")) - prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty")) - prev_con_fac = flt(child_item.get("conversion_factor")) - new_con_fac = flt(new_data.get("conversion_factor")) - - if parent_doctype == "Sales Order": - prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date") - elif parent_doctype == "Purchase Order": - prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date") - else: - prev_date, new_date = None, None - - if parent_doctype in ("Quotation", "Supplier Quotation"): - date_unchanged = False - else: - prev_date = getdate(prev_date) if prev_date else None - new_date = getdate(new_date) if new_date else None - date_unchanged = prev_date == new_date - - return frappe._dict( - rate_unchanged=prev_rate == new_rate, - qty_unchanged=prev_qty == new_qty, - fg_qty_unchanged=prev_fg_qty == new_fg_qty, - uom_unchanged=child_item.get("uom") == new_data.get("uom"), - conversion_factor_unchanged=prev_con_fac == new_con_fac, - date_unchanged=date_unchanged, - description_unchanged=child_item.get("description") == new_data.get("description"), - ) - - -def is_child_item_unchanged(change_state: frappe._dict) -> bool: - return ( - change_state.rate_unchanged - and change_state.qty_unchanged - and change_state.fg_qty_unchanged - and change_state.conversion_factor_unchanged - and change_state.uom_unchanged - and change_state.date_unchanged - and change_state.description_unchanged - ) - - -def update_child_item_rate_and_discount( - parent_doctype: str, - child_item, - new_data, - allow_zero_qty: bool, - rate_unchanged: bool | None = None, -) -> None: - rate_precision = child_item.precision("rate") or 2 - qty_precision = child_item.precision("qty") or 2 - - if rate_unchanged is None: - rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate")) - - if not rate_unchanged and not child_item.get("qty") and allow_zero_qty: - frappe.throw(_("Rate of '{0}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - - row_rate = flt(new_data.get("rate"), rate_precision) - - if parent_doctype in ("Purchase Order", "Sales Order"): - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(new_data.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) - ) - - child_item.rate = row_rate - - if parent_doctype not in ("Sales Order", "Purchase Order") or not flt(child_item.price_list_rate): - return - - if flt(child_item.rate) > flt(child_item.price_list_rate): - child_item.discount_percentage = 0 - child_item.discount_amount = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = child_item.price_list_rate - child_item.discount_percentage = 0 - child_item.discount_amount = flt(child_item.rate_with_margin) - flt(child_item.rate) - - -def update_child_item_uom_and_weight(child_item, new_data) -> None: - conv_fac_precision = child_item.precision("conversion_factor") or 2 - - if new_data.get("conversion_factor"): - if child_item.stock_uom == child_item.uom: - child_item.conversion_factor = 1 - else: - child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision) - - if new_data.get("uom"): - child_item.uom = new_data.get("uom") - conversion_factor = flt( - get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor") - ) - child_item.conversion_factor = ( - flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor - ) - - if child_item.get("weight_per_unit"): - child_item.total_weight = flt( - child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, - child_item.precision("total_weight"), - ) - - -def check_if_child_table_updated( - child_table_before_update, child_table_after_update, fields_to_check -) -> bool: - """Return True if any accounting-relevant field changed in a child table.""" - fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"] - - for index, item in enumerate(child_table_before_update): - for field in fields_to_check: - if child_table_after_update[index].get(field) != item.get(field): - return True - - return False diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index fbe8b348000..238a4ddf783 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -77,6 +77,11 @@ from erpnext.stock.get_item_details import ( get_item_tax_map, get_item_warehouse_, ) +from erpnext.stock.utils import ( + is_group_warehouse, + validate_disabled_warehouse, + validate_warehouse_company, +) from erpnext.utilities.regional import temporary_flag from erpnext.utilities.transaction_base import TransactionBase @@ -3777,7 +3782,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) child_item.stock_uom = item.stock_uom child_item.uom = trans_item.get("uom") or item.stock_uom - child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True) + child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype) conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) @@ -3786,20 +3791,45 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 - if child_doctype == "Sales Order Item": - child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True) - if not child_item.warehouse: - frappe.throw( - _( - "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings." - ).format(frappe.bold(item.item_code)) - ) set_child_tax_template_and_map(item, child_item, p_doc) add_taxes_from_tax_template(child_item, p_doc) return child_item +def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None: + """Return the warehouse picked in the Update Items dialog, else the configured default. + + Validates whichever warehouse was resolved, since a submitted parent skips validate(). + """ + warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True) + + if not warehouse: + if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): + frappe.throw( + _( + "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." + ).format(frappe.bold(item.item_code)) + ) + return None + + validate_warehouse_company(warehouse, p_doc.company) + validate_disabled_warehouse(warehouse) + is_group_warehouse(warehouse) + return warehouse + + +def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool: + """Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse.""" + if child_doctype == "Sales Order Item": + return True + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier) + + return False + + def validate_child_on_delete(row, parent, ordered_item=None): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 3514c89b469..b847595d021 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -925,13 +925,6 @@ erpnext.utils.update_child_items = function (opts) { }); } -<<<<<<< HEAD - if ( - ["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && - frm.doc.is_subcontracted && - !frm.doc.is_old_subcontracting_flow - ) { -======= const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse"); if (warehouse_df) { fields.splice(3, 0, { @@ -955,8 +948,11 @@ erpnext.utils.update_child_items = function (opts) { }); } - if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) { ->>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) + if ( + ["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && + frm.doc.is_subcontracted && + !frm.doc.is_old_subcontracting_flow + ) { fields.push( { fieldtype: "Link", From d8bbe865a8c1d6602599d32d3df06d81d752bd19 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 13:01:00 +0530 Subject: [PATCH 3/3] fix: use stock settings for warehouse defaults --- .../doctype/purchase_order/test_purchase_order.py | 10 +++++++--- erpnext/controllers/accounts_controller.py | 2 +- .../selling/doctype/sales_order/test_sales_order.py | 11 ++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 423338b39ac..2cc53d6ad95 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -302,9 +302,11 @@ class TestPurchaseOrder(ERPNextTestSuite): po.submit() first_item_of_po = po.get("items")[0] - company_default = frappe.db.get_value("Company", po.company, "default_warehouse") - frappe.db.set_value("Company", po.company, "default_warehouse", None) - self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default) + stock_settings_default = frappe.db.get_single_value("Stock Settings", "default_warehouse") + frappe.db.set_single_value("Stock Settings", "default_warehouse", None) + self.addCleanup( + frappe.db.set_single_value, "Stock Settings", "default_warehouse", stock_settings_default + ) def get_trans_items(item_code): return json.dumps( @@ -517,11 +519,13 @@ class TestPurchaseOrder(ERPNextTestSuite): "item_code": item, "rate": 100, "qty": 1, + "warehouse": po.items[0].warehouse, }, # added item whose tax account head already exists in PO { "item_code": new_item_with_tax.name, "rate": 100, "qty": 1, + "warehouse": po.items[0].warehouse, }, # added item whose tax account head is missing in PO ] ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 238a4ddf783..dd4f51025f7 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3808,7 +3808,7 @@ def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: s if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): frappe.throw( _( - "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." + "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in Stock Settings." ).format(frappe.bold(item.item_code)) ) return None diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 25b1cf6887b..5a52e051882 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -655,10 +655,11 @@ class TestSalesOrder(ERPNextTestSuite): so = make_sales_order(item_code="_Test Item", qty=4) existing_item = so.get("items")[0] - # a company gets a default warehouse when its warehouses are created - company_default = frappe.db.get_value("Company", so.company, "default_warehouse") - frappe.db.set_value("Company", so.company, "default_warehouse", None) - self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default) + stock_settings_default = frappe.db.get_single_value("Stock Settings", "default_warehouse") + frappe.db.set_single_value("Stock Settings", "default_warehouse", None) + self.addCleanup( + frappe.db.set_single_value, "Stock Settings", "default_warehouse", stock_settings_default + ) def get_trans_items(warehouse=None): new_row = {"item_code": item_code, "rate": 200, "qty": 7} @@ -677,7 +678,7 @@ class TestSalesOrder(ERPNextTestSuite): ] ) - # no default in the Item Master, Item Group, Brand or Company + # no default in the Item Master, Item Group, Brand or Stock Settings self.assertRaisesRegex( frappe.ValidationError, "Cannot find a default warehouse",