Merge pull request #55684 from nabinhait/so-services

refactor(selling): extract Sales Order services
This commit is contained in:
Nabin Hait
2026-06-07 22:16:24 +05:30
committed by GitHub
7 changed files with 457 additions and 338 deletions

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"