fix(stock): check warehouse user permission in get_bin_details

(cherry picked from commit 6141c967a4)

# Conflicts:
#	erpnext/accounts/services/child_item_update.py
#	erpnext/controllers/selling_controller.py
#	erpnext/selling/doctype/sales_order/mapper.py
#	erpnext/stock/doctype/stock_entry/stock_entry.py
#	erpnext/stock/get_item_details.py
This commit is contained in:
Mihir Kandoi
2026-10-22 15:54:33 +00:00
committed by Mergify
parent 90e583878c
commit ea9e7cccfd
5 changed files with 4147 additions and 2 deletions
@@ -0,0 +1,829 @@
# 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."""
from collections import defaultdict
from functools import cached_property
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.item.item import validate_item_uoms
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._blanket_order_change = defaultdict(float)
self._transacted_stock_qty: dict | None = None
self._rfqs_before_update: set[str] = set()
@cached_property
def _rows_with_kept_reservation(self) -> set[str]:
return get_rows_with_kept_reservation(self.parent)
def update(self, trans_items: str | list) -> None:
"""Process item additions, edits, and deletions from trans_items JSON."""
from erpnext.buying.doctype.supplier_quotation.mapper import (
get_ordered_items as get_ordered_supplier_quotation_items,
)
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
data = frappe.parse_json(trans_items)
had_mapped_discount = self.parent.has_mapped_discount
any_qty_changed = False
items_added_or_removed = False
any_conversion_factor_changed = False
self._check_permissions("write")
self._validate_parent_status()
self._validate_changed_uoms(data)
if self.parent_doctype == "Quotation":
self._transacted_stock_qty = get_ordered_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty
)
elif self.parent_doctype == "Supplier Quotation":
self._rfqs_before_update = self.parent.get_request_for_quotations()
self._transacted_stock_qty = get_ordered_supplier_quotation_items(self.parent.name)
items_added_or_removed |= validate_and_delete_children(
self.parent, data, self._transacted_stock_qty
)
else:
self._track_deleted_blanket_order_rows(data)
items_added_or_removed |= validate_and_delete_children(self.parent, data)
frappe.db.savepoint("update_child_items")
try:
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"))
d["conversion_factor"] = self._get_new_conversion_factor(child_item, d)
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
if child_item.get("closed"):
frappe.throw(
_(
"Row #{0}: Cannot change item {1} because it is closed. Reopen the row first."
).format(child_item.idx, child_item.item_code)
)
self._track_blanket_order_change(child_item, d)
self._validate_quantity_and_rate(child_item, d, rate_unchanged)
if not new_child_flag:
self._validate_uom_unchanged_after_fulfilment(child_item, d, change_state)
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._validate_blanket_order_changes()
except Exception:
frappe.db.rollback(save_point="update_child_items")
raise
self._post_update(
any_qty_changed, items_added_or_removed, any_conversion_factor_changed, had_mapped_discount
)
def _post_update(
self,
any_qty_changed: bool,
items_added_or_removed: bool,
any_conversion_factor_changed: bool,
had_mapped_discount: bool,
) -> None:
parent = self.parent
parent.reload()
if had_mapped_discount:
parent.clear_stale_mapped_discount_total()
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.set_skip_delivery()
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()
elif self.parent_doctype == "Supplier Quotation":
parent.update_rfq_supplier_status(parent.docstatus.is_submitted(), self._rfqs_before_update)
parent.set_status(update=True)
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 parent.per_picked == 0 and has_reserved_stock(parent.doctype, parent.name):
cancel_stock_reservation_entries(parent.doctype, parent.name)
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 _validate_parent_status(self) -> None:
if self.parent_doctype == "Purchase Order" and self.parent.status in (
"Closed",
"On Hold",
"Delivered",
):
frappe.throw(
_("Cannot update items of Purchase Order {0} because it is {1}.").format(
self.parent.name, _(self.parent.status)
)
)
def _validate_changed_uoms(self, data: list) -> None:
current_uoms = {row.name: row.uom for row in self.parent.get(self.child_docname)}
validate_item_uoms(
[frappe._dict(d) for d in data if d.get("uom") != current_uoms.get(d.get("docname"))]
)
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 _get_new_conversion_factor(self, child_item, new_data: dict) -> float:
current_factor = flt(child_item.get("conversion_factor")) or 1
uom = new_data.get("uom") or child_item.get("uom")
if uom == child_item.get("stock_uom"):
return 1
requested_factor = flt(new_data.get("conversion_factor"))
if requested_factor:
return requested_factor
if uom == child_item.get("uom"):
return current_factor
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
def _track_blanket_order_change(self, child_item, new_data: dict) -> None:
if not child_item.get("blanket_order"):
return
new_stock_qty = flt(
flt(new_data.get("qty")) * flt(new_data.get("conversion_factor")),
child_item.precision("stock_qty"),
)
key = (child_item.blanket_order, child_item.item_code)
self._blanket_order_change[key] += new_stock_qty - flt(child_item.stock_qty)
def _track_deleted_blanket_order_rows(self, data: list) -> None:
kept_rows = {d.get("docname") for d in data}
for row in self.parent.items:
if row.name not in kept_rows and not row.get("closed") and row.get("blanket_order"):
self._blanket_order_change[(row.blanket_order, row.item_code)] -= flt(row.stock_qty)
def _validate_blanket_order_changes(self) -> None:
for (blanket_order_name, item_code), change in sorted(self._blanket_order_change.items()):
if change <= 0:
continue
blanket_order = frappe.get_doc("Blanket Order", blanket_order_name, for_update=True)
blanket_order.validate_can_be_ordered(self.parent.transaction_date)
blanket_order.validate_items_are_open([item_code])
blanket_order.validate_qty_can_be_ordered({item_code: change}, self.parent_doctype)
def _validate_uom_unchanged_after_fulfilment(self, child_item, new_data: dict, change_state) -> None:
uom_changed = new_data.get("uom") not in (None, child_item.uom)
if not uom_changed and change_state.conversion_factor_unchanged:
return
if child_item.name in self._rows_with_kept_reservation:
frappe.throw(
_(
"Row #{0}: Unreserve stock before changing the UOM or conversion factor of Item {1}."
).format(new_data.get("idx") or child_item.idx, frappe.bold(child_item.item_code)),
title=_("Stock Reserved"),
)
fulfilled_qty_field = {"Sales Order": "delivered_qty", "Purchase Order": "received_qty"}.get(
self.parent_doctype
)
if not fulfilled_qty_field or not flt(child_item.get(fulfilled_qty_field)):
return
frappe.throw(
_(
"Row #{0}: Cannot change the UOM or conversion factor of Item {1} after part of it was received or delivered."
).format(new_data.get("idx") or child_item.idx, frappe.bold(child_item.item_code)),
title=_("Invalid UOM"),
)
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.")),
}
old_conversion_factor = flt(child_item.get("conversion_factor")) or 1
new_conversion_factor = flt(new_data.get("conversion_factor")) or old_conversion_factor
new_stock_qty = flt(new_data.get("qty")) * new_conversion_factor
if self.parent_doctype in qty_limits:
qty_field, error_message = qty_limits[self.parent_doctype]
old_stock_qty = flt(child_item.get(qty_field)) * old_conversion_factor
if new_stock_qty < old_stock_qty:
frappe.throw(
_("Row #{0}:").format(new_data.get("idx")) + error_message,
title=_("Invalid Qty"),
)
if child_item.name in self._rows_with_kept_reservation:
self._validate_qty_covers_reservation(child_item, new_data, new_stock_qty, old_stock_qty)
if not self._transacted_stock_qty:
return
old_stock_qty = self._transacted_stock_qty.get(child_item.name)
if not old_stock_qty:
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 new_stock_qty < old_stock_qty:
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
def _validate_qty_covers_reservation(
self, child_item, new_data: dict, new_stock_qty: float, delivered_stock_qty: float
) -> None:
reserved_qty = flt(child_item.get("stock_reserved_qty"))
# Packed Items reserve in component units, so a reserved bundle keeps its qty.
min_stock_qty = delivered_stock_qty + reserved_qty if reserved_qty else flt(child_item.stock_qty)
if flt(new_stock_qty - min_stock_qty, child_item.precision("stock_qty")) < 0:
frappe.throw(
_(
"Row #{0}: Cannot set quantity less than delivered and reserved quantity. Unreserve stock before reducing the quantity."
).format(new_data.get("idx") or child_item.idx),
title=_("Invalid Qty"),
)
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 get_rows_with_kept_reservation(parent) -> set[str]:
"""Rows of a picked Sales Order whose stock stays reserved, directly or through their Packed Items."""
if parent.doctype != "Sales Order" or not flt(parent.per_picked):
return set()
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_details_for_voucher,
)
bundle_rows = {d.name: d.parent_detail_docname for d in parent.get("packed_items")}
reserved = get_sre_reserved_qty_details_for_voucher(parent.doctype, parent.name)
return {bundle_rows.get(row, row) for row in reserved}
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_no_issued_proforma(rows) -> None:
"""Raise if a Sales Order row being deleted has an issued Proforma Invoice."""
if not rows:
return
proformed = set(
frappe.get_all(
"Proforma Invoice Item",
filters={"so_detail": ["in", [row.name for row in rows]], "docstatus": 1},
pluck="so_detail",
)
)
for row in rows:
if row.name in proformed:
frappe.throw(
_("Row #{0}: Cannot delete item {1} which has an issued Proforma Invoice.").format(
row.idx, row.item_code
)
)
def validate_no_kept_reservation(rows, parent) -> None:
"""Raise if a Sales Order row being deleted still holds reserved stock."""
if not rows:
return
reserved_rows = get_rows_with_kept_reservation(parent)
for row in rows:
if row.name in reserved_rows:
frappe.throw(
_("Row #{0}: Unreserve stock before deleting item {1}.").format(
row.idx, frappe.bold(row.item_code)
),
title=_("Stock Reserved"),
)
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]
# A closed row is left out of the payload rather than deleted, so its absence
# must not be read as a removal.
deleted_children = [
item for item in parent.items if item.name not in updated_item_names and not item.get("closed")
]
if parent.doctype == "Sales Order":
validate_no_issued_proforma(deleted_children)
validate_no_kept_reservation(deleted_children, parent)
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:
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"))
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")) 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
+7 -1
View File
@@ -10,8 +10,14 @@ from erpnext.accounts.party import render_address
from erpnext.controllers.accounts_controller import _get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, is_batch_expired
from erpnext.controllers.stock_controller import StockController
<<<<<<< HEAD
from erpnext.stock.doctype.item.item import set_item_default
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
=======
from erpnext.selling.doctype.customer.customer import is_customer_blocked
from erpnext.stock.doctype.item.item import set_item_default, validate_item_uoms
from erpnext.stock.get_item_details import _get_bin_details, get_conversion_factor
>>>>>>> 6141c96 (fix(stock): check warehouse user permission in get_bin_details)
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
@@ -26,7 +32,7 @@ class SellingController(StockController):
company = self.company
item.update(
get_bin_details(
_get_bin_details(
item.item_code, item.warehouse, company=company, include_child_warehouses=True
)
)
File diff suppressed because it is too large Load Diff
@@ -43,8 +43,8 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
OpeningEntryAccountError,
)
from erpnext.stock.get_item_details import (
_get_bin_details,
get_barcode_data,
get_bin_details,
get_conversion_factor,
get_default_cost_center,
)
@@ -209,7 +209,17 @@ class StockEntry(StockController):
def update_items_from_bin_details(self):
for item in self.get("items"):
<<<<<<< HEAD
item.update(get_bin_details(item.item_code, item.s_warehouse))
=======
item.update(_get_bin_details(item.item_code, item.s_warehouse or item.t_warehouse))
def before_insert(self):
if self.subcontracting_order and frappe.get_cached_value(
"Subcontracting Order", self.subcontracting_order, "reserve_stock"
):
self.set_serial_batch_from_reserved_entry()
>>>>>>> 6141c96 (fix(stock): check warehouse user permission in get_bin_details)
def before_validate(self):
from erpnext.stock.doctype.putaway_rule.putaway_rule import _apply_putaway_rule
File diff suppressed because it is too large Load Diff