mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 07:58:38 +00:00
feat(manufacturing): prompt for qty when creating material request from work order (#57855)
This commit is contained in:
@@ -9,6 +9,7 @@ the controller; work_order.py re-exports them for backward compatibility.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from functools import partial
|
||||
|
||||
import frappe
|
||||
@@ -476,42 +477,104 @@ def create_pick_list(
|
||||
):
|
||||
frappe.has_permission("Pick List", "create", throw=True)
|
||||
|
||||
for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
|
||||
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
|
||||
postprocess = partial(
|
||||
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
|
||||
)
|
||||
if for_qty is None:
|
||||
for_qty = frappe.parse_json(target_doc or "{}").get("for_qty")
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess), target_doc)
|
||||
for_qty = _validated_for_qty(for_qty)
|
||||
work_order = frappe.get_doc("Work Order", source_name)
|
||||
allocation = _allocate_material_demand(work_order, for_qty / flt(work_order.qty))
|
||||
postprocess = partial(_set_pick_list_item_qty, allocation_by_item=allocation)
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess, allocation), target_doc)
|
||||
_validate_material_is_pending(doc.locations)
|
||||
doc.purpose = "Material Transfer for Manufacture"
|
||||
doc.for_qty = for_qty
|
||||
doc.set_item_locations()
|
||||
return doc
|
||||
|
||||
|
||||
def _pick_list_mapping(postprocess):
|
||||
def _pick_list_mapping(postprocess, allocation):
|
||||
return {
|
||||
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
|
||||
"Work Order Item": {
|
||||
"doctype": "Pick List Item",
|
||||
"field_no_map": ["transferred_qty"],
|
||||
"postprocess": postprocess,
|
||||
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
|
||||
"condition": lambda doc: _allocation_key(doc) in allocation,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished_goods_qty):
|
||||
pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
|
||||
desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
|
||||
def _allocate_material_demand(work_order, fraction):
|
||||
"""Fraction of each (item, warehouse, operation row) group's requirement, capped
|
||||
at the group's proportional share of the item's pending pool."""
|
||||
required_by_item = {}
|
||||
covered_by_item = {}
|
||||
required_by_group = {}
|
||||
for row in work_order.required_items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
covered_by_item.setdefault(
|
||||
row.item_code,
|
||||
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty),
|
||||
)
|
||||
key = _allocation_key(row)
|
||||
required_by_group[key] = required_by_group.get(key, 0.0) + flt(row.required_qty)
|
||||
|
||||
qty = 0
|
||||
if desire_to_transfer <= pending_to_issue:
|
||||
qty = desire_to_transfer
|
||||
elif pending_to_issue > 0:
|
||||
qty = pending_to_issue
|
||||
pending_pool = {
|
||||
item_code: required_qty - covered_by_item[item_code]
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
}
|
||||
|
||||
if not qty:
|
||||
allocation = {}
|
||||
for key, required_qty in required_by_group.items():
|
||||
item_code = key[0]
|
||||
if required_by_item[item_code] <= 0:
|
||||
continue
|
||||
|
||||
pool_share = pending_pool[item_code] * required_qty / required_by_item[item_code]
|
||||
qty = min(required_qty * fraction, pool_share)
|
||||
if qty > 0:
|
||||
allocation[key] = qty
|
||||
return allocation
|
||||
|
||||
|
||||
def _allocation_key(row):
|
||||
"""Manual rows have no operation_row_id; their operation label splits them."""
|
||||
return (row.item_code, row.source_warehouse, cint(row.operation_row_id) or row.operation)
|
||||
|
||||
|
||||
def _merge_allocation_per_item(allocation):
|
||||
"""Material Request rejects repeated item codes unless Buying Settings allows them."""
|
||||
merged = {}
|
||||
key_by_item = {}
|
||||
for key, qty in allocation.items():
|
||||
item_code = key[0]
|
||||
if item_code in key_by_item:
|
||||
merged[key_by_item[item_code]] += qty
|
||||
else:
|
||||
key_by_item[item_code] = key
|
||||
merged[key] = qty
|
||||
return merged
|
||||
|
||||
|
||||
def _validated_for_qty(for_qty):
|
||||
qty = flt(for_qty)
|
||||
if not math.isfinite(qty) or qty <= 0:
|
||||
frappe.throw(_("Quantity must be greater than zero."))
|
||||
return qty
|
||||
|
||||
|
||||
def _validate_material_is_pending(rows):
|
||||
if not rows:
|
||||
frappe.throw(
|
||||
_("All required items have already been transferred, requested or picked."),
|
||||
title=_("No Pending Materials"),
|
||||
)
|
||||
|
||||
|
||||
def _set_pick_list_item_qty(source, target, source_parent, allocation_by_item):
|
||||
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
|
||||
if qty <= 0:
|
||||
target.delete()
|
||||
return
|
||||
|
||||
@@ -523,15 +586,32 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_material_request(source_name: str, target_doc: str | dict | Document | None = None):
|
||||
def make_material_request(
|
||||
source_name: str, target_doc: str | dict | Document | None = None, for_qty: float | None = None
|
||||
):
|
||||
frappe.has_permission("Material Request", "create", throw=True)
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
|
||||
if for_qty is None and frappe.flags.args:
|
||||
for_qty = frappe.flags.args.for_qty
|
||||
|
||||
work_order = frappe.get_doc("Work Order", source_name)
|
||||
fraction = 1.0
|
||||
if for_qty is not None:
|
||||
fraction = _validated_for_qty(for_qty) / flt(work_order.qty)
|
||||
|
||||
allocation = _allocate_material_demand(work_order, fraction)
|
||||
if not cint(frappe.db.get_single_value("Buying Settings", "allow_multiple_items")):
|
||||
allocation = _merge_allocation_per_item(allocation)
|
||||
postprocess = partial(_set_material_request_item, allocation_by_item=allocation)
|
||||
doc = get_mapped_doc(
|
||||
"Work Order", source_name, _material_request_mapping(postprocess, allocation), target_doc
|
||||
)
|
||||
_validate_material_is_pending(doc.items)
|
||||
doc.material_request_type = "Material Transfer"
|
||||
return doc
|
||||
|
||||
|
||||
def _material_request_mapping():
|
||||
def _material_request_mapping(postprocess, allocation):
|
||||
return {
|
||||
"Work Order": {
|
||||
"doctype": "Material Request",
|
||||
@@ -541,19 +621,23 @@ def _material_request_mapping():
|
||||
"Work Order Item": {
|
||||
"doctype": "Material Request Item",
|
||||
"field_map": [
|
||||
("required_qty", "qty"),
|
||||
("stock_uom", "uom"),
|
||||
("source_warehouse", "from_warehouse"),
|
||||
],
|
||||
"postprocess": _set_material_request_item,
|
||||
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
|
||||
"postprocess": postprocess,
|
||||
"condition": lambda doc: _allocation_key(doc) in allocation,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _set_material_request_item(source, target, source_parent):
|
||||
def _set_material_request_item(source, target, source_parent, allocation_by_item):
|
||||
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
|
||||
if qty <= 0:
|
||||
target.delete()
|
||||
return
|
||||
|
||||
target.warehouse = source_parent.wip_warehouse
|
||||
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
|
||||
target.qty = qty
|
||||
target.schedule_date = nowdate()
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ callers and the whitelisted entry point keep working unchanged.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
from pypika import functions as fn
|
||||
|
||||
@@ -198,6 +199,97 @@ class RequiredItemsService:
|
||||
for row in self.doc.required_items:
|
||||
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
|
||||
|
||||
def validate_incoming_material_demand(self, incoming_qty_by_item):
|
||||
"""Reject demand exceeding the pending requirement; callers must hold the
|
||||
work order row lock (for_update=True)."""
|
||||
required_by_item = {}
|
||||
uom_by_item = {}
|
||||
for row in self.doc.required_items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
uom_by_item.setdefault(row.item_code, row.stock_uom)
|
||||
|
||||
transferred = self._material_transfer_qty_by_item(is_return=0)
|
||||
requested = self._material_request_pending_qty_by_item()
|
||||
picked = self._pick_list_pending_qty_by_item()
|
||||
|
||||
for item_code, incoming_qty in incoming_qty_by_item.items():
|
||||
if item_code not in required_by_item:
|
||||
continue
|
||||
|
||||
pending = (
|
||||
required_by_item[item_code]
|
||||
- flt(transferred.get(item_code))
|
||||
- flt(requested.get(item_code))
|
||||
- flt(picked.get(item_code))
|
||||
)
|
||||
if flt(incoming_qty - pending, 6) > 0:
|
||||
frappe.throw(
|
||||
_("Only {0} {1} of {2} is pending in Work Order {3}.").format(
|
||||
max(pending, 0.0), uom_by_item[item_code], item_code, self.doc.name
|
||||
),
|
||||
title=_("Exceeds Pending Qty"),
|
||||
)
|
||||
|
||||
def update_requested_qty_for_required_items(self):
|
||||
"""Refresh per-row qty requested via open Material Requests but not yet transferred."""
|
||||
requested_items = self._material_request_pending_qty_by_item()
|
||||
for row in self.doc.required_items:
|
||||
row.db_set("requested_qty", (requested_items.get(row.item_code) or 0.0), update_modified=False)
|
||||
|
||||
def _material_request_pending_qty_by_item(self):
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
mr_item = frappe.qb.DocType("Material Request Item")
|
||||
query = (
|
||||
frappe.qb.from_(mr)
|
||||
.inner_join(mr_item)
|
||||
.on(mr_item.parent == mr.name)
|
||||
.select(mr_item.item_code, fn.Sum(mr_item.stock_qty - mr_item.ordered_qty).as_("qty"))
|
||||
.where(
|
||||
(mr.docstatus == 1)
|
||||
& (mr.work_order == self.doc.name)
|
||||
& (mr.material_request_type == "Material Transfer")
|
||||
& (mr.status != "Stopped")
|
||||
& (mr_item.stock_qty > mr_item.ordered_qty)
|
||||
)
|
||||
.groupby(mr_item.item_code)
|
||||
)
|
||||
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
|
||||
|
||||
def update_picked_qty_for_required_items(self):
|
||||
"""Refresh per-row qty picked but not yet transferred. Rows of a live material
|
||||
request count as requested_qty instead, until that request stops or cancels."""
|
||||
picked_items = self._pick_list_pending_qty_by_item()
|
||||
for row in self.doc.required_items:
|
||||
row.db_set("picked_qty", (picked_items.get(row.item_code) or 0.0), update_modified=False)
|
||||
|
||||
def _pick_list_pending_qty_by_item(self):
|
||||
pick_list = frappe.qb.DocType("Pick List")
|
||||
pick_list_item = frappe.qb.DocType("Pick List Item")
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
query = (
|
||||
frappe.qb.from_(pick_list)
|
||||
.inner_join(pick_list_item)
|
||||
.on(pick_list_item.parent == pick_list.name)
|
||||
.left_join(mr)
|
||||
.on(pick_list_item.material_request == mr.name)
|
||||
.select(
|
||||
pick_list_item.item_code,
|
||||
fn.Sum(pick_list_item.picked_qty - pick_list_item.transferred_qty).as_("qty"),
|
||||
)
|
||||
.where(
|
||||
(pick_list.docstatus == 1)
|
||||
& (pick_list.work_order == self.doc.name)
|
||||
& (pick_list_item.picked_qty > pick_list_item.transferred_qty)
|
||||
& (
|
||||
(fn.Coalesce(pick_list_item.material_request_item, "") == "")
|
||||
| (mr.docstatus != 1)
|
||||
| (mr.status == "Stopped")
|
||||
)
|
||||
)
|
||||
.groupby(pick_list_item.item_code)
|
||||
)
|
||||
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
|
||||
|
||||
def _material_transfer_qty_by_item(self, is_return):
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
@@ -1638,6 +1638,359 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
|
||||
def test_material_request_qty_scales_with_requested_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
self.assertEqual(len(mr.items), len(required_qty))
|
||||
for row in mr.items:
|
||||
self.assertEqual(row.qty, required_qty[row.item_code] * 4 / 10)
|
||||
|
||||
mr = make_material_request(work_order.name)
|
||||
for row in mr.items:
|
||||
self.assertEqual(row.qty, required_qty[row.item_code])
|
||||
|
||||
def test_material_request_qty_capped_at_pending_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
partially_transferred = work_order.required_items[0]
|
||||
partially_transferred.db_set("transferred_qty", flt(partially_transferred.required_qty) - 1)
|
||||
work_order.reload()
|
||||
|
||||
mr = make_material_request(work_order.name, for_qty=10)
|
||||
requested_qty = {row.item_code: row.qty for row in mr.items}
|
||||
self.assertEqual(requested_qty[partially_transferred.item_code], 1)
|
||||
|
||||
def test_material_request_maps_only_selected_rows(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
selected = work_order.required_items[0]
|
||||
|
||||
try:
|
||||
frappe.flags.selected_children = {"required_items": [selected.name]}
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
finally:
|
||||
frappe.flags.selected_children = None
|
||||
|
||||
self.assertEqual([row.item_code for row in mr.items], [selected.item_code])
|
||||
self.assertEqual(mr.items[0].qty, flt(selected.required_qty) * 4 / 10)
|
||||
|
||||
def test_material_request_rejects_nonpositive_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=0)
|
||||
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=-1)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("inf")
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("nan")
|
||||
)
|
||||
|
||||
def test_pick_list_rejects_nonpositive_qty(self):
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=0)
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=-1)
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("inf"))
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("nan"))
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name)
|
||||
|
||||
def submit_material_request(self, work_order_name, for_qty=None):
|
||||
mr = make_material_request(work_order_name, for_qty=for_qty)
|
||||
mr.schedule_date = today()
|
||||
for item in mr.items:
|
||||
item.schedule_date = today()
|
||||
mr.insert()
|
||||
mr.submit()
|
||||
return mr
|
||||
|
||||
def receive_test_fg_raw_materials(self):
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=1000.0
|
||||
)
|
||||
|
||||
def test_requested_qty_tracks_open_material_requests(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
|
||||
|
||||
remainder_mr = make_material_request(work_order.name, for_qty=10)
|
||||
for row in remainder_mr.items:
|
||||
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
|
||||
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
|
||||
|
||||
mr.cancel()
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, 0)
|
||||
|
||||
def test_requested_qty_moves_to_transferred_qty_on_stock_entry(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
|
||||
|
||||
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
|
||||
stock_entry.insert()
|
||||
stock_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, 0)
|
||||
self.assertEqual(row.transferred_qty, mr_qty[row.item_code])
|
||||
|
||||
remainder_mr = make_material_request(work_order.name)
|
||||
for row in remainder_mr.items:
|
||||
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
|
||||
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
|
||||
|
||||
def test_picked_qty_tracks_open_pick_lists(self):
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
pick_list = create_pick_list(work_order.name, for_qty=4)
|
||||
pick_list.insert()
|
||||
pick_list.submit()
|
||||
picked_qty = {row.item_code: flt(row.stock_qty) for row in pick_list.locations}
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.picked_qty, picked_qty[row.item_code])
|
||||
|
||||
remainder_pick_list = create_pick_list(work_order.name, for_qty=10)
|
||||
for row in remainder_pick_list.locations:
|
||||
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
|
||||
self.assertEqual(row.qty, flt(required_row.required_qty) - picked_qty[row.item_code])
|
||||
|
||||
remainder_pick_list.insert()
|
||||
remainder_pick_list.submit()
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=10)
|
||||
|
||||
def test_material_request_submit_rejects_exceeding_pending_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
def full_draft():
|
||||
mr = make_material_request(work_order.name)
|
||||
mr.schedule_date = today()
|
||||
for item in mr.items:
|
||||
item.schedule_date = today()
|
||||
mr.insert()
|
||||
return mr
|
||||
|
||||
first, second = full_draft(), full_draft()
|
||||
first.submit()
|
||||
self.assertRaises(frappe.ValidationError, second.submit)
|
||||
|
||||
def test_picked_qty_counts_pick_list_of_stopped_material_request(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
|
||||
|
||||
pick_list = mr_to_pick_list(mr.name)
|
||||
pick_list.insert()
|
||||
pick_list.submit()
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
|
||||
self.assertEqual(row.picked_qty, 0)
|
||||
|
||||
mr.reload()
|
||||
mr.update_status("Stopped")
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, 0)
|
||||
self.assertEqual(row.picked_qty, mr_qty[row.item_code])
|
||||
|
||||
def test_pending_demand_shared_across_duplicate_item_rows(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": first.source_warehouse,
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
total_required = flt(first.required_qty) + 5
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
requested = sum(flt(row.qty) for row in mr.items if row.item_code == first.item_code)
|
||||
self.assertAlmostEqual(requested, total_required * 4 / 10, places=6)
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
if row.item_code == first.item_code:
|
||||
self.assertAlmostEqual(row.requested_qty, requested, places=6)
|
||||
|
||||
remainder_mr = make_material_request(work_order.name, for_qty=10)
|
||||
remainder = sum(flt(row.qty) for row in remainder_mr.items if row.item_code == first.item_code)
|
||||
self.assertAlmostEqual(remainder, total_required - requested, places=6)
|
||||
|
||||
def test_allocation_splits_by_source_warehouse(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
rows = {row.from_warehouse: flt(row.qty) for row in mr.items if row.item_code == first.item_code}
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
|
||||
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
|
||||
|
||||
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
rows = [row for row in mr.items if row.item_code == first.item_code]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertAlmostEqual(flt(rows[0].qty), (flt(first.required_qty) + 5) * 4 / 10, places=6)
|
||||
|
||||
def test_remainder_allocation_splits_proportionally_across_groups(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
|
||||
self.submit_material_request(work_order.name, for_qty=4)
|
||||
work_order.reload()
|
||||
remainder = make_material_request(work_order.name, for_qty=10)
|
||||
rows = {
|
||||
row.from_warehouse: flt(row.qty) for row in remainder.items if row.item_code == first.item_code
|
||||
}
|
||||
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 6 / 10, places=6)
|
||||
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 6 / 10, places=6)
|
||||
|
||||
def test_allocation_splits_manual_rows_by_operation_label(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
for operation in ("_Test Operation A", "_Test Operation B"):
|
||||
row = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": first.source_warehouse,
|
||||
"operation": operation,
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
row.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
rows = [flt(row.qty) for row in mr.items if row.item_code == first.item_code]
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertAlmostEqual(sum(rows), (flt(first.required_qty) + 10) * 4 / 10, places=6)
|
||||
|
||||
def test_pick_list_rejects_over_pick_against_material_request(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
pick_list = mr_to_pick_list(mr.name)
|
||||
pick_list.insert()
|
||||
pick_list.locations[0].picked_qty = flt(pick_list.locations[0].stock_qty) + 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pick_list.submit)
|
||||
|
||||
def test_backflushed_batch_raw_materials_based_on_transferred(self):
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings",
|
||||
|
||||
@@ -852,7 +852,10 @@ erpnext.work_order = {
|
||||
function () {
|
||||
let purpose = "Material Transfer for Manufacture";
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, purpose, qty, 1)
|
||||
.show_prompt_for_qty_input(frm, purpose, {
|
||||
qty: qty,
|
||||
additional_transfer_entry: 1,
|
||||
})
|
||||
.then((data) => {
|
||||
return frappe.xcall(
|
||||
"erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
|
||||
@@ -1038,6 +1041,26 @@ erpnext.work_order = {
|
||||
return flt(max, precision("qty"));
|
||||
},
|
||||
|
||||
get_max_requestable_qty: (frm) => {
|
||||
const required = {};
|
||||
const covered = {};
|
||||
(frm.doc.required_items || []).forEach((row) => {
|
||||
required[row.item_code] = (required[row.item_code] || 0) + flt(row.required_qty);
|
||||
if (!(row.item_code in covered)) {
|
||||
covered[row.item_code] =
|
||||
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty);
|
||||
}
|
||||
});
|
||||
|
||||
let max_fraction = 0;
|
||||
Object.keys(required).forEach((item_code) => {
|
||||
if (required[item_code] <= 0) return;
|
||||
const pending = required[item_code] - covered[item_code];
|
||||
max_fraction = Math.max(max_fraction, pending / required[item_code]);
|
||||
});
|
||||
return flt(max_fraction * flt(frm.doc.qty), precision("qty"));
|
||||
},
|
||||
|
||||
show_disassembly_prompt: function (frm) {
|
||||
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
||||
|
||||
@@ -1092,20 +1115,20 @@ erpnext.work_order = {
|
||||
});
|
||||
},
|
||||
|
||||
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
|
||||
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
|
||||
show_prompt_for_qty_input: function (frm, purpose, { qty, additional_transfer_entry, target } = {}) {
|
||||
let max = qty == null ? this.get_max_transferable_qty(frm, purpose) : qty;
|
||||
|
||||
let fields = [
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Qty for {0}", [__(purpose)]),
|
||||
label: __("Qty for {0}", [target || __(purpose)]),
|
||||
fieldname: "qty",
|
||||
description: __("Max: {0}", [max]),
|
||||
default: max,
|
||||
},
|
||||
];
|
||||
|
||||
if (!additional_transfer_entry) {
|
||||
if (!additional_transfer_entry && !target) {
|
||||
fields.push({
|
||||
fieldtype: "Check",
|
||||
label: __("Consider Process Loss"),
|
||||
@@ -1127,6 +1150,11 @@ erpnext.work_order = {
|
||||
(data) => {
|
||||
max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100;
|
||||
|
||||
if (!data.qty || data.qty <= 0) {
|
||||
frappe.msgprint(__("Quantity must be greater than zero."));
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
if (data.qty > max) {
|
||||
frappe.msgprint(__("Quantity must not be more than {0}", [max]));
|
||||
reject();
|
||||
@@ -1169,15 +1197,32 @@ erpnext.work_order = {
|
||||
}
|
||||
},
|
||||
|
||||
make_material_request: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
|
||||
frm,
|
||||
});
|
||||
make_material_request: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||
const max = this.get_max_requestable_qty(frm);
|
||||
if (max <= 0) {
|
||||
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
|
||||
return;
|
||||
}
|
||||
|
||||
const get_material_request = (for_qty) =>
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
|
||||
frm,
|
||||
args: { for_qty: for_qty },
|
||||
});
|
||||
|
||||
this.show_prompt_for_qty_input(frm, purpose, {
|
||||
qty: max,
|
||||
target: __("Material Request"),
|
||||
}).then((data) => get_material_request(data.qty));
|
||||
},
|
||||
|
||||
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||
const max = this.get_max_transferable_qty(frm, purpose);
|
||||
const max = this.get_max_requestable_qty(frm);
|
||||
if (max <= 0) {
|
||||
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
|
||||
return;
|
||||
}
|
||||
|
||||
const get_pick_list = (for_qty) =>
|
||||
frappe
|
||||
@@ -1190,11 +1235,10 @@ erpnext.work_order = {
|
||||
frappe.set_route("Form", pick_list.doctype, pick_list.name);
|
||||
});
|
||||
|
||||
if (max <= 0) {
|
||||
get_pick_list(frm.doc.qty);
|
||||
} else {
|
||||
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
|
||||
}
|
||||
this.show_prompt_for_qty_input(frm, purpose, {
|
||||
qty: max,
|
||||
target: __("Pick List"),
|
||||
}).then((data) => get_pick_list(data.qty));
|
||||
},
|
||||
|
||||
make_consumption_se: function (frm, backflush_raw_materials_based_on) {
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"amount",
|
||||
"column_break_11",
|
||||
"transferred_qty",
|
||||
"requested_qty",
|
||||
"picked_qty",
|
||||
"consumed_qty",
|
||||
"returned_qty",
|
||||
"section_break_idhr",
|
||||
@@ -93,6 +95,22 @@
|
||||
"label": "Transferred Qty",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!parent.skip_transfer",
|
||||
"fieldname": "requested_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Requested Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!parent.skip_transfer",
|
||||
"fieldname": "picked_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Picked Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:!parent.subcontracting_inward_order",
|
||||
@@ -209,7 +227,7 @@
|
||||
"grid_page_length": 50,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-12 12:05:16.687866",
|
||||
"modified": "2026-08-07 10:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -31,8 +31,10 @@ class WorkOrderItem(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
picked_qty: DF.Float
|
||||
rate: DF.Currency
|
||||
required_qty: DF.Float
|
||||
requested_qty: DF.Float
|
||||
returned_qty: DF.Float
|
||||
source_warehouse: DF.Link | None
|
||||
stock_reserved_qty: DF.Float
|
||||
|
||||
@@ -508,3 +508,4 @@ erpnext.patches.v16_0.move_warehouse_defaults_to_company
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
|
||||
def execute():
|
||||
"""Backfill requested_qty and picked_qty for work orders with open demand;
|
||||
fulfilled documents leave the zero default."""
|
||||
work_orders = set(
|
||||
frappe.get_all(
|
||||
"Material Request",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"material_request_type": "Material Transfer",
|
||||
"work_order": ("is", "set"),
|
||||
"status": ("!=", "Stopped"),
|
||||
"per_ordered": ("<", 100),
|
||||
},
|
||||
pluck="work_order",
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
work_orders.update(
|
||||
frappe.get_all(
|
||||
"Pick List",
|
||||
filters={"docstatus": 1, "work_order": ("is", "set"), "status": ("!=", "Completed")},
|
||||
pluck="work_order",
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
|
||||
for name in work_orders:
|
||||
if frappe.db.get_value("Work Order", name, "docstatus") != 1:
|
||||
continue
|
||||
|
||||
service = RequiredItemsService(frappe.get_doc("Work Order", name))
|
||||
service.update_requested_qty_for_required_items()
|
||||
service.update_picked_qty_for_required_items()
|
||||
@@ -315,7 +315,8 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Work Order",
|
||||
"options": "Work Order",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "terms_tab",
|
||||
@@ -376,7 +377,7 @@
|
||||
"idx": 70,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-30 11:04:31.517204",
|
||||
"modified": "2026-08-07 10:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Material Request",
|
||||
|
||||
@@ -273,6 +273,7 @@ class MaterialRequest(BuyingController):
|
||||
def on_submit(self):
|
||||
self.update_requested_qty_in_production_plan()
|
||||
self.update_requested_qty()
|
||||
self.update_requested_qty_in_work_order()
|
||||
if self.material_request_type == "Purchase":
|
||||
self.update_prevdoc_status()
|
||||
if frappe.db.exists("Budget", {"applicable_on_material_request": 1, "docstatus": 1}):
|
||||
@@ -283,6 +284,20 @@ class MaterialRequest(BuyingController):
|
||||
|
||||
def before_submit(self):
|
||||
self.set_status(update=True)
|
||||
self.validate_pending_qty_in_work_order()
|
||||
|
||||
def validate_pending_qty_in_work_order(self):
|
||||
if not self.work_order or self.material_request_type != "Material Transfer":
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
|
||||
incoming = {}
|
||||
for row in self.items:
|
||||
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.stock_qty)
|
||||
|
||||
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
|
||||
|
||||
def before_cancel(self):
|
||||
# if MRQ is already closed, no point saving the document
|
||||
@@ -301,6 +316,7 @@ class MaterialRequest(BuyingController):
|
||||
self.status_can_change(status)
|
||||
self.set_status(update=True, status=status)
|
||||
self.update_requested_qty()
|
||||
self.update_requested_qty_in_work_order()
|
||||
|
||||
def status_can_change(self, status):
|
||||
"""
|
||||
@@ -330,6 +346,7 @@ class MaterialRequest(BuyingController):
|
||||
def on_cancel(self):
|
||||
self.update_requested_qty_in_production_plan(cancel=True)
|
||||
self.update_requested_qty()
|
||||
self.update_requested_qty_in_work_order()
|
||||
if self.material_request_type == "Purchase":
|
||||
self.update_prevdoc_status()
|
||||
|
||||
@@ -417,6 +434,19 @@ class MaterialRequest(BuyingController):
|
||||
update_modified,
|
||||
)
|
||||
|
||||
self.update_requested_qty_in_work_order()
|
||||
|
||||
def update_requested_qty_in_work_order(self):
|
||||
"""Refresh both counters: stop and cancel also flip pick list coverage."""
|
||||
if not self.work_order or self.material_request_type != "Material Transfer":
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
service = RequiredItemsService(frappe.get_doc("Work Order", self.work_order))
|
||||
service.update_requested_qty_for_required_items()
|
||||
service.update_picked_qty_for_required_items()
|
||||
|
||||
def update_requested_qty(self, mr_item_rows=None):
|
||||
"""update requested qty (before ordered_qty is updated)"""
|
||||
item_wh_list = []
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"fieldname": "work_order",
|
||||
"fieldtype": "Link",
|
||||
"label": "Work Order",
|
||||
"options": "Work Order"
|
||||
"options": "Work Order",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "locations",
|
||||
@@ -278,7 +279,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-01 14:27:50.617011",
|
||||
"modified": "2026-08-07 10:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List",
|
||||
|
||||
@@ -240,6 +240,45 @@ class PickList(TransactionBase):
|
||||
def before_submit(self):
|
||||
self.validate_sales_order()
|
||||
self.validate_picked_items()
|
||||
self.validate_pending_qty_in_work_order()
|
||||
|
||||
def validate_pending_qty_in_work_order(self):
|
||||
"""Rows covered by a live material request must stay within that request;
|
||||
every other row must fit the work order's pending requirement."""
|
||||
if not self.work_order:
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
|
||||
live_requests = {}
|
||||
request_pending = {}
|
||||
incoming = {}
|
||||
|
||||
for row in self.locations:
|
||||
if row.material_request not in live_requests:
|
||||
live_requests[row.material_request] = is_live_material_request(row.material_request)
|
||||
|
||||
if not (row.material_request_item and live_requests[row.material_request]):
|
||||
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.picked_qty)
|
||||
continue
|
||||
|
||||
if row.material_request_item not in request_pending:
|
||||
stock_qty, ordered_qty = frappe.db.get_value(
|
||||
"Material Request Item", row.material_request_item, ["stock_qty", "ordered_qty"]
|
||||
)
|
||||
request_pending[row.material_request_item] = flt(stock_qty) - flt(ordered_qty)
|
||||
|
||||
if flt(row.picked_qty - request_pending[row.material_request_item], 6) > 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: picked qty {1} {2} exceeds the pending qty in Material Request {3}.").format(
|
||||
row.idx, row.picked_qty, row.stock_uom, row.material_request
|
||||
),
|
||||
title=_("Exceeds Requested Qty"),
|
||||
)
|
||||
request_pending[row.material_request_item] -= flt(row.picked_qty)
|
||||
|
||||
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
|
||||
|
||||
def validate_sales_order(self):
|
||||
"""Raises an exception if the `Sales Order` has reserved stock."""
|
||||
@@ -281,6 +320,7 @@ class PickList(TransactionBase):
|
||||
self.update_bundle_picked_qty()
|
||||
self.update_reference_qty()
|
||||
self.update_sales_order_picking_status()
|
||||
self.update_picked_qty_in_work_order()
|
||||
self.update_prevdoc_status()
|
||||
|
||||
def validate_expired_batches(self):
|
||||
@@ -358,6 +398,7 @@ class PickList(TransactionBase):
|
||||
self.update_bundle_picked_qty()
|
||||
self.update_reference_qty()
|
||||
self.update_sales_order_picking_status()
|
||||
self.update_picked_qty_in_work_order()
|
||||
self.delink_serial_and_batch_bundle()
|
||||
self.update_prevdoc_status()
|
||||
|
||||
@@ -494,6 +535,15 @@ class PickList(TransactionBase):
|
||||
for sales_order in sales_orders:
|
||||
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status()
|
||||
|
||||
def update_picked_qty_in_work_order(self):
|
||||
if not self.work_order:
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
work_order = frappe.get_doc("Work Order", self.work_order)
|
||||
RequiredItemsService(work_order).update_picked_qty_for_required_items()
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_stock_reservation_entries(self, notify: bool = True) -> None:
|
||||
"""Creates Stock Reservation Entries for Sales Order Items against Pick List."""
|
||||
@@ -936,6 +986,15 @@ def update_pick_list_status(pick_list):
|
||||
if pick_list:
|
||||
doc = frappe.get_doc("Pick List", pick_list)
|
||||
doc.run_method("update_status")
|
||||
doc.update_picked_qty_in_work_order()
|
||||
|
||||
|
||||
def is_live_material_request(material_request):
|
||||
if not material_request:
|
||||
return False
|
||||
|
||||
docstatus, status = frappe.db.get_value("Material Request", material_request, ["docstatus", "status"])
|
||||
return docstatus == 1 and status != "Stopped"
|
||||
|
||||
|
||||
def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
|
||||
|
||||
Reference in New Issue
Block a user