diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index cb3de5e560d..f871907ce6b 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -8,10 +8,9 @@ from typing import Literal import frappe import frappe.utils from frappe import _, qb -from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document from frappe.query_builder.functions import Sum -from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, parse_json +from frappe.utils import cint, flt, get_link_to_form, getdate from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( unlink_inter_company_doc, @@ -23,13 +22,13 @@ from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, ) from erpnext.selling.doctype.customer.customer import check_credit_limit +from erpnext.selling.doctype.sales_order.services.delivery_schedule import DeliveryScheduleService +from erpnext.selling.doctype.sales_order.services.status import StatusService +from erpnext.selling.doctype.sales_order.services.stock_reservation import StockReservationService +from erpnext.selling.doctype.sales_order.services.subcontracting import SubcontractingService from erpnext.stock.doctype.packed_item.packed_item import make_packing_list -from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details_for_voucher, - has_reserved_stock, -) +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import has_reserved_stock from erpnext.stock.get_item_details import get_default_bom -from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -211,13 +210,7 @@ class SalesOrder(SellingController): self.set_onload("has_reserved_stock", True) def can_update_items(self) -> bool: - result = True - - if self.is_subcontracted: - if frappe.db.exists("Subcontracting Inward Order", {"sales_order": self.name, "docstatus": 1}): - result = False - - return result + return SubcontractingService(self).can_update_items() def before_validate(self): self.set_has_unit_price_items() @@ -233,7 +226,7 @@ class SalesOrder(SellingController): self.validate_for_items() self.validate_warehouse() self.validate_drop_ship() - self.validate_reserved_stock() + StockReservationService(self).validate_reserved_stock() self.validate_serial_no_based_delivery() validate_against_blanket_order(self) validate_inter_company_party( @@ -248,54 +241,14 @@ class SalesOrder(SellingController): make_packing_list(self) self.validate_with_previous_doc() - self.validate_fg_item_for_subcontracting() + SubcontractingService(self).validate_fg_item_for_subcontracting() self.set_status() - if not self.billing_status: - self.billing_status = "Not Billed" - if not self.delivery_status: - self.delivery_status = "Not Delivered" - if not self.advance_payment_status: - self.advance_payment_status = "Not Requested" + StatusService(self).set_default_statuses() self.reset_default_field_value("set_warehouse", "items", "warehouse") if not self.get("is_subcontracted"): - self.enable_auto_reserve_stock() - - def validate_fg_item_for_subcontracting(self): - if self.is_subcontracted: - for item in self.items: - if not item.fg_item: - frappe.throw( - _("Row #{0}: Finished Good Item is not specified for service item {1}").format( - item.idx, item.item_code - ) - ) - else: - if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"): - frappe.throw( - _("Row #{0}: Finished Good Item {1} must be a sub-contracted item").format( - item.idx, item.fg_item - ) - ) - if not frappe.db.get_value( - "Subcontracting BOM", - {"finished_good": item.fg_item, "is_active": 1}, - "finished_good_bom", - ) and not frappe.get_value("Item", item.fg_item, "default_bom"): - frappe.throw( - _("Row #{0}: BOM not found for FG Item {1}").format(item.idx, item.fg_item) - ) - if not item.fg_item_qty: - frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx)) - else: - for item in self.items: - item.set("fg_item", None) - item.set("fg_item_qty", 0) - - def enable_auto_reserve_stock(self): - if self.is_new() and frappe.get_single_value("Stock Settings", "auto_reserve_stock"): - self.reserve_stock = 1 + StockReservationService(self).enable_auto_reserve_stock() def set_has_unit_price_items(self): """ @@ -492,7 +445,7 @@ class SalesOrder(SellingController): super().update_prevdoc_status() self.check_credit_limit() self.update_reserved_qty() - self.delete_removed_delivery_schedule_items() + DeliveryScheduleService(self).delete_removed_delivery_schedule_items() frappe.get_cached_doc("Authorization Control").validate_approving_authority( self.doctype, self.company, self.base_grand_total, self @@ -511,13 +464,6 @@ class SalesOrder(SellingController): if self.get("reserve_stock") and not self.get("is_subcontracted"): self.create_stock_reservation_entries() - def delete_removed_delivery_schedule_items(self): - items = [d.name for d in self.get("items")] - doctype = frappe.qb.DocType("Delivery Schedule Item") - frappe.qb.from_(doctype).delete().where( - (doctype.sales_order == self.name) & (doctype.sales_order_item.notin(items)) - ).run() - def on_cancel(self): self.ignore_linked_doctypes = ( "GL Entry", @@ -533,7 +479,7 @@ class SalesOrder(SellingController): if self.status == "Closed": frappe.throw(_("Closed order cannot be cancelled. Unclose to cancel.")) - self.delete_delivery_schedule_items() + DeliveryScheduleService(self).delete_delivery_schedule_items() self.check_nextdoc_docstatus() self.update_reserved_qty() self.update_project() @@ -586,61 +532,11 @@ class SalesOrder(SellingController): ) ) - def check_modified_date(self): - mod_db = frappe.db.get_value("Sales Order", self.name, "modified") - if mod_db and cstr(mod_db) != cstr(self.modified): - frappe.throw(_("{0} {1} has been modified. Please refresh.").format(self.doctype, self.name)) - def update_status(self, status): - self.check_modified_date() - self.set_status(update=True, status=status) - # Upon Sales Order Re-open, check for credit limit. - # Limit should be checked after the 'Hold/Closed' status is reset. - if status == "Draft" and self.docstatus == 1: - self.check_credit_limit() - self.update_reserved_qty() - self.update_subcontracting_order_status() - self.notify_update() - clear_doctype_notifications(self) - self.update_blanket_order() - - def update_subcontracting_order_status(self): - from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import ( - update_subcontracting_inward_order_status as update_scio_status, - ) - - if self.is_subcontracted: - scio = frappe.get_cached_value( - "Subcontracting Inward Order", {"sales_order": self.name, "docstatus": 1}, "name" - ) - - if scio: - update_scio_status(scio, "Closed" if self.status == "Closed" else None) + StatusService(self).update_status(status) def update_reserved_qty(self, so_item_rows=None): - """update requested qty (before ordered_qty is updated)""" - item_wh_list = [] - - def _valid_for_reserve(item_code, warehouse): - if ( - item_code - and warehouse - and [item_code, warehouse] not in item_wh_list - and frappe.get_cached_value("Item", item_code, "is_stock_item") - ): - item_wh_list.append([item_code, warehouse]) - - for d in self.get("items"): - if (not so_item_rows or d.name in so_item_rows) and not d.delivered_by_supplier: - if self.has_product_bundle(d.item_code): - for p in self.get("packed_items"): - if p.parent_detail_docname == d.name and p.parent_item == d.item_code: - _valid_for_reserve(p.item_code, p.warehouse) - else: - _valid_for_reserve(d.item_code, d.warehouse) - - for item_code, warehouse in item_wh_list: - update_bin_qty(item_code, warehouse, {"reserved_qty": get_reserved_qty(item_code, warehouse)}) + StockReservationService(self).update_reserved_qty(so_item_rows) def on_update_after_submit(self): self.calculate_commission() @@ -672,65 +568,14 @@ class SalesOrder(SellingController): def update_delivery_status(self): """Update delivery status from Purchase Order for drop shipping""" - tot_qty, delivered_qty = 0.0, 0.0 - - for item in self.items: - if item.delivered_by_supplier: - item_delivered_qty = frappe.get_all( - "Purchase Order Item", - {"sales_order_item": item.name, "docstatus": 1}, - [{"SUM": "received_qty", "AS": "received_qty"}], - pluck="received_qty", - )[0] - item.db_set("delivered_qty", flt(item_delivered_qty), update_modified=False) - - delivered_qty += min(item.delivered_qty, item.qty) - tot_qty += item.qty - - if tot_qty != 0: - self.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False) + StatusService(self).update_delivery_status() def update_picking_status(self): - total_picked_qty = 0.0 - total_qty = 0.0 - per_picked = 0.0 - - for so_item in self.items: - if cint( - frappe.get_cached_value("Item", so_item.item_code, "is_stock_item") - ) or self.has_product_bundle(so_item.item_code): - total_picked_qty += flt(so_item.picked_qty) - total_qty += flt(so_item.stock_qty) - - if total_picked_qty and total_qty: - per_picked = total_picked_qty / total_qty * 100 - - pick_percentage = frappe.get_single_value("Stock Settings", "over_picking_allowance") - if pick_percentage: - total_qty += flt(total_qty) * (pick_percentage / 100) - - if total_picked_qty > total_qty: - frappe.throw( - _( - "Total Picked Quantity {0} is more than ordered qty {1}. You can set the Over Picking Allowance in Stock Settings." - ).format(total_picked_qty, total_qty) - ) - - self.db_set("per_picked", flt(per_picked), update_modified=False) + StatusService(self).update_picking_status() def set_indicator(self): """Set indicator for portal""" - self.indicator_color = { - "Draft": "red", - "On Hold": "orange", - "To Deliver and Bill": "orange", - "To Bill": "orange", - "To Deliver": "orange", - "Completed": "green", - "Cancelled": "red", - }.get(self.status, "blue") - - self.indicator_title = _(self.status) + StatusService(self).set_indicator() def on_recurring(self, reference_doc, auto_repeat_doc): def _get_delivery_date(ref_doc_delivery_date, red_doc_transaction_date, transaction_date): @@ -798,31 +643,10 @@ class SalesOrder(SellingController): ).format(item.item_code) ) - def validate_reserved_stock(self): - """Clean reserved stock flag for non-stock Item""" - - enable_stock_reservation = frappe.get_single_value("Stock Settings", "enable_stock_reservation") - - for item in self.items: - if item.reserve_stock and (not enable_stock_reservation or not cint(item.is_stock_item)): - item.reserve_stock = 0 - @frappe.whitelist() - def has_unreserved_stock(self, table_name: str = "items") -> bool: - """Returns True if there is any unreserved item in the Sales Order.""" - - reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name) - - data = {} - for item in self.get(table_name): - if not item.get("reserve_stock"): - continue - - unreserved_qty = get_unreserved_qty(item, reserved_qty_details) - if unreserved_qty > 0: - data[item.name] = unreserved_qty - - return data + def has_unreserved_stock(self, table_name: str = "items") -> dict: + """Returns unreserved qty per item if there is any unreserved item in the Sales Order.""" + return StockReservationService(self).has_unreserved_stock(table_name) @frappe.whitelist() def create_stock_reservation_entries( @@ -832,58 +656,14 @@ class SalesOrder(SellingController): notify: bool = True, ) -> None: """Creates Stock Reservation Entries for Sales Order Items.""" - - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - create_stock_reservation_entries_for_so_items as create_stock_reservation_entries, + StockReservationService(self).create_stock_reservation_entries( + items_details, from_voucher_type, notify ) - packed_items = [] - if items_details: - for item in items_details: - if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")): - item["qty"] = item.pop("qty_to_reserve") - packed_items.append(item) - - for item in packed_items: - items_details.remove(item) - - sre_count = 0 - if items_details != []: - sre_count = create_stock_reservation_entries( - sales_order=self, - items_details=items_details, - from_voucher_type=from_voucher_type, - notify=notify, - ) - - items = [] - if packed_items: - items = packed_items - elif not items_details: - items = [item for item in self.packed_items if item.reserve_stock] - - if items: - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation - - stock_reservation = StockReservation(doc=self, items=items) - stock_reservation.table_name = "packed_items" - stock_reservation.qty_field = "qty" - is_sre_created = stock_reservation.make_stock_reservation_entries() - - if notify and is_sre_created and not sre_count: - frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") - @frappe.whitelist() def cancel_stock_reservation_entries(self, sre_list: list | None = None, notify: bool = True) -> None: """Cancel Stock Reservation Entries for Sales Order Items.""" - - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - cancel_stock_reservation_entries, - ) - - cancel_stock_reservation_entries( - voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify - ) + StockReservationService(self).cancel_stock_reservation_entries(sre_list, notify) def set_missing_values(self, for_validate=False): super().set_missing_values(for_validate) @@ -895,102 +675,11 @@ class SalesOrder(SellingController): @frappe.whitelist() def get_delivery_schedule(self, sales_order_item: str): - return frappe.get_all( - "Delivery Schedule Item", - filters={"sales_order_item": sales_order_item, "sales_order": self.name}, - fields=["delivery_date", "qty", "name"], - order_by="delivery_date asc", - ) + return DeliveryScheduleService(self).get_delivery_schedule(sales_order_item) @frappe.whitelist() def create_delivery_schedule(self, child_row: dict | frappe._dict, schedules: str | list[dict]): - if isinstance(child_row, dict): - child_row = frappe._dict(child_row) - - if isinstance(schedules, str): - schedules = parse_json(schedules) - - names = [] - first_delivery_date = None - for row in schedules: - row = frappe._dict(row) - - if not first_delivery_date: - first_delivery_date = row.delivery_date - - data = { - "delivery_date": row.delivery_date, - "qty": row.qty, - "uom": child_row.uom, - "stock_uom": child_row.stock_uom, - "item_code": child_row.item_code, - "conversion_factor": child_row.conversion_factor or 1.0, - "warehouse": child_row.warehouse, - "sales_order_item": child_row.name, - "sales_order": self.name, - "stock_qty": row.qty * (child_row.conversion_factor or 1.0), - } - - if frappe.db.exists("Delivery Schedule Item", row.name): - doc = frappe.get_doc("Delivery Schedule Item", row.name) - else: - doc = frappe.new_doc("Delivery Schedule Item") - - doc.update(data) - doc.save(ignore_permissions=True) - names.append(doc.name) - - if names: - self.delete_delivery_schedule_items(child_row.name, names) - - if first_delivery_date: - self.update_delivery_date_based_on_schedule(child_row, first_delivery_date) - - def update_delivery_date_based_on_schedule(self, child_row, first_delivery_date): - for row in self.items: - if row.name == child_row.name: - if first_delivery_date: - row.delivery_date = first_delivery_date - break - - self.save() - - def delete_delivery_schedule_items(self, sales_order_item=None, ignore_names=None): - """Delete delivery schedule items.""" - doctype = frappe.qb.DocType("Delivery Schedule Item") - - query = frappe.qb.from_(doctype).delete().where(doctype.sales_order == self.name) - - if ignore_names: - query = query.where(doctype.name.notin(ignore_names)) - - if sales_order_item: - query = query.where(doctype.sales_order_item == sales_order_item) - - query.run() - - -def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: - """Returns the unreserved quantity for the Sales Order Item.""" - - existing_reserved_qty = reserved_qty_details.get(item.name, 0) - if item.get("delivered_qty") is not None: - return ( - item.stock_qty - - flt(item.delivered_qty) * item.get("conversion_factor", 1) - - existing_reserved_qty - ) - else: - stock_qty, delivered_qty, conversion_factor = frappe.get_value( - "Sales Order Item", - item.parent_detail_docname, - ["stock_qty", "delivered_qty", "conversion_factor"], - ) - bundle_conversion_factor = ( - item.qty / stock_qty - ) # ratio of packed item qty to main item qty in product bundle - delivered_qty = delivered_qty * conversion_factor * bundle_conversion_factor - return item.qty - delivered_qty - existing_reserved_qty + DeliveryScheduleService(self).create_delivery_schedule(child_row, schedules) def get_list_context(context=None): diff --git a/erpnext/selling/doctype/sales_order/services/__init__.py b/erpnext/selling/doctype/sales_order/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/doctype/sales_order/services/delivery_schedule.py b/erpnext/selling/doctype/sales_order/services/delivery_schedule.py new file mode 100644 index 00000000000..4bee6121579 --- /dev/null +++ b/erpnext/selling/doctype/sales_order/services/delivery_schedule.py @@ -0,0 +1,95 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Delivery schedule management for Sales Order Items.""" + +import frappe +from frappe.utils import parse_json + + +class DeliveryScheduleService: + def __init__(self, doc): + self.doc = doc + + def get_delivery_schedule(self, sales_order_item: str) -> list[dict]: + return frappe.get_all( + "Delivery Schedule Item", + filters={"sales_order_item": sales_order_item, "sales_order": self.doc.name}, + fields=["delivery_date", "qty", "name"], + order_by="delivery_date asc", + ) + + def create_delivery_schedule(self, child_row: dict | frappe._dict, schedules: str | list[dict]) -> None: + if isinstance(child_row, dict): + child_row = frappe._dict(child_row) + + if isinstance(schedules, str): + schedules = parse_json(schedules) + + names = [] + first_delivery_date = None + for row in schedules: + row = frappe._dict(row) + + if not first_delivery_date: + first_delivery_date = row.delivery_date + + data = { + "delivery_date": row.delivery_date, + "qty": row.qty, + "uom": child_row.uom, + "stock_uom": child_row.stock_uom, + "item_code": child_row.item_code, + "conversion_factor": child_row.conversion_factor or 1.0, + "warehouse": child_row.warehouse, + "sales_order_item": child_row.name, + "sales_order": self.doc.name, + "stock_qty": row.qty * (child_row.conversion_factor or 1.0), + } + + if frappe.db.exists("Delivery Schedule Item", row.name): + doc = frappe.get_doc("Delivery Schedule Item", row.name) + else: + doc = frappe.new_doc("Delivery Schedule Item") + + doc.update(data) + doc.save(ignore_permissions=True) + names.append(doc.name) + + if names: + self.delete_delivery_schedule_items(child_row.name, names) + + if first_delivery_date: + self.update_delivery_date_based_on_schedule(child_row, first_delivery_date) + + def update_delivery_date_based_on_schedule(self, child_row, first_delivery_date) -> None: + for row in self.doc.items: + if row.name == child_row.name: + if first_delivery_date: + row.delivery_date = first_delivery_date + break + + self.doc.save() + + def delete_delivery_schedule_items( + self, sales_order_item: str | None = None, ignore_names: list | None = None + ) -> None: + """Delete delivery schedule items.""" + doctype = frappe.qb.DocType("Delivery Schedule Item") + + query = frappe.qb.from_(doctype).delete().where(doctype.sales_order == self.doc.name) + + if ignore_names: + query = query.where(doctype.name.notin(ignore_names)) + + if sales_order_item: + query = query.where(doctype.sales_order_item == sales_order_item) + + query.run() + + def delete_removed_delivery_schedule_items(self) -> None: + items = [d.name for d in self.doc.get("items")] + doctype = frappe.qb.DocType("Delivery Schedule Item") + frappe.qb.from_(doctype).delete().where( + (doctype.sales_order == self.doc.name) & (doctype.sales_order_item.notin(items)) + ).run() diff --git a/erpnext/selling/doctype/sales_order/services/status.py b/erpnext/selling/doctype/sales_order/services/status.py new file mode 100644 index 00000000000..c11163088c8 --- /dev/null +++ b/erpnext/selling/doctype/sales_order/services/status.py @@ -0,0 +1,110 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Status computation and progress tracking for Sales Order.""" + +import frappe +from frappe import _ +from frappe.desk.notifications import clear_doctype_notifications +from frappe.utils import cint, cstr, flt + +from erpnext.selling.doctype.sales_order.services.subcontracting import SubcontractingService + + +class StatusService: + def __init__(self, doc): + self.doc = doc + + def set_default_statuses(self) -> None: + doc = self.doc + if not doc.billing_status: + doc.billing_status = "Not Billed" + if not doc.delivery_status: + doc.delivery_status = "Not Delivered" + if not doc.advance_payment_status: + doc.advance_payment_status = "Not Requested" + + def update_status(self, status: str) -> None: + doc = self.doc + self.check_modified_date() + doc.set_status(update=True, status=status) + # Upon Sales Order Re-open, check for credit limit. + # Limit should be checked after the 'Hold/Closed' status is reset. + if status == "Draft" and doc.docstatus == 1: + doc.check_credit_limit() + doc.update_reserved_qty() + SubcontractingService(doc).update_subcontracting_order_status() + doc.notify_update() + clear_doctype_notifications(doc) + doc.update_blanket_order() + + def check_modified_date(self) -> None: + doc = self.doc + mod_db = frappe.db.get_value("Sales Order", doc.name, "modified") + if mod_db and cstr(mod_db) != cstr(doc.modified): + frappe.throw(_("{0} {1} has been modified. Please refresh.").format(doc.doctype, doc.name)) + + def update_delivery_status(self) -> None: + """Update delivery status from Purchase Order for drop shipping""" + doc = self.doc + tot_qty, delivered_qty = 0.0, 0.0 + + for item in doc.items: + if item.delivered_by_supplier: + item_delivered_qty = frappe.get_all( + "Purchase Order Item", + {"sales_order_item": item.name, "docstatus": 1}, + [{"SUM": "received_qty", "AS": "received_qty"}], + pluck="received_qty", + )[0] + item.db_set("delivered_qty", flt(item_delivered_qty), update_modified=False) + + delivered_qty += min(item.delivered_qty, item.qty) + tot_qty += item.qty + + if tot_qty != 0: + doc.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False) + + def update_picking_status(self) -> None: + doc = self.doc + total_picked_qty = 0.0 + total_qty = 0.0 + per_picked = 0.0 + + for so_item in doc.items: + if cint( + frappe.get_cached_value("Item", so_item.item_code, "is_stock_item") + ) or doc.has_product_bundle(so_item.item_code): + total_picked_qty += flt(so_item.picked_qty) + total_qty += flt(so_item.stock_qty) + + if total_picked_qty and total_qty: + per_picked = total_picked_qty / total_qty * 100 + + pick_percentage = frappe.get_single_value("Stock Settings", "over_picking_allowance") + if pick_percentage: + total_qty += flt(total_qty) * (pick_percentage / 100) + + if total_picked_qty > total_qty: + frappe.throw( + _( + "Total Picked Quantity {0} is more than ordered qty {1}. You can set the Over Picking Allowance in Stock Settings." + ).format(total_picked_qty, total_qty) + ) + + doc.db_set("per_picked", flt(per_picked), update_modified=False) + + def set_indicator(self) -> None: + """Set indicator for portal""" + doc = self.doc + doc.indicator_color = { + "Draft": "red", + "On Hold": "orange", + "To Deliver and Bill": "orange", + "To Bill": "orange", + "To Deliver": "orange", + "Completed": "green", + "Cancelled": "red", + }.get(doc.status, "blue") + + doc.indicator_title = _(doc.status) diff --git a/erpnext/selling/doctype/sales_order/services/stock_reservation.py b/erpnext/selling/doctype/sales_order/services/stock_reservation.py new file mode 100644 index 00000000000..3a7bf7347d6 --- /dev/null +++ b/erpnext/selling/doctype/sales_order/services/stock_reservation.py @@ -0,0 +1,156 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Stock reservation logic for Sales Order.""" + +from typing import Literal + +import frappe +from frappe import _ +from frappe.utils import cint, flt + +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_details_for_voucher, +) +from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty + + +class StockReservationService: + def __init__(self, doc): + self.doc = doc + + def validate_reserved_stock(self) -> None: + """Clean reserved stock flag for non-stock Item""" + + enable_stock_reservation = frappe.get_single_value("Stock Settings", "enable_stock_reservation") + + for item in self.doc.items: + if item.reserve_stock and (not enable_stock_reservation or not cint(item.is_stock_item)): + item.reserve_stock = 0 + + def enable_auto_reserve_stock(self) -> None: + if self.doc.is_new() and frappe.get_single_value("Stock Settings", "auto_reserve_stock"): + self.doc.reserve_stock = 1 + + def update_reserved_qty(self, so_item_rows: list | None = None) -> None: + """update requested qty (before ordered_qty is updated)""" + item_wh_list = [] + + def _valid_for_reserve(item_code, warehouse): + if ( + item_code + and warehouse + and [item_code, warehouse] not in item_wh_list + and frappe.get_cached_value("Item", item_code, "is_stock_item") + ): + item_wh_list.append([item_code, warehouse]) + + for d in self.doc.get("items"): + if (not so_item_rows or d.name in so_item_rows) and not d.delivered_by_supplier: + if self.doc.has_product_bundle(d.item_code): + for p in self.doc.get("packed_items"): + if p.parent_detail_docname == d.name and p.parent_item == d.item_code: + _valid_for_reserve(p.item_code, p.warehouse) + else: + _valid_for_reserve(d.item_code, d.warehouse) + + for item_code, warehouse in item_wh_list: + update_bin_qty(item_code, warehouse, {"reserved_qty": get_reserved_qty(item_code, warehouse)}) + + def has_unreserved_stock(self, table_name: str = "items") -> dict: + """Returns unreserved qty per item if there is any unreserved item in the Sales Order.""" + + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.doc.name) + + data = {} + for item in self.doc.get(table_name): + if not item.get("reserve_stock"): + continue + + unreserved_qty = get_unreserved_qty(item, reserved_qty_details) + if unreserved_qty > 0: + data[item.name] = unreserved_qty + + return data + + def create_stock_reservation_entries( + self, + items_details: list[dict] | None = None, + from_voucher_type: Literal["Pick List", "Purchase Receipt"] | None = None, + notify: bool = True, + ) -> None: + """Creates Stock Reservation Entries for Sales Order Items.""" + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + create_stock_reservation_entries_for_so_items as create_stock_reservation_entries, + ) + + packed_items = [] + if items_details: + for item in items_details: + if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")): + item["qty"] = item.pop("qty_to_reserve") + packed_items.append(item) + + for item in packed_items: + items_details.remove(item) + + sre_count = 0 + if items_details != []: + sre_count = create_stock_reservation_entries( + sales_order=self.doc, + items_details=items_details, + from_voucher_type=from_voucher_type, + notify=notify, + ) + + items = [] + if packed_items: + items = packed_items + elif not items_details: + items = [item for item in self.doc.packed_items if item.reserve_stock] + + if items: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation + + stock_reservation = StockReservation(doc=self.doc, items=items) + stock_reservation.table_name = "packed_items" + stock_reservation.qty_field = "qty" + is_sre_created = stock_reservation.make_stock_reservation_entries() + + if notify and is_sre_created and not sre_count: + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + + def cancel_stock_reservation_entries(self, sre_list: list | None = None, notify: bool = True) -> None: + """Cancel Stock Reservation Entries for Sales Order Items.""" + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + ) + + cancel_stock_reservation_entries( + voucher_type=self.doc.doctype, voucher_no=self.doc.name, sre_list=sre_list, notify=notify + ) + + +def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: + """Returns the unreserved quantity for the Sales Order Item.""" + + existing_reserved_qty = reserved_qty_details.get(item.name, 0) + if item.get("delivered_qty") is not None: + return ( + item.stock_qty + - flt(item.delivered_qty) * item.get("conversion_factor", 1) + - existing_reserved_qty + ) + else: + stock_qty, delivered_qty, conversion_factor = frappe.get_value( + "Sales Order Item", + item.parent_detail_docname, + ["stock_qty", "delivered_qty", "conversion_factor"], + ) + bundle_conversion_factor = ( + item.qty / stock_qty + ) # ratio of packed item qty to main item qty in product bundle + delivered_qty = delivered_qty * conversion_factor * bundle_conversion_factor + return item.qty - delivered_qty - existing_reserved_qty diff --git a/erpnext/selling/doctype/sales_order/services/subcontracting.py b/erpnext/selling/doctype/sales_order/services/subcontracting.py new file mode 100644 index 00000000000..8c67f1e0990 --- /dev/null +++ b/erpnext/selling/doctype/sales_order/services/subcontracting.py @@ -0,0 +1,69 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Subcontracting (inward) integration for Sales Order.""" + +import frappe +from frappe import _ + + +class SubcontractingService: + def __init__(self, doc): + self.doc = doc + + def validate_fg_item_for_subcontracting(self) -> None: + doc = self.doc + if doc.is_subcontracted: + for item in doc.items: + if not item.fg_item: + frappe.throw( + _("Row #{0}: Finished Good Item is not specified for service item {1}").format( + item.idx, item.item_code + ) + ) + else: + if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"): + frappe.throw( + _("Row #{0}: Finished Good Item {1} must be a sub-contracted item").format( + item.idx, item.fg_item + ) + ) + if not frappe.db.get_value( + "Subcontracting BOM", + {"finished_good": item.fg_item, "is_active": 1}, + "finished_good_bom", + ) and not frappe.get_value("Item", item.fg_item, "default_bom"): + frappe.throw( + _("Row #{0}: BOM not found for FG Item {1}").format(item.idx, item.fg_item) + ) + if not item.fg_item_qty: + frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx)) + else: + for item in doc.items: + item.set("fg_item", None) + item.set("fg_item_qty", 0) + + def can_update_items(self) -> bool: + result = True + + if self.doc.is_subcontracted: + if frappe.db.exists( + "Subcontracting Inward Order", {"sales_order": self.doc.name, "docstatus": 1} + ): + result = False + + return result + + def update_subcontracting_order_status(self) -> None: + from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import ( + update_subcontracting_inward_order_status as update_scio_status, + ) + + doc = self.doc + if doc.is_subcontracted: + scio = frappe.get_cached_value( + "Subcontracting Inward Order", {"sales_order": doc.name, "docstatus": 1}, "name" + ) + + if scio: + update_scio_status(scio, "Closed" if doc.status == "Closed" else None) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 8fb7a375dcf..52a3c94b608 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1587,7 +1587,7 @@ def create_stock_reservation_entries_for_so_items( ): """Creates Stock Reservation Entries for Sales Order Items.""" - from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty + from erpnext.selling.doctype.sales_order.services.stock_reservation import get_unreserved_qty if not from_voucher_type and ( sales_order.get("_action") == "submit"