mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-17 00:25:01 +00:00
feat: stock reservation for Work Order
This commit is contained in:
@@ -2553,6 +2553,187 @@ class TestWorkOrder(IntegrationTestCase):
|
||||
status = frappe.db.get_value("Serial No", row, "status")
|
||||
self.assertEqual(status, "Consumed")
|
||||
|
||||
def test_stock_reservation_for_serialized_raw_material(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
production_item = "Test Stock Reservation FG 1"
|
||||
rm_item = "Test Stock Reservation RM 1"
|
||||
source_warehouse = "Stores - _TC"
|
||||
|
||||
make_item(production_item, {"is_stock_item": 1})
|
||||
make_item(rm_item, {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TST-SER-RES-.###"})
|
||||
|
||||
bom = make_bom(
|
||||
item=production_item,
|
||||
source_warehouse=source_warehouse,
|
||||
raw_materials=[rm_item],
|
||||
operating_cost_per_bom_quantity=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
for row in bom.exploded_items:
|
||||
make_stock_entry_test_record(
|
||||
item_code=row.item_code,
|
||||
target=source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item,
|
||||
qty=10,
|
||||
reserve_stock=1,
|
||||
source_warehouse=source_warehouse,
|
||||
)
|
||||
|
||||
self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}))
|
||||
|
||||
wo1 = make_wo_order_test_record(
|
||||
item=production_item,
|
||||
qty=10,
|
||||
reserve_stock=1,
|
||||
source_warehouse=source_warehouse,
|
||||
)
|
||||
|
||||
self.assertFalse(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo1.name}))
|
||||
|
||||
transfer_entry = frappe.get_doc(make_stock_entry(wo1.name, "Material Transfer for Manufacture", 10))
|
||||
transfer_entry.save()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
|
||||
|
||||
def test_stock_reservation_for_batched_raw_material(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
production_item = "Test Stock Reservation FG 2"
|
||||
rm_item = "Test Stock Reservation RM 2"
|
||||
source_warehouse = "Stores - _TC"
|
||||
|
||||
make_item(production_item, {"is_stock_item": 1})
|
||||
make_item(
|
||||
rm_item,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"batch_number_series": "TST-BATCH-RES-.###",
|
||||
"create_new_batch": 1,
|
||||
},
|
||||
)
|
||||
|
||||
bom = make_bom(
|
||||
item=production_item,
|
||||
source_warehouse=source_warehouse,
|
||||
raw_materials=[rm_item],
|
||||
operating_cost_per_bom_quantity=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
for row in bom.exploded_items:
|
||||
make_stock_entry_test_record(
|
||||
item_code=row.item_code,
|
||||
target=source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item,
|
||||
qty=10,
|
||||
reserve_stock=1,
|
||||
source_warehouse=source_warehouse,
|
||||
)
|
||||
|
||||
self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}))
|
||||
|
||||
wo1 = make_wo_order_test_record(
|
||||
item=production_item,
|
||||
qty=10,
|
||||
reserve_stock=1,
|
||||
source_warehouse=source_warehouse,
|
||||
)
|
||||
|
||||
self.assertFalse(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo1.name}))
|
||||
|
||||
transfer_entry = frappe.get_doc(make_stock_entry(wo1.name, "Material Transfer for Manufacture", 10))
|
||||
transfer_entry.save()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
|
||||
|
||||
def test_auto_stock_reservation_for_batched_raw_material(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", 1)
|
||||
|
||||
production_item = "Test Stock Reservation FG 3"
|
||||
rm_item = "Test Stock Reservation RM 3"
|
||||
source_warehouse = "Stores - _TC"
|
||||
|
||||
make_item(production_item, {"is_stock_item": 1})
|
||||
make_item(
|
||||
rm_item,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"batch_number_series": "TST-BATCH-RES-.###",
|
||||
"create_new_batch": 1,
|
||||
},
|
||||
)
|
||||
|
||||
bom = make_bom(
|
||||
item=production_item,
|
||||
source_warehouse=source_warehouse,
|
||||
raw_materials=[rm_item],
|
||||
operating_cost_per_bom_quantity=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
itemwise_batches = frappe._dict()
|
||||
for row in bom.exploded_items:
|
||||
se = make_stock_entry_test_record(
|
||||
item_code=row.item_code,
|
||||
target=source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
itemwise_batches[row.item_code] = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item,
|
||||
qty=10,
|
||||
reserve_stock=1,
|
||||
source_warehouse=source_warehouse,
|
||||
)
|
||||
|
||||
self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}))
|
||||
|
||||
for row in frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}):
|
||||
reservation_entry = frappe.get_doc("Stock Reservation Entry", row.name)
|
||||
self.assertTrue(reservation_entry.has_batch_no)
|
||||
self.assertTrue(reservation_entry.sb_entries)
|
||||
|
||||
for row in bom.exploded_items:
|
||||
make_stock_entry_test_record(
|
||||
item_code=row.item_code,
|
||||
target=source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
|
||||
transfer_entry.save()
|
||||
transfer_entry.submit()
|
||||
|
||||
for row in transfer_entry.items:
|
||||
batch_no = get_batch_from_bundle(row.serial_and_batch_bundle)
|
||||
self.assertEqual(batch_no, itemwise_batches[row.item_code])
|
||||
|
||||
|
||||
def make_operation(**kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
@@ -2878,6 +3059,7 @@ def make_wo_order_test_record(**args):
|
||||
"BOM", {"item": wo_order.production_item, "is_active": 1, "is_default": 1}
|
||||
)
|
||||
wo_order.qty = args.qty or 10
|
||||
wo_order.reserve_stock = args.reserve_stock or 0
|
||||
wo_order.wip_warehouse = args.wip_warehouse or "_Test Warehouse - _TC"
|
||||
wo_order.fg_warehouse = args.fg_warehouse or "_Test Warehouse 1 - _TC"
|
||||
wo_order.scrap_warehouse = args.fg_warehouse or "_Test Scrap Warehouse - _TC"
|
||||
|
||||
@@ -211,6 +211,28 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
frm.trigger("add_custom_button_to_return_components");
|
||||
frm.trigger("allow_alternative_item");
|
||||
frm.trigger("hide_reserve_stock_button");
|
||||
},
|
||||
|
||||
hide_reserve_stock_button(frm) {
|
||||
frm.toggle_display("reserve_stock", false);
|
||||
if (frm.doc.__onload?.enable_stock_reservation) {
|
||||
frm.toggle_display("reserve_stock", true);
|
||||
}
|
||||
},
|
||||
|
||||
has_unreserved_stock(frm) {
|
||||
let has_unreserved_stock = frm.doc.required_items.some(
|
||||
(item) => flt(item.required_qty) > flt(item.stock_reserved_qty)
|
||||
);
|
||||
|
||||
return has_unreserved_stock;
|
||||
},
|
||||
|
||||
has_reserved_stock(frm) {
|
||||
let has_reserved_stock = frm.doc.required_items.some((item) => flt(item.stock_reserved_qty) > 0);
|
||||
|
||||
return has_reserved_stock;
|
||||
},
|
||||
|
||||
add_custom_button_to_return_components: function (frm) {
|
||||
@@ -552,6 +574,12 @@ frappe.ui.form.on("Work Order", {
|
||||
erpnext.work_order.calculate_cost(frm.doc);
|
||||
erpnext.work_order.calculate_total_cost(frm);
|
||||
},
|
||||
|
||||
on_submit() {
|
||||
frappe.route_hooks.after_submit = (frm) => {
|
||||
frm.reload_doc();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Work Order Item", {
|
||||
@@ -667,6 +695,8 @@ erpnext.work_order = {
|
||||
);
|
||||
}
|
||||
|
||||
erpnext.work_order.setup_stock_reservation(frm);
|
||||
|
||||
if (!frm.doc.track_semi_finished_goods) {
|
||||
const show_start_btn =
|
||||
frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1;
|
||||
@@ -759,6 +789,38 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setup_stock_reservation(frm) {
|
||||
if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
|
||||
if (
|
||||
frm.events.has_unreserved_stock(frm) &&
|
||||
(frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty)
|
||||
) {
|
||||
frm.add_custom_button(
|
||||
__("Reserve"),
|
||||
() => erpnext.stock_reservation.make_entries(frm, "required_items"),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.events.has_reserved_stock(frm)) {
|
||||
if (frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty) {
|
||||
frm.add_custom_button(
|
||||
__("Unreserve"),
|
||||
() => erpnext.stock_reservation.unreserve_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
}
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Reserved Stock"),
|
||||
() => erpnext.stock_reservation.show_reserved_stock(frm, "required_items"),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
calculate_cost: function (doc) {
|
||||
if (doc.operations) {
|
||||
var op = doc.operations;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"process_loss_qty",
|
||||
"project",
|
||||
"track_semi_finished_goods",
|
||||
"reserve_stock",
|
||||
"warehouses",
|
||||
"source_warehouse",
|
||||
"wip_warehouse",
|
||||
@@ -102,7 +103,7 @@
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "status",
|
||||
"oldfieldtype": "Select",
|
||||
"options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nClosed\nCancelled",
|
||||
"options": "\nDraft\nSubmitted\nNot Started\nIn Process\nStock Reserved\nStock Partially Reserved\nCompleted\nStopped\nClosed\nCancelled",
|
||||
"read_only": 1,
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
@@ -214,7 +215,6 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "sales_order",
|
||||
"fieldtype": "Link",
|
||||
"in_global_search": 1,
|
||||
@@ -322,6 +322,8 @@
|
||||
"label": "Expected Delivery Date"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:!doc.operations",
|
||||
"fieldname": "operations_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Operations",
|
||||
@@ -584,6 +586,12 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Track Semi Finished Goods",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "reserve_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": " Reserve Stock"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cogs",
|
||||
@@ -591,7 +599,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:13:00.129434",
|
||||
"modified": "2024-09-23 16:56:00.483027",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -19,6 +20,7 @@ from frappe.utils import (
|
||||
getdate,
|
||||
now,
|
||||
nowdate,
|
||||
parse_json,
|
||||
time_diff_in_hours,
|
||||
)
|
||||
from pypika import functions as fn
|
||||
@@ -34,6 +36,7 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
|
||||
from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty
|
||||
from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company
|
||||
from erpnext.utilities.transaction_base import validate_uom_is_integer
|
||||
@@ -73,9 +76,7 @@ class WorkOrder(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order_item.work_order_item import WorkOrderItem
|
||||
from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import (
|
||||
WorkOrderOperation,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import WorkOrderOperation
|
||||
|
||||
actual_end_date: DF.Datetime | None
|
||||
actual_operating_cost: DF.Currency
|
||||
@@ -89,7 +90,7 @@ class WorkOrder(Document):
|
||||
corrective_operation_cost: DF.Currency
|
||||
description: DF.SmallText | None
|
||||
expected_delivery_date: DF.Date | None
|
||||
fg_warehouse: DF.Link
|
||||
fg_warehouse: DF.Link | None
|
||||
from_wip_warehouse: DF.Check
|
||||
has_batch_no: DF.Check
|
||||
has_serial_no: DF.Check
|
||||
@@ -114,6 +115,7 @@ class WorkOrder(Document):
|
||||
project: DF.Link | None
|
||||
qty: DF.Float
|
||||
required_items: DF.Table[WorkOrderItem]
|
||||
reserve_stock: DF.Check
|
||||
sales_order: DF.Link | None
|
||||
sales_order_item: DF.Data | None
|
||||
scrap_warehouse: DF.Link | None
|
||||
@@ -125,6 +127,8 @@ class WorkOrder(Document):
|
||||
"Submitted",
|
||||
"Not Started",
|
||||
"In Process",
|
||||
"Stock Reserved",
|
||||
"Stock Partially Reserved",
|
||||
"Completed",
|
||||
"Stopped",
|
||||
"Closed",
|
||||
@@ -132,6 +136,7 @@ class WorkOrder(Document):
|
||||
]
|
||||
stock_uom: DF.Link | None
|
||||
total_operating_cost: DF.Currency
|
||||
track_semi_finished_goods: DF.Check
|
||||
transfer_material_against: DF.Literal["", "Work Order", "Job Card"]
|
||||
update_consumed_material_cost_in_project: DF.Check
|
||||
use_multi_level_bom: DF.Check
|
||||
@@ -144,6 +149,10 @@ class WorkOrder(Document):
|
||||
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
|
||||
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
|
||||
self.set_onload("show_create_job_card_button", self.show_create_job_card_button())
|
||||
self.set_onload(
|
||||
"enable_stock_reservation",
|
||||
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"),
|
||||
)
|
||||
|
||||
def show_create_job_card_button(self):
|
||||
operation_details = frappe._dict(
|
||||
@@ -178,6 +187,8 @@ class WorkOrder(Document):
|
||||
self.status = self.get_status()
|
||||
self.validate_workstation_type()
|
||||
self.reset_use_multi_level_bom()
|
||||
self.set_reserve_stock()
|
||||
self.validate_fg_warehouse_for_reservation()
|
||||
|
||||
if self.source_warehouse:
|
||||
self.set_warehouses()
|
||||
@@ -185,6 +196,31 @@ class WorkOrder(Document):
|
||||
validate_uom_is_integer(self, "stock_uom", ["qty", "produced_qty"])
|
||||
|
||||
self.set_required_items(reset_only_qty=len(self.get("required_items")))
|
||||
self.enable_auto_reserve_stock()
|
||||
|
||||
def validate_fg_warehouse_for_reservation(self):
|
||||
if self.reserve_stock and self.sales_order:
|
||||
warehouses = frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"parent": self.sales_order, "item_code": self.production_item},
|
||||
pluck="warehouse",
|
||||
)
|
||||
|
||||
if self.fg_warehouse not in warehouses:
|
||||
frappe.throw(
|
||||
_("Warehouse {0} is not allowed for Sales Order {1}, it should be {2}").format(
|
||||
self.fg_warehouse, self.sales_order, warehouses[0]
|
||||
),
|
||||
title=_("Target Warehouse Reservation Error"),
|
||||
)
|
||||
|
||||
def set_reserve_stock(self):
|
||||
for row in self.required_items:
|
||||
row.reserve_stock = self.reserve_stock
|
||||
|
||||
def enable_auto_reserve_stock(self):
|
||||
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||
self.reserve_stock = 1
|
||||
|
||||
def set_warehouses(self):
|
||||
for row in self.required_items:
|
||||
@@ -380,6 +416,17 @@ class WorkOrder(Document):
|
||||
):
|
||||
status = "In Process"
|
||||
|
||||
if status == "Not Started" and self.reserve_stock:
|
||||
for row in self.required_items:
|
||||
if not row.stock_reserved_qty:
|
||||
continue
|
||||
|
||||
if row.stock_reserved_qty >= row.required_qty:
|
||||
status = "Stock Reserved"
|
||||
else:
|
||||
status = "Stock Partially Reserved"
|
||||
break
|
||||
|
||||
return status
|
||||
|
||||
def update_work_order_qty(self):
|
||||
@@ -496,6 +543,9 @@ class WorkOrder(Document):
|
||||
self.update_planned_qty()
|
||||
self.create_job_card()
|
||||
|
||||
if self.reserve_stock:
|
||||
self.update_stock_reservation()
|
||||
|
||||
def on_cancel(self):
|
||||
self.validate_cancel()
|
||||
self.db_set("status", "Cancelled")
|
||||
@@ -514,6 +564,13 @@ class WorkOrder(Document):
|
||||
self.update_reserved_qty_for_production()
|
||||
self.delete_auto_created_batch_and_serial_no()
|
||||
|
||||
if self.reserve_stock:
|
||||
self.update_stock_reservation()
|
||||
|
||||
def update_stock_reservation(self):
|
||||
make_stock_reservation_entries(self)
|
||||
self.db_set("status", self.get_status())
|
||||
|
||||
def create_serial_no_batch_no(self):
|
||||
if not (self.has_serial_no or self.has_batch_no):
|
||||
return
|
||||
@@ -1091,6 +1148,8 @@ class WorkOrder(Document):
|
||||
# update in bin
|
||||
self.update_reserved_qty_for_production()
|
||||
|
||||
self.validate_reserved_qty()
|
||||
|
||||
def update_reserved_qty_for_production(self, items=None):
|
||||
"""update reserved_qty_for_production in bins"""
|
||||
for d in self.required_items:
|
||||
@@ -1184,9 +1243,33 @@ class WorkOrder(Document):
|
||||
transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
|
||||
|
||||
for row in self.required_items:
|
||||
row.db_set(
|
||||
"transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False
|
||||
)
|
||||
transferred_qty = transferred_items.get(row.item_code) or 0.0
|
||||
row.db_set("transferred_qty", transferred_qty, update_modified=False)
|
||||
|
||||
if not self.reserve_stock:
|
||||
return
|
||||
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.name)
|
||||
for row in self.required_items:
|
||||
self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
|
||||
|
||||
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
|
||||
if name := frappe.db.get_value(
|
||||
"Stock Reservation Entry",
|
||||
{
|
||||
"voucher_no": self.name,
|
||||
"item_code": row.item_code,
|
||||
"voucher_detail_no": row.name,
|
||||
"warehouse": row.source_warehouse,
|
||||
},
|
||||
"name",
|
||||
):
|
||||
doc = frappe.get_doc("Stock Reservation Entry", name)
|
||||
doc.db_set("transferred_qty", flt(transferred_qty), update_modified=False)
|
||||
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
|
||||
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
|
||||
def update_returned_qty(self):
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
@@ -1221,30 +1304,71 @@ class WorkOrder(Document):
|
||||
Update consumed qty from submitted stock entries
|
||||
against a work order for each stock item
|
||||
"""
|
||||
wip_warehouse = self.wip_warehouse
|
||||
if self.skip_transfer and not self.from_wip_warehouse:
|
||||
wip_warehouse = None
|
||||
|
||||
for item in self.required_items:
|
||||
consumed_qty = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
SUM(detail.qty)
|
||||
FROM
|
||||
`tabStock Entry` entry,
|
||||
`tabStock Entry Detail` detail
|
||||
WHERE
|
||||
entry.work_order = %(name)s
|
||||
AND (entry.purpose = "Material Consumption for Manufacture"
|
||||
OR entry.purpose = "Manufacture")
|
||||
AND entry.docstatus = 1
|
||||
AND detail.parent = entry.name
|
||||
AND detail.s_warehouse IS NOT null
|
||||
AND (detail.item_code = %(item)s
|
||||
OR detail.original_item = %(item)s)
|
||||
""",
|
||||
{"name": self.name, "item": item.item_code},
|
||||
)[0][0]
|
||||
|
||||
consumed_qty = get_consumed_qty(self.name, item.item_code)
|
||||
item.db_set("consumed_qty", flt(consumed_qty), update_modified=False)
|
||||
|
||||
if not self.reserve_stock:
|
||||
continue
|
||||
|
||||
wip_warehouse = wip_warehouse or item.source_warehouse
|
||||
self.update_consumed_qty_in_stock_reservation(item, consumed_qty, wip_warehouse)
|
||||
|
||||
def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
|
||||
filters = {
|
||||
"voucher_no": self.name,
|
||||
"item_code": item.item_code,
|
||||
"voucher_detail_no": item.name,
|
||||
"warehouse": wip_warehouse,
|
||||
"docstatus": 1,
|
||||
}
|
||||
if not self.skip_transfer:
|
||||
filters["from_voucher_no"] = ("is", "set")
|
||||
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.name, "Manufacture")
|
||||
|
||||
if names := frappe.get_all(
|
||||
"Stock Reservation Entry", filters=filters, pluck="name", order_by="creation"
|
||||
):
|
||||
for name in names:
|
||||
if consumed_qty < 0:
|
||||
consumed_qty = 0
|
||||
|
||||
doc = frappe.get_doc("Stock Reservation Entry", name)
|
||||
reserved_qty = doc.reserved_qty
|
||||
qty_to_update = consumed_qty if consumed_qty < reserved_qty else reserved_qty
|
||||
if qty_to_update >= 0:
|
||||
doc.db_set("consumed_qty", flt(qty_to_update), update_modified=False)
|
||||
consumed_qty -= qty_to_update
|
||||
|
||||
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
|
||||
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
|
||||
def validate_reserved_qty(self):
|
||||
sre_details = get_sre_details(self.name)
|
||||
for item in self.required_items:
|
||||
if details := sre_details.get(item.name):
|
||||
if details.reserved_qty < details.consumed_qty:
|
||||
frappe.throw(
|
||||
_("Consumed Qty cannot be greater than Reserved Qty for item {0}").format(
|
||||
details.consumed_qty, details.reserved_qty, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
if details.reserved_qty < details.transferred_qty:
|
||||
frappe.throw(
|
||||
_("Transferred Qty {0} cannot be greater than Reserved Qty {1} for item {2}").format(
|
||||
details.transferred_qty, details.reserved_qty, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_bom(self):
|
||||
data = frappe.db.sql(
|
||||
@@ -1271,6 +1395,213 @@ class WorkOrder(Document):
|
||||
bom.set_bom_material_details()
|
||||
return bom
|
||||
|
||||
def set_reserved_qty_for_wip_and_fg(self, stock_entry):
|
||||
items = frappe._dict()
|
||||
if stock_entry.purpose == "Manufacture" and self.sales_order:
|
||||
items = self.get_finished_goods_for_reservation(stock_entry)
|
||||
elif stock_entry.purpose == "Material Transfer for Manufacture":
|
||||
items = self.get_list_of_materials_for_reservation(stock_entry)
|
||||
|
||||
if not items:
|
||||
return
|
||||
|
||||
item_list = list(items.values())
|
||||
make_stock_reservation_entries(self, item_list, notify=True)
|
||||
|
||||
def get_list_of_materials_for_reservation(self, stock_entry):
|
||||
items = frappe._dict()
|
||||
vocher_detail_no = {d.item_code: d.name for d in self.required_items}
|
||||
|
||||
for row in stock_entry.items:
|
||||
if row.item_code not in items:
|
||||
items[row.item_code] = frappe._dict(
|
||||
{
|
||||
"voucher_no": self.name,
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_detail_no": vocher_detail_no.get(row.item_code),
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.t_warehouse,
|
||||
"stock_qty": row.transfer_qty,
|
||||
"from_voucher_no": stock_entry.name,
|
||||
"from_voucher_type": stock_entry.doctype,
|
||||
"from_voucher_detail_no": row.name,
|
||||
"serial_and_batch_bundles": [row.serial_and_batch_bundle],
|
||||
}
|
||||
)
|
||||
else:
|
||||
items[row.item_code]["stock_qty"] += row.transfer_qty
|
||||
if row.serial_and_batch_bundle:
|
||||
items[row.item_code]["serial_and_batch_bundles"].append(row.serial_and_batch_bundle)
|
||||
|
||||
return items
|
||||
|
||||
def get_finished_goods_for_reservation(self, stock_entry):
|
||||
items = frappe._dict()
|
||||
|
||||
so_details = self.get_so_details()
|
||||
qty = so_details.stock_qty - so_details.stock_reserved_qty
|
||||
if not qty:
|
||||
return items
|
||||
|
||||
for row in stock_entry.items:
|
||||
if not row.t_warehouse or not row.is_finished_item:
|
||||
continue
|
||||
|
||||
if qty > row.transfer_qty:
|
||||
qty = row.transfer_qty
|
||||
|
||||
if row.item_code not in items:
|
||||
items[row.item_code] = frappe._dict(
|
||||
{
|
||||
"voucher_no": self.sales_order,
|
||||
"voucher_type": "Sales Order",
|
||||
"voucher_detail_no": so_details.name,
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.t_warehouse,
|
||||
"stock_qty": qty,
|
||||
"from_voucher_no": stock_entry.name,
|
||||
"from_voucher_type": stock_entry.doctype,
|
||||
"from_voucher_detail_no": row.name,
|
||||
}
|
||||
)
|
||||
else:
|
||||
items[row.item_code]["stock_qty"] += qty
|
||||
|
||||
return items
|
||||
|
||||
def get_so_details(self):
|
||||
return frappe.db.get_value(
|
||||
"Sales Order Item",
|
||||
{
|
||||
"parent": self.sales_order,
|
||||
"item_code": self.production_item,
|
||||
"docstatus": 1,
|
||||
"stock_reserved_qty": 0,
|
||||
},
|
||||
["name", "stock_qty", "stock_reserved_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
def get_voucher_details(self, stock_entry):
|
||||
vocher_detail_no = {}
|
||||
|
||||
if stock_entry.purpose == "Manufacture" and self.sales_order:
|
||||
so_details = frappe.db.get_value(
|
||||
"Sales Order Item",
|
||||
{
|
||||
"parent": self.sales_order,
|
||||
"item_code": self.production_item,
|
||||
"docstatus": 1,
|
||||
"stock_reserved_qty": 0,
|
||||
},
|
||||
["name", "stock_qty", "stock_reserved_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
vocher_detail_no = {self.production_item: so_details}
|
||||
else:
|
||||
vocher_detail_no = {d.item_code: d.name for d in self.required_items}
|
||||
|
||||
return frappe._dict(vocher_detail_no)
|
||||
|
||||
def cancel_reserved_qty_for_wip_and_fg(self, ste_doc):
|
||||
for row in ste_doc.items:
|
||||
sre_list = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={
|
||||
"from_voucher_no": ste_doc.name,
|
||||
"from_voucher_detail_no": row.name,
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
if sre_list:
|
||||
cancel_stock_reservation_entries(self, sre_list)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_reservation_entries(doc, items=None, notify=False):
|
||||
if isinstance(doc, str):
|
||||
doc = parse_json(doc)
|
||||
doc = frappe.get_doc("Work Order", doc.get("name"))
|
||||
|
||||
if items and isinstance(items, str):
|
||||
items = parse_json(items)
|
||||
|
||||
sre = StockReservation(doc, items=items, notify=notify)
|
||||
if doc.docstatus == 1:
|
||||
sre.make_stock_reservation_entries()
|
||||
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
|
||||
elif doc.docstatus == 2:
|
||||
sre.cancel_stock_reservation_entries()
|
||||
|
||||
doc.reload()
|
||||
doc.db_set("status", doc.get_status())
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_stock_reservation_entries(doc, sre_list):
|
||||
if isinstance(doc, str):
|
||||
doc = parse_json(doc)
|
||||
doc = frappe.get_doc("Work Order", doc.get("name"))
|
||||
|
||||
sre = StockReservation(doc)
|
||||
sre.cancel_stock_reservation_entries(sre_list)
|
||||
|
||||
doc.reload()
|
||||
doc.db_set("status", doc.get_status())
|
||||
|
||||
|
||||
def get_sre_details(work_order):
|
||||
sre_details = frappe._dict()
|
||||
|
||||
data = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={"voucher_no": work_order, "docstatus": 1},
|
||||
fields=[
|
||||
"item_code",
|
||||
"warehouse",
|
||||
"reserved_qty",
|
||||
"transferred_qty",
|
||||
"consumed_qty",
|
||||
"voucher_detail_no",
|
||||
],
|
||||
)
|
||||
|
||||
for row in data:
|
||||
if row.voucher_detail_no not in sre_details:
|
||||
sre_details.setdefault(row.voucher_detail_no, row)
|
||||
else:
|
||||
sre_details[row.voucher_detail_no].reserved_qty += row.reserved_qty
|
||||
sre_details[row.voucher_detail_no].transferred_qty += row.transferred_qty
|
||||
sre_details[row.voucher_detail_no].consumed_qty += row.consumed_qty
|
||||
|
||||
return sre_details
|
||||
|
||||
|
||||
def get_consumed_qty(work_order, item_code):
|
||||
stock_entry = frappe.qb.DocType("Stock Entry")
|
||||
stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(stock_entry)
|
||||
.inner_join(stock_entry_detail)
|
||||
.on(stock_entry_detail.parent == stock_entry.name)
|
||||
.select(fn.Sum(stock_entry_detail.qty).as_("qty"))
|
||||
.where(
|
||||
(stock_entry.work_order == work_order)
|
||||
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry_detail.s_warehouse.isnotnull())
|
||||
& ((stock_entry_detail.item_code == item_code) | (stock_entry_detail.original_item == item_code))
|
||||
)
|
||||
)
|
||||
|
||||
result = query.run()
|
||||
|
||||
return flt(result[0][0]) if result else 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
@@ -1826,3 +2157,57 @@ def make_stock_return_entry(work_order):
|
||||
stock_entry.set_stock_entry_type()
|
||||
|
||||
return stock_entry
|
||||
|
||||
|
||||
def get_row_wise_serial_batch(work_order, purpose=None):
|
||||
if not purpose:
|
||||
purpose = "Material Transfer for Manufacture"
|
||||
|
||||
stock_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={
|
||||
"work_order": work_order,
|
||||
"purpose": purpose,
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
serial_batch_entries = frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
fields=[
|
||||
"`tabSerial and Batch Entry`.`serial_no`",
|
||||
"`tabSerial and Batch Entry`.`batch_no`",
|
||||
"`tabSerial and Batch Entry`.`qty`",
|
||||
"`tabSerial and Batch Bundle`.`warehouse`",
|
||||
"`tabSerial and Batch Bundle`.`item_code`",
|
||||
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
|
||||
],
|
||||
filters=[
|
||||
["Serial and Batch Bundle", "voucher_type", "=", "Stock Entry"],
|
||||
["Serial and Batch Bundle", "voucher_no", "in", stock_entries],
|
||||
["Serial and Batch Bundle", "voucher_detail_no", "is", "set"],
|
||||
["Serial and Batch Bundle", "docstatus", "<", 2],
|
||||
["Serial and Batch Bundle", "is_cancelled", "=", 0],
|
||||
["Serial and Batch Entry", "qty", "<", 0],
|
||||
],
|
||||
)
|
||||
|
||||
row_wise_serial_batch = {}
|
||||
for entry in serial_batch_entries:
|
||||
key = (entry.item_code, entry.warehouse, entry.voucher_detail_no)
|
||||
if key not in row_wise_serial_batch:
|
||||
row_wise_serial_batch[key] = frappe._dict(
|
||||
{
|
||||
"serial_nos": [],
|
||||
"batch_nos": defaultdict(float),
|
||||
}
|
||||
)
|
||||
|
||||
details = row_wise_serial_batch[key]
|
||||
if entry.serial_no:
|
||||
details.serial_nos.append(entry.serial_no)
|
||||
if entry.batch_no:
|
||||
details.batch_nos[entry.batch_no] += abs(entry.qty)
|
||||
|
||||
return row_wise_serial_batch
|
||||
|
||||
@@ -4,9 +4,13 @@ from frappe import _
|
||||
def get_data():
|
||||
return {
|
||||
"fieldname": "work_order",
|
||||
"non_standard_fieldnames": {"Batch": "reference_name"},
|
||||
"non_standard_fieldnames": {
|
||||
"Batch": "reference_name",
|
||||
"Stock Reservation Entry": "voucher_no",
|
||||
},
|
||||
"transactions": [
|
||||
{"label": _("Transactions"), "items": ["Stock Entry", "Job Card", "Pick List"]},
|
||||
{"label": _("Reference"), "items": ["Serial No", "Batch", "Material Request"]},
|
||||
{"label": _("Stock Reservation"), "items": ["Stock Reservation Entry"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ frappe.listview_settings["Work Order"] = {
|
||||
"Not Started": "red",
|
||||
"In Process": "orange",
|
||||
Completed: "green",
|
||||
"Stock Reserved": "blue",
|
||||
"Stock Partially Reserved": "orange",
|
||||
Cancelled: "gray",
|
||||
}[doc.status],
|
||||
"status,=," + doc.status,
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
"transferred_qty",
|
||||
"consumed_qty",
|
||||
"returned_qty",
|
||||
"section_break_idhr",
|
||||
"available_qty_at_source_warehouse",
|
||||
"available_qty_at_wip_warehouse"
|
||||
"available_qty_at_wip_warehouse",
|
||||
"column_break_jash",
|
||||
"stock_reserved_qty"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -152,13 +155,28 @@
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "Stock UOM",
|
||||
"options": "UOM",
|
||||
"options": "UOM"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_idhr",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_jash",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_reserved_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Stock Reserved Qty",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-19 15:48:16.823384",
|
||||
"modified": "2024-11-20 15:48:16.823384",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -34,6 +34,7 @@ class WorkOrderItem(Document):
|
||||
returned_qty: DF.Float
|
||||
source_warehouse: DF.Link | None
|
||||
stock_uom: DF.Link | None
|
||||
stock_reserved_qty: DF.Float
|
||||
transferred_qty: DF.Float
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./conf";
|
||||
import "./utils";
|
||||
import "./stock_reservation";
|
||||
import "./queries";
|
||||
import "./sms_manager";
|
||||
import "./utils/party";
|
||||
|
||||
339
erpnext/public/js/stock_reservation.js
Normal file
339
erpnext/public/js/stock_reservation.js
Normal file
@@ -0,0 +1,339 @@
|
||||
frappe.provide("erpnext.stock_reservation");
|
||||
|
||||
$.extend(erpnext.stock_reservation, {
|
||||
make_entries(frm, table_name) {
|
||||
erpnext.stock_reservation.setup(frm, table_name);
|
||||
},
|
||||
|
||||
setup(frm, table_name) {
|
||||
let parms = erpnext.stock_reservation.get_parms(frm, table_name);
|
||||
|
||||
erpnext.stock_reservation.dialog = new frappe.ui.Dialog({
|
||||
title: __("Stock Reservation"),
|
||||
size: "extra-large",
|
||||
fields: erpnext.stock_reservation.get_dialog_fields(frm, parms),
|
||||
primary_action_label: __("Reserve Stock"),
|
||||
primary_action: () => {
|
||||
erpnext.stock_reservation.reserve_stock(frm, parms);
|
||||
},
|
||||
});
|
||||
|
||||
erpnext.stock_reservation.render_items(frm, parms);
|
||||
},
|
||||
|
||||
get_parms(frm, table_name) {
|
||||
let params = {
|
||||
table_name: table_name || "items",
|
||||
child_doctype: frm.doc.doctype + " Item",
|
||||
};
|
||||
|
||||
params["qty_field"] = {
|
||||
"Sales Order": "stock_qty",
|
||||
"Work Order": "required_qty",
|
||||
}[frm.doc.doctype];
|
||||
|
||||
params["dispatch_qty_field"] = {
|
||||
"Sales Order": "delivered_qty",
|
||||
"Work Order": "transferred_qty",
|
||||
}[frm.doc.doctype];
|
||||
|
||||
params["method"] = {
|
||||
"Sales Order": "delivered_qty",
|
||||
"Work Order":
|
||||
"erpnext.manufacturing.doctype.work_order.work_order.make_stock_reservation_entries",
|
||||
}[frm.doc.doctype];
|
||||
|
||||
return params;
|
||||
},
|
||||
|
||||
get_dialog_fields(frm, parms) {
|
||||
let fields = erpnext.stock_reservation.fields || [];
|
||||
let qty_field = parms.qty_field;
|
||||
let dialog = erpnext.stock_reservation.dialog;
|
||||
|
||||
let table_fields = [
|
||||
{ fieldtype: "Section Break" },
|
||||
{
|
||||
fieldname: "items",
|
||||
fieldtype: "Table",
|
||||
label: __("Items to Reserve"),
|
||||
allow_bulk_edit: false,
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
data: [],
|
||||
fields: [
|
||||
{
|
||||
fieldname: frappe.scrub(parms.child_doctype),
|
||||
fieldtype: "Link",
|
||||
label: __(parms.child_doctype),
|
||||
options: parms.child_doctype,
|
||||
reqd: 1,
|
||||
in_list_view: 1,
|
||||
get_query: () => {
|
||||
return {
|
||||
query: "erpnext.controllers.queries.get_filtered_child_rows",
|
||||
filters: {
|
||||
parenttype: frm.doc.doctype,
|
||||
parent: frm.doc.name,
|
||||
reserve_stock: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
onchange: (event) => {
|
||||
if (event) {
|
||||
let name = $(event.currentTarget).closest(".grid-row").attr("data-name");
|
||||
let item_row = dialog.fields_dict.items.grid.grid_rows_by_docname[name].doc;
|
||||
|
||||
frm.doc.items.forEach((item) => {
|
||||
if (item.name === item_row.sales_order_item) {
|
||||
item_row.item_code = item.item_code;
|
||||
}
|
||||
});
|
||||
dialog.fields_dict.items.grid.refresh();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
label: __("Item Code"),
|
||||
options: "Item",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
fieldtype: "Link",
|
||||
label: __("Warehouse"),
|
||||
options: "Warehouse",
|
||||
reqd: 1,
|
||||
in_list_view: 1,
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: [["Warehouse", "is_group", "!=", 1]],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: qty_field,
|
||||
fieldtype: "Float",
|
||||
label: __("Qty"),
|
||||
reqd: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return fields.concat(table_fields);
|
||||
},
|
||||
|
||||
render_items(frm, parms) {
|
||||
let dialog = erpnext.stock_reservation.dialog;
|
||||
let field = frappe.scrub(parms.child_doctype);
|
||||
|
||||
let qty_field = parms.qty_field;
|
||||
let dispatch_qty_field = parms.dispatch_qty_field;
|
||||
|
||||
if (frm.doc.doctype === "Work Order" && frm.doc.skip_transfer) {
|
||||
dispatch_qty_field = "consumed_qty";
|
||||
}
|
||||
|
||||
frm.doc[parms.table_name].forEach((item) => {
|
||||
if (frm.doc.reserve_stock) {
|
||||
let unreserved_qty =
|
||||
(flt(item[qty_field]) -
|
||||
(item.stock_reserved_qty
|
||||
? flt(item.stock_reserved_qty)
|
||||
: flt(item[dispatch_qty_field]) * flt(item.conversion_factor || 1))) /
|
||||
flt(item.conversion_factor || 1);
|
||||
|
||||
if (unreserved_qty > 0) {
|
||||
let args = {
|
||||
__checked: 1,
|
||||
item_code: item.item_code,
|
||||
warehouse: item.warehouse || item.source_warehouse,
|
||||
};
|
||||
|
||||
args[field] = item.name;
|
||||
args[qty_field] = unreserved_qty;
|
||||
dialog.fields_dict.items.df.data.push(args);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialog.fields_dict.items.grid.refresh();
|
||||
dialog.show();
|
||||
},
|
||||
|
||||
reserve_stock(frm, parms) {
|
||||
let dialog = erpnext.stock_reservation.dialog;
|
||||
var data = { items: dialog.fields_dict.items.grid.get_selected_children() };
|
||||
|
||||
if (data.items && data.items.length > 0) {
|
||||
frappe.call({
|
||||
method: parms.method,
|
||||
args: {
|
||||
doc: frm.doc,
|
||||
items: data.items,
|
||||
notify: true,
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __("Reserving Stock..."),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_unreserved_stock = false;
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
|
||||
dialog.hide();
|
||||
} else {
|
||||
frappe.msgprint(__("Please select items to reserve."));
|
||||
}
|
||||
},
|
||||
|
||||
unreserve_stock(frm) {
|
||||
erpnext.stock_reservation.get_stock_reservation_entries(frm.doctype, frm.docname).then((r) => {
|
||||
if (!r.exc && r.message) {
|
||||
if (r.message.length > 0) {
|
||||
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, r.message);
|
||||
} else {
|
||||
frappe.msgprint(__("No reserved stock to unreserve."));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
prepare_for_cancel_sre_entries(frm, sre_entries) {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Stock Unreservation"),
|
||||
size: "extra-large",
|
||||
fields: [
|
||||
{
|
||||
fieldname: "sr_entries",
|
||||
fieldtype: "Table",
|
||||
label: __("Reserved Stock"),
|
||||
allow_bulk_edit: false,
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
in_place_edit: true,
|
||||
data: [],
|
||||
fields: erpnext.stock_reservation.get_fields_for_cancel(),
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Unreserve Stock"),
|
||||
primary_action: () => {
|
||||
erpnext.stock_reservation.cancel_stock_reservation(dialog, frm);
|
||||
},
|
||||
});
|
||||
|
||||
sre_entries.forEach((sre) => {
|
||||
dialog.fields_dict.sr_entries.df.data.push({
|
||||
sre: sre.name,
|
||||
item_code: sre.item_code,
|
||||
warehouse: sre.warehouse,
|
||||
qty: flt(sre.reserved_qty) - flt(sre.delivered_qty),
|
||||
});
|
||||
});
|
||||
|
||||
dialog.fields_dict.sr_entries.grid.refresh();
|
||||
dialog.show();
|
||||
},
|
||||
|
||||
cancel_stock_reservation(dialog, frm) {
|
||||
var data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
|
||||
|
||||
if (data.sr_entries?.length > 0) {
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.doctype.work_order.work_order.cancel_stock_reservation_entries",
|
||||
args: {
|
||||
doc: frm.doc,
|
||||
sre_list: data.sr_entries.map((item) => item.sre),
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __("Unreserving Stock..."),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_reserved_stock = false;
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
|
||||
dialog.hide();
|
||||
} else {
|
||||
frappe.msgprint(__("Please select items to unreserve."));
|
||||
}
|
||||
},
|
||||
|
||||
get_stock_reservation_entries(voucher_type, voucher_no) {
|
||||
return frappe.call({
|
||||
method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher",
|
||||
args: {
|
||||
voucher_type: voucher_type,
|
||||
voucher_no: voucher_no,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
get_fields_for_cancel() {
|
||||
return [
|
||||
{
|
||||
fieldname: "sre",
|
||||
fieldtype: "Link",
|
||||
label: __("Stock Reservation Entry"),
|
||||
options: "Stock Reservation Entry",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
label: __("Item Code"),
|
||||
options: "Item",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
fieldtype: "Link",
|
||||
label: __("Warehouse"),
|
||||
options: "Warehouse",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "qty",
|
||||
fieldtype: "Float",
|
||||
label: __("Qty"),
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
show_reserved_stock(frm, table_name) {
|
||||
if (!table_name) {
|
||||
table_name = "items";
|
||||
}
|
||||
|
||||
// Get the latest modified date from the items table.
|
||||
var to_date = moment(
|
||||
new Date(Math.max(...frm.doc[table_name].map((e) => new Date(e.modified))))
|
||||
).format("YYYY-MM-DD");
|
||||
|
||||
let from_date = frm.doc.transaction_date || new Date(frm.doc.creation);
|
||||
|
||||
frappe.route_options = {
|
||||
company: frm.doc.company,
|
||||
from_date: from_date,
|
||||
to_date: to_date,
|
||||
voucher_type: frm.doc.doctype,
|
||||
voucher_no: frm.doc.name,
|
||||
};
|
||||
frappe.set_route("query-report", "Reserved Stock");
|
||||
},
|
||||
});
|
||||
@@ -238,6 +238,11 @@ class SalesOrder(SellingController):
|
||||
self.advance_payment_status = "Not Requested"
|
||||
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.enable_auto_reserve_stock()
|
||||
|
||||
def enable_auto_reserve_stock(self):
|
||||
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
|
||||
self.reserve_stock = 1
|
||||
|
||||
def validate_po(self):
|
||||
# validate p.o date v/s delivery date
|
||||
|
||||
@@ -1826,8 +1826,22 @@ def get_reserved_serial_nos(kwargs) -> list:
|
||||
# Extend the list by serial nos reserved in POS Invoice
|
||||
ignore_serial_nos.extend(get_reserved_serial_nos_for_pos(kwargs))
|
||||
|
||||
reserved_entries = get_reserved_serial_nos_for_sre(kwargs)
|
||||
|
||||
serial_nos = []
|
||||
for entry in reserved_entries:
|
||||
if kwargs.get("serial_nos") and entry.serial_no in kwargs.get("serial_nos"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Serial No {0} is reserved against the {1} {2} and cannot be used for any other transaction."
|
||||
).format(bold(entry.serial_no), entry.voucher_type, bold(entry.voucher_no)),
|
||||
title=_("Serial No Reserved"),
|
||||
)
|
||||
|
||||
serial_nos.append(entry.serial_no)
|
||||
|
||||
# Extend the list by serial nos reserved via SRE
|
||||
ignore_serial_nos.extend(get_reserved_serial_nos_for_sre(kwargs))
|
||||
ignore_serial_nos.extend(serial_nos)
|
||||
|
||||
return ignore_serial_nos
|
||||
|
||||
@@ -1912,7 +1926,11 @@ def get_reserved_serial_nos_for_sre(kwargs) -> list:
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sb_entry.serial_no)
|
||||
.select(
|
||||
sb_entry.serial_no,
|
||||
sre.voucher_no,
|
||||
sre.voucher_type,
|
||||
)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == kwargs.item_code)
|
||||
@@ -1928,7 +1946,7 @@ def get_reserved_serial_nos_for_sre(kwargs) -> list:
|
||||
if kwargs.ignore_voucher_nos:
|
||||
query = query.where(sre.name.notin(kwargs.ignore_voucher_nos))
|
||||
|
||||
return [row[0] for row in query.run()]
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_reserved_batches_for_pos(kwargs) -> dict:
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"outgoing_rate",
|
||||
"stock_value_difference",
|
||||
"is_outward",
|
||||
"stock_queue"
|
||||
"stock_queue",
|
||||
"section_break_gmim",
|
||||
"reference_for_reservation"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -66,6 +68,7 @@
|
||||
"label": "Rate Section"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "incoming_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Valuation Rate",
|
||||
@@ -86,6 +89,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "stock_value_difference",
|
||||
"fieldtype": "Float",
|
||||
"label": "Change in Stock Value",
|
||||
@@ -117,12 +121,25 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_gmim",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_for_reservation",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Reference for Reservation",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:39.060322",
|
||||
"modified": "2025-01-02 21:51:52.528916",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Serial and Batch Entry",
|
||||
|
||||
@@ -23,6 +23,7 @@ class SerialandBatchEntry(Document):
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
qty: DF.Float
|
||||
reference_for_reservation: DF.Data | None
|
||||
serial_no: DF.Link | None
|
||||
stock_queue: DF.SmallText | None
|
||||
stock_value_difference: DF.Float
|
||||
|
||||
@@ -246,8 +246,10 @@ class StockEntry(StockController):
|
||||
def on_submit(self):
|
||||
self.validate_closed_subcontracting_order()
|
||||
self.make_bundle_using_old_serial_batch_fields()
|
||||
self.update_stock_ledger()
|
||||
self.update_work_order()
|
||||
self.update_stock_ledger()
|
||||
self.make_stock_reserve_for_wip_and_fg()
|
||||
|
||||
self.validate_subcontract_order()
|
||||
self.update_subcontract_order_supplied_items()
|
||||
self.update_subcontracting_order_status()
|
||||
@@ -269,6 +271,7 @@ class StockEntry(StockController):
|
||||
self.validate_closed_subcontracting_order()
|
||||
self.update_subcontract_order_supplied_items()
|
||||
self.update_subcontracting_order_status()
|
||||
self.cancel_stock_reserve_for_wip_and_fg()
|
||||
|
||||
if self.work_order and self.purpose == "Material Consumption for Manufacture":
|
||||
self.validate_work_order_status()
|
||||
@@ -1614,6 +1617,32 @@ class StockEntry(StockController):
|
||||
if not pro_doc.operations:
|
||||
pro_doc.set_actual_dates()
|
||||
|
||||
def make_stock_reserve_for_wip_and_fg(self):
|
||||
if self.is_stock_reserve_for_work_order():
|
||||
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
if self.purpose == "Manufacture" and not pro_doc.sales_order:
|
||||
return
|
||||
|
||||
pro_doc.set_reserved_qty_for_wip_and_fg(self)
|
||||
|
||||
def cancel_stock_reserve_for_wip_and_fg(self):
|
||||
if self.is_stock_reserve_for_work_order():
|
||||
pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
if self.purpose == "Manufacture" and not pro_doc.sales_order:
|
||||
return
|
||||
|
||||
pro_doc.cancel_reserved_qty_for_wip_and_fg(self)
|
||||
|
||||
def is_stock_reserve_for_work_order(self):
|
||||
if (
|
||||
self.work_order
|
||||
and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"]
|
||||
and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock")
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_item_details(self, args: ItemDetailsCtx = None, for_update=False):
|
||||
item = frappe.db.sql(
|
||||
@@ -1888,11 +1917,93 @@ class StockEntry(StockController):
|
||||
self.set_process_loss_qty()
|
||||
self.load_items_from_bom()
|
||||
|
||||
self.set_serial_batch_from_reserved_entry()
|
||||
self.set_scrap_items()
|
||||
self.set_actual_qty()
|
||||
self.validate_customer_provided_item()
|
||||
self.calculate_rate_and_amount(raise_error_if_no_rate=False)
|
||||
|
||||
def set_serial_batch_from_reserved_entry(self):
|
||||
if not self.work_order:
|
||||
return
|
||||
|
||||
if not frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"):
|
||||
return
|
||||
|
||||
if self.purpose not in ["Material Transfer for Manufacture", "Manufacture"]:
|
||||
return
|
||||
|
||||
reservation_entries = self.get_available_reserved_materials()
|
||||
|
||||
for d in self.items:
|
||||
key = (d.item_code, d.s_warehouse)
|
||||
if details := reservation_entries.get(key):
|
||||
if details.get("serial_no"):
|
||||
d.serial_no = "\n".join(details.get("serial_no"))
|
||||
|
||||
if batches := details.get("batch_no"):
|
||||
for batch_no, qty in batches.items():
|
||||
if qty <= 0:
|
||||
continue
|
||||
|
||||
if qty >= d.qty:
|
||||
d.batch_no = batch_no
|
||||
batches[batch_no] -= d.qty
|
||||
else:
|
||||
d.batch_no = batch_no
|
||||
d.qty = qty
|
||||
batches[batch_no] = 0
|
||||
|
||||
d.use_serial_batch_fields = 1
|
||||
|
||||
def get_available_reserved_materials(self):
|
||||
reserved_entries = self.get_reserved_materials()
|
||||
if not reserved_entries:
|
||||
return {}
|
||||
|
||||
itemwise_serial_batch_qty = frappe._dict()
|
||||
|
||||
for d in reserved_entries:
|
||||
key = (d.item_code, d.warehouse)
|
||||
if key not in itemwise_serial_batch_qty:
|
||||
itemwise_serial_batch_qty[key] = frappe._dict(
|
||||
{
|
||||
"serial_no": [],
|
||||
"batch_no": defaultdict(float),
|
||||
}
|
||||
)
|
||||
|
||||
details = itemwise_serial_batch_qty[key]
|
||||
if d.serial_no:
|
||||
details.serial_no.append(d.serial_no)
|
||||
if d.batch_no:
|
||||
details.batch_no[d.batch_no] += d.qty
|
||||
|
||||
return itemwise_serial_batch_qty
|
||||
|
||||
def get_reserved_materials(self):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(doctype)
|
||||
.inner_join(serial_batch_doc)
|
||||
.on(doctype.name == serial_batch_doc.parent)
|
||||
.select(
|
||||
serial_batch_doc.serial_no,
|
||||
serial_batch_doc.batch_no,
|
||||
serial_batch_doc.qty,
|
||||
doctype.item_code,
|
||||
doctype.warehouse,
|
||||
doctype.name,
|
||||
doctype.transferred_qty,
|
||||
doctype.consumed_qty,
|
||||
)
|
||||
.where((doctype.docstatus == 1) & (doctype.voucher_no == self.work_order))
|
||||
)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def set_scrap_items(self):
|
||||
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
|
||||
scrap_item_dict = self.get_bom_scrap_material(self.fg_completed_qty)
|
||||
|
||||
@@ -6,7 +6,7 @@ def get_data():
|
||||
return {
|
||||
"fieldname": "stock_entry",
|
||||
"non_standard_fieldnames": {
|
||||
# "DocType Name": "Reference field name",
|
||||
"Stock Reservation Entry": "from_voucher_no",
|
||||
},
|
||||
"internal_links": {
|
||||
"Purchase Order": ["items", "purchase_order"],
|
||||
@@ -22,5 +22,6 @@ def get_data():
|
||||
"Subcontracting Receipt",
|
||||
],
|
||||
},
|
||||
{"label": _("Stock Reservation"), "items": ["Stock Reservation Entry"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -8,26 +8,31 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"warehouse",
|
||||
"has_serial_no",
|
||||
"has_batch_no",
|
||||
"column_break_elik",
|
||||
"section_break_xt4m",
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
"voucher_detail_no",
|
||||
"column_break_grdt",
|
||||
"voucher_qty",
|
||||
"available_qty",
|
||||
"column_break_o6ex",
|
||||
"reserved_qty",
|
||||
"delivered_qty",
|
||||
"item_information_section",
|
||||
"item_code",
|
||||
"warehouse",
|
||||
"column_break_elik",
|
||||
"stock_uom",
|
||||
"has_serial_no",
|
||||
"has_batch_no",
|
||||
"column_break_7dxj",
|
||||
"from_voucher_type",
|
||||
"from_voucher_no",
|
||||
"from_voucher_detail_no",
|
||||
"section_break_xt4m",
|
||||
"stock_uom",
|
||||
"column_break_grdt",
|
||||
"available_qty",
|
||||
"voucher_qty",
|
||||
"column_break_o6ex",
|
||||
"reserved_qty",
|
||||
"delivered_qty",
|
||||
"production_section",
|
||||
"transferred_qty",
|
||||
"column_break_qdwj",
|
||||
"consumed_qty",
|
||||
"serial_and_batch_reservation_section",
|
||||
"reservation_based_on",
|
||||
"sb_entries",
|
||||
@@ -79,7 +84,7 @@
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "voucher_type",
|
||||
"oldfieldtype": "Data",
|
||||
"options": "\nSales Order",
|
||||
"options": "\nSales Order\nWork Order",
|
||||
"print_width": "150px",
|
||||
"read_only": 1,
|
||||
"width": "150px"
|
||||
@@ -213,16 +218,16 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_xt4m",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Transaction Information"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_o6ex",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_break_3vb3",
|
||||
"fieldtype": "Section Break",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "More Information"
|
||||
},
|
||||
{
|
||||
@@ -257,7 +262,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "serial_and_batch_reservation_section",
|
||||
"fieldtype": "Section Break",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Serial and Batch Reservation"
|
||||
},
|
||||
{
|
||||
@@ -284,7 +289,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "From Voucher Type",
|
||||
"no_copy": 1,
|
||||
"options": "\nPick List\nPurchase Receipt",
|
||||
"options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
@@ -308,6 +313,30 @@
|
||||
"read_only": 1,
|
||||
"report_hide": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "production_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Production"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qdwj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "consumed_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Consumed Qty"
|
||||
},
|
||||
{
|
||||
"fieldname": "item_information_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Item Information"
|
||||
},
|
||||
{
|
||||
"fieldname": "transferred_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Qty in WIP Warehouse"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
@@ -315,7 +344,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:45.186573",
|
||||
"modified": "2024-09-19 15:28:24.726283",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reservation Entry",
|
||||
|
||||
@@ -7,7 +7,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, nowdate, nowtime
|
||||
from frappe.utils import cint, flt, nowdate, nowtime, parse_json
|
||||
|
||||
from erpnext.stock.utils import get_or_make_bin, get_stock_balance
|
||||
|
||||
@@ -21,17 +21,16 @@ class StockReservationEntry(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.stock.doctype.serial_and_batch_entry.serial_and_batch_entry import (
|
||||
SerialandBatchEntry,
|
||||
)
|
||||
from erpnext.stock.doctype.serial_and_batch_entry.serial_and_batch_entry import SerialandBatchEntry
|
||||
|
||||
amended_from: DF.Link | None
|
||||
available_qty: DF.Float
|
||||
company: DF.Link | None
|
||||
consumed_qty: DF.Float
|
||||
delivered_qty: DF.Float
|
||||
from_voucher_detail_no: DF.Data | None
|
||||
from_voucher_no: DF.DynamicLink | None
|
||||
from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt"]
|
||||
from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order"]
|
||||
has_batch_no: DF.Check
|
||||
has_serial_no: DF.Check
|
||||
item_code: DF.Link | None
|
||||
@@ -43,10 +42,11 @@ class StockReservationEntry(Document):
|
||||
"Draft", "Partially Reserved", "Reserved", "Partially Delivered", "Delivered", "Cancelled"
|
||||
]
|
||||
stock_uom: DF.Link | None
|
||||
transferred_qty: DF.Float
|
||||
voucher_detail_no: DF.Data | None
|
||||
voucher_no: DF.DynamicLink | None
|
||||
voucher_qty: DF.Float
|
||||
voucher_type: DF.Literal["", "Sales Order"]
|
||||
voucher_type: DF.Literal["", "Sales Order", "Work Order"]
|
||||
warehouse: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -213,6 +213,8 @@ class StockReservationEntry(Document):
|
||||
|
||||
def validate_reservation_based_on_serial_and_batch(self) -> None:
|
||||
"""Validates `Reserved Qty`, `Serial and Batch Nos` when `Reservation Based On` is `Serial and Batch`."""
|
||||
if self.voucher_type == "Work Order":
|
||||
return
|
||||
|
||||
if self.reservation_based_on == "Serial and Batch":
|
||||
allow_partial_reservation = frappe.db.get_single_value(
|
||||
@@ -330,7 +332,10 @@ class StockReservationEntry(Document):
|
||||
) -> None:
|
||||
"""Updates total reserved qty in the voucher."""
|
||||
|
||||
item_doctype = "Sales Order Item" if self.voucher_type == "Sales Order" else None
|
||||
item_doctype = {
|
||||
"Sales Order": "Sales Order Item",
|
||||
"Work Order": "Work Order Item",
|
||||
}.get(self.voucher_type, None)
|
||||
|
||||
if item_doctype:
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
@@ -393,7 +398,7 @@ class StockReservationEntry(Document):
|
||||
if self.docstatus == 2:
|
||||
status = "Cancelled"
|
||||
elif self.docstatus == 1:
|
||||
if self.reserved_qty == self.delivered_qty:
|
||||
if self.reserved_qty == (self.delivered_qty or self.transferred_qty or self.consumed_qty):
|
||||
status = "Delivered"
|
||||
elif self.delivered_qty and self.delivered_qty < self.reserved_qty:
|
||||
status = "Partially Delivered"
|
||||
@@ -433,8 +438,17 @@ class StockReservationEntry(Document):
|
||||
get_available_qty_to_reserve(self.item_code, self.warehouse, ignore_sre=self.name),
|
||||
)
|
||||
|
||||
from_voucher_detail_no = None
|
||||
if self.from_voucher_type and self.from_voucher_type == "Stock Entry":
|
||||
from_voucher_detail_no = self.from_voucher_detail_no
|
||||
|
||||
total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no(
|
||||
self.voucher_type, self.voucher_no, self.voucher_detail_no, ignore_sre=self.name
|
||||
self.voucher_type,
|
||||
self.voucher_no,
|
||||
self.voucher_detail_no,
|
||||
ignore_sre=self.name,
|
||||
warehouse=self.warehouse,
|
||||
from_voucher_detail_no=from_voucher_detail_no,
|
||||
)
|
||||
|
||||
voucher_delivered_qty = 0
|
||||
@@ -491,6 +505,57 @@ class StockReservationEntry(Document):
|
||||
msg = _("Reserved Qty should be greater than Delivered Qty.")
|
||||
frappe.throw(msg)
|
||||
|
||||
def consume_serial_batch_for_material_transfer(self, row_wise_serial_batch):
|
||||
for entry in self.sb_entries:
|
||||
if entry.reference_for_reservation:
|
||||
entry.delete()
|
||||
|
||||
qty_to_consume = self.reserved_qty
|
||||
for (item_code, warehouse, reference_for_reservation), data in row_wise_serial_batch.items():
|
||||
if item_code != self.item_code or warehouse != self.warehouse:
|
||||
continue
|
||||
|
||||
remove_serial_nos = []
|
||||
for serial_no in data.serial_nos:
|
||||
if qty_to_consume <= 0:
|
||||
break
|
||||
|
||||
new_row = self.append(
|
||||
"sb_entries",
|
||||
{
|
||||
"serial_no": serial_no,
|
||||
"qty": -1,
|
||||
"warehouse": self.warehouse,
|
||||
"reference_for_reservation": reference_for_reservation,
|
||||
},
|
||||
)
|
||||
|
||||
new_row.insert()
|
||||
qty_to_consume -= 1
|
||||
remove_serial_nos.append(serial_no)
|
||||
|
||||
for sn in remove_serial_nos:
|
||||
data.serial_nos.remove(sn)
|
||||
|
||||
for batch_no, batch_qty in data.batch_nos.items():
|
||||
if qty_to_consume <= 0:
|
||||
break
|
||||
|
||||
qty = batch_qty if batch_qty <= qty_to_consume else (qty_to_consume)
|
||||
new_row = self.append(
|
||||
"sb_entries",
|
||||
{
|
||||
"batch_no": batch_no,
|
||||
"qty": qty * -1,
|
||||
"warehouse": self.warehouse,
|
||||
"reference_for_reservation": reference_for_reservation,
|
||||
},
|
||||
)
|
||||
|
||||
new_row.insert()
|
||||
qty_to_consume -= qty
|
||||
data.batch_nos[batch_no] -= qty
|
||||
|
||||
|
||||
def validate_stock_reservation_settings(voucher: object) -> None:
|
||||
"""Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
|
||||
@@ -610,7 +675,11 @@ def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str |
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"))
|
||||
.select(
|
||||
Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty).as_(
|
||||
"reserved_qty"
|
||||
)
|
||||
)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == item_code)
|
||||
@@ -709,7 +778,12 @@ def get_sre_reserved_warehouses_for_voucher(
|
||||
|
||||
|
||||
def get_sre_reserved_qty_for_voucher_detail_no(
|
||||
voucher_type: str, voucher_no: str, voucher_detail_no: str, ignore_sre=None
|
||||
voucher_type: str,
|
||||
voucher_no: str,
|
||||
voucher_detail_no: str,
|
||||
ignore_sre=None,
|
||||
warehouse=None,
|
||||
from_voucher_detail_no=None,
|
||||
) -> float:
|
||||
"""Returns `Reserved Qty` against the Voucher."""
|
||||
|
||||
@@ -717,7 +791,12 @@ def get_sre_reserved_qty_for_voucher_detail_no(
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(
|
||||
(Sum(sre.reserved_qty) - Sum(sre.delivered_qty)),
|
||||
(
|
||||
Sum(sre.reserved_qty)
|
||||
- Sum(sre.delivered_qty)
|
||||
- Sum(sre.transferred_qty)
|
||||
- Sum(sre.consumed_qty)
|
||||
),
|
||||
)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
@@ -731,6 +810,12 @@ def get_sre_reserved_qty_for_voucher_detail_no(
|
||||
if ignore_sre:
|
||||
query = query.where(sre.name != ignore_sre)
|
||||
|
||||
if warehouse:
|
||||
query = query.where(sre.warehouse == warehouse)
|
||||
|
||||
if from_voucher_detail_no:
|
||||
query = query.where(sre.from_voucher_detail_no == from_voucher_detail_no)
|
||||
|
||||
reserved_qty = query.run(as_list=True)
|
||||
|
||||
return flt(reserved_qty[0][0])
|
||||
@@ -880,6 +965,182 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st
|
||||
return False
|
||||
|
||||
|
||||
class StockReservation:
|
||||
def __init__(self, doc, items=None, notify=True):
|
||||
if isinstance(doc, str):
|
||||
doc = parse_json(doc)
|
||||
doc = frappe.get_doc("Work Order", doc.get("name"))
|
||||
|
||||
self.doc = doc
|
||||
self.items = items
|
||||
self.initialize_fields()
|
||||
|
||||
def initialize_fields(self) -> None:
|
||||
self.table_name = "items"
|
||||
self.qty_field = "stock_qty"
|
||||
self.warehouse_field = "warehouse"
|
||||
self.warehouse = None
|
||||
|
||||
if self.doc.doctype == "Work Order":
|
||||
self.table_name = "required_items"
|
||||
self.qty_field = "required_qty"
|
||||
self.warehouse_field = "source_warehouse"
|
||||
if self.doc.skip_transfer and self.doc.from_wip_warehouse:
|
||||
self.warehouse = self.doc.wip_warehouse
|
||||
|
||||
def cancel_stock_reservation_entries(self, names=None) -> None:
|
||||
"""Cancels Stock Reservation Entries for the Voucher."""
|
||||
|
||||
if isinstance(names, str):
|
||||
names = parse_json(names)
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(sre.name)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.voucher_type == self.doc.doctype)
|
||||
& (sre.voucher_no == self.doc.name)
|
||||
)
|
||||
)
|
||||
|
||||
if names:
|
||||
query = query.where(sre.name.isin(names))
|
||||
elif self.warehouse:
|
||||
query = query.where(sre.warehouse == self.warehouse)
|
||||
|
||||
sre_names = query.run(as_dict=True)
|
||||
|
||||
if sre_names:
|
||||
for sre_name in sre_names:
|
||||
sre_doc = frappe.get_doc("Stock Reservation Entry", sre_name.name)
|
||||
sre_doc.cancel()
|
||||
|
||||
if sre_names and names:
|
||||
frappe.msgprint(
|
||||
_("Stock has been unreserved for work order {0}.").format(frappe.bold(self.doc.name)),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
def make_stock_reservation_entries(self):
|
||||
items = self.items
|
||||
if not items:
|
||||
items = self.doc.get(self.table_name)
|
||||
|
||||
child_doctype = frappe.scrub(self.doc.doctype + " Item")
|
||||
|
||||
for item in items:
|
||||
sre = frappe.new_doc("Stock Reservation Entry")
|
||||
if isinstance(item, dict):
|
||||
item = frappe._dict(item)
|
||||
|
||||
item_details = frappe.get_cached_value(
|
||||
"Item", item.item_code, ["has_serial_no", "has_batch_no", "stock_uom"], as_dict=True
|
||||
)
|
||||
|
||||
warehouse = self.warehouse or item.get(self.warehouse_field) or item.get("warehouse")
|
||||
if (
|
||||
not warehouse
|
||||
and self.doc.doctype == "Work Order"
|
||||
and (not self.doc.skip_transfer or not self.doc.from_wip_warehouse)
|
||||
):
|
||||
frappe.throw(
|
||||
_("Source Warehouse is mandatory for the Item {0}.").format(frappe.bold(item.item_code))
|
||||
)
|
||||
|
||||
qty = item.get(self.qty_field) or item.get("stock_qty")
|
||||
|
||||
self.available_qty_to_reserve = self.get_available_qty_to_reserve(item.item_code, warehouse)
|
||||
if not self.available_qty_to_reserve:
|
||||
self.throw_stock_not_exists_error(item, warehouse)
|
||||
|
||||
self.qty_to_be_reserved = (
|
||||
qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve
|
||||
)
|
||||
|
||||
if not self.qty_to_be_reserved:
|
||||
continue
|
||||
|
||||
sre.item_code = item.item_code
|
||||
sre.warehouse = warehouse
|
||||
sre.has_serial_no = item_details.has_serial_no
|
||||
sre.has_batch_no = item_details.has_batch_no
|
||||
sre.voucher_type = item.get("voucher_type") or self.doc.doctype
|
||||
sre.voucher_no = item.get("voucher_no") or self.doc.name
|
||||
sre.voucher_detail_no = item.get(child_doctype) or item.name or item.voucher_detail_no
|
||||
sre.available_qty = self.available_qty_to_reserve
|
||||
sre.voucher_qty = qty
|
||||
sre.reserved_qty = self.qty_to_be_reserved
|
||||
sre.company = self.doc.company
|
||||
sre.stock_uom = item_details.stock_uom
|
||||
sre.project = self.doc.project
|
||||
sre.from_voucher_no = item.get("from_voucher_no")
|
||||
sre.from_voucher_detail_no = item.get("from_voucher_detail_no")
|
||||
sre.from_voucher_type = item.get("from_voucher_type")
|
||||
sre.save()
|
||||
|
||||
if item.get("serial_and_batch_bundles"):
|
||||
sre.reservation_based_on = "Serial and Batch"
|
||||
self.set_serial_batch(sre, item.serial_and_batch_bundles)
|
||||
|
||||
sre.submit()
|
||||
|
||||
def set_serial_batch(self, sre, serial_batch_bundles):
|
||||
bundle_details = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
fields=["serial_no", "batch_no", "qty"],
|
||||
filters={"parent": ("in", serial_batch_bundles)},
|
||||
)
|
||||
|
||||
for detail in bundle_details:
|
||||
sre.append(
|
||||
"sb_entries",
|
||||
{
|
||||
"serial_no": detail.serial_no,
|
||||
"batch_no": detail.batch_no,
|
||||
"qty": abs(detail.qty),
|
||||
"warehouse": sre.warehouse,
|
||||
},
|
||||
)
|
||||
|
||||
def throw_stock_not_exists_error(self, item, warehouse):
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format(
|
||||
item.idx, frappe.bold(item.item_code), frappe.bold(warehouse)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
def get_available_qty_to_reserve(self, item_code, warehouse, ignore_sre=None):
|
||||
available_qty = get_stock_balance(item_code, warehouse)
|
||||
|
||||
if available_qty:
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty))
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == item_code)
|
||||
& (sre.warehouse == warehouse)
|
||||
& (sre.reserved_qty >= sre.delivered_qty)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
)
|
||||
)
|
||||
|
||||
if ignore_sre:
|
||||
query = query.where(sre.name != ignore_sre)
|
||||
|
||||
reserved_qty = query.run()[0][0] or 0.0
|
||||
|
||||
if reserved_qty:
|
||||
return available_qty - reserved_qty
|
||||
|
||||
return available_qty
|
||||
|
||||
|
||||
def create_stock_reservation_entries_for_so_items(
|
||||
sales_order: object,
|
||||
items_details: list[dict] | None = None,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.listview_settings["Stock Reservation Entry"] = {
|
||||
filters: [["status", "!=", "Cancelled"]],
|
||||
get_indicator: function (doc) {
|
||||
const status_colors = {
|
||||
Draft: "red",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"action_if_quality_inspection_is_rejected",
|
||||
"stock_reservation_tab",
|
||||
"enable_stock_reservation",
|
||||
"auto_reserve_stock",
|
||||
"column_break_rx3e",
|
||||
"allow_partial_reservation",
|
||||
"auto_reserve_stock_for_sales_order_on_purchase",
|
||||
@@ -467,6 +468,13 @@
|
||||
"fieldname": "allow_existing_serial_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow existing Serial No to be Manufactured/Received again"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Upon submission of the Sales Order, Work Order, or Production Plan, the system will automatically reserve the stock.",
|
||||
"fieldname": "auto_reserve_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Reserve Stock"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -474,7 +482,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-09 17:52:36.030456",
|
||||
"modified": "2024-12-10 17:52:36.030456",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
||||
@@ -37,6 +37,7 @@ class StockSettings(Document):
|
||||
auto_indent: DF.Check
|
||||
auto_insert_price_list_rate_if_missing: DF.Check
|
||||
auto_reserve_serial_and_batch: DF.Check
|
||||
auto_reserve_stock: DF.Check
|
||||
auto_reserve_stock_for_sales_order_on_purchase: DF.Check
|
||||
clean_description_html: DF.Check
|
||||
default_warehouse: DF.Link | None
|
||||
@@ -166,6 +167,9 @@ class StockSettings(Document):
|
||||
def validate_stock_reservation(self):
|
||||
"""Raises an exception if the user tries to enable/disable `Stock Reservation` with `Negative Stock` or `Open Stock Reservation Entries`."""
|
||||
|
||||
if not self.enable_stock_reservation and self.auto_reserve_stock:
|
||||
self.auto_reserve_stock = 0
|
||||
|
||||
# Skip validation for tests
|
||||
if frappe.flags.in_test:
|
||||
return
|
||||
|
||||
@@ -68,7 +68,7 @@ frappe.query_reports["Reserved Stock"] = {
|
||||
default: "Sales Order",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
name: ["in", ["Sales Order"]],
|
||||
name: ["in", ["Sales Order", "Work Order", "Production Plan"]],
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1512,7 +1512,7 @@ class update_entries_after:
|
||||
msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
|
||||
)
|
||||
else:
|
||||
msg = f"{msg} As the full stock is reserved for other sales orders, you're not allowed to consume the stock."
|
||||
msg = f"{msg} As the full stock is reserved for other transactions, you're not allowed to consume the stock."
|
||||
|
||||
msg_list.append(msg)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user