mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-25 14:27:05 +00:00
Compare commits
13 Commits
assets-ver
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a22514c93 | ||
|
|
8e378d923d | ||
|
|
213284cfa2 | ||
|
|
c8511e01eb | ||
|
|
fd8e6230f3 | ||
|
|
8cdff3bfba | ||
|
|
48720781ce | ||
|
|
a710111db9 | ||
|
|
1d1562a68e | ||
|
|
12d342cc62 | ||
|
|
75efa2d1cf | ||
|
|
f6bd6733c8 | ||
|
|
dbada3f461 |
@@ -7,6 +7,7 @@ frappe.ui.form.on("Bank Statement Import", {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_company_account: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ class BankStatementImport(DataImport):
|
||||
self.template_warnings = ""
|
||||
|
||||
if self.import_file and not self.import_file.lower().endswith(".txt"):
|
||||
self.validate_import_file()
|
||||
self.get_importer()
|
||||
self.validate_google_sheets_url()
|
||||
|
||||
def start_import(self):
|
||||
|
||||
@@ -440,7 +440,15 @@ def get_invoices(filters, additional_query_columns):
|
||||
if filters.get("supplier"):
|
||||
query = query.where(pi.supplier == filters.supplier)
|
||||
if filters.get("supplier_group"):
|
||||
query = query.where(pi.supplier_group == filters.supplier_group)
|
||||
# read the group from the supplier master, to match the Supplier Group column
|
||||
supplier = frappe.qb.DocType("Supplier")
|
||||
query = query.where(
|
||||
pi.supplier.isin(
|
||||
frappe.qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.supplier_group == filters.supplier_group)
|
||||
)
|
||||
)
|
||||
|
||||
query = get_conditions(filters, query, "Purchase Invoice")
|
||||
|
||||
|
||||
@@ -175,6 +175,23 @@ class TestPurchaseRegister(ERPNextTestSuite):
|
||||
self.assertEqual(first_row.credit, 600)
|
||||
self.assertEqual(first_row.balance, 500)
|
||||
|
||||
def test_supplier_group_filter_uses_supplier_master(self):
|
||||
# invoices created before the supplier_group field existed have it blank
|
||||
pi = make_purchase_invoice()
|
||||
pi.db_set("supplier_group", None, update_modified=False)
|
||||
supplier_group = frappe.db.get_value("Supplier", pi.supplier, "supplier_group")
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company 6",
|
||||
from_date=add_months(today(), -1),
|
||||
to_date=today(),
|
||||
supplier_group=supplier_group,
|
||||
)
|
||||
rows = [frappe._dict(row) for row in execute(filters)[1] if row.get("voucher_no") == pi.name]
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].supplier_group, supplier_group)
|
||||
|
||||
|
||||
def make_purchase_invoice():
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "subject",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"in_list_view": 1,
|
||||
"label": "Subject",
|
||||
"print_width": "518",
|
||||
@@ -76,7 +76,7 @@
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:32.933603",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Activity",
|
||||
|
||||
@@ -17,7 +17,7 @@ class AssetActivity(Document):
|
||||
|
||||
asset: DF.Link
|
||||
date: DF.Datetime
|
||||
subject: DF.SmallText
|
||||
subject: DF.TextEditor
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "notes",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Notes",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -217,7 +217,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-23 01:17:16.708004",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Depreciation Schedule",
|
||||
|
||||
@@ -40,7 +40,7 @@ class AssetDepreciationSchedule(DepreciationScheduleController):
|
||||
frequency_of_depreciation: DF.Int
|
||||
naming_series: DF.Literal["ACC-ADS-.YYYY.-"]
|
||||
net_purchase_amount: DF.Currency
|
||||
notes: DF.SmallText | None
|
||||
notes: DF.TextEditor | None
|
||||
opening_accumulated_depreciation: DF.Currency
|
||||
opening_number_of_booked_depreciations: DF.Int
|
||||
rate_of_depreciation: DF.Percent
|
||||
|
||||
@@ -1490,6 +1490,7 @@ def _build_base_bom_items_query(bom, company, qty, t):
|
||||
Min(t.bom_item.idx).as_("idx"),
|
||||
Max(t.item_doc.item_name).as_("item_name"),
|
||||
(Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
|
||||
(Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * qty).as_("stock_qty"),
|
||||
Max(t.item_doc.image).as_("image"),
|
||||
Max(t.bom_doc.project).as_("project"),
|
||||
Max(t.item_doc.stock_uom).as_("stock_uom"),
|
||||
@@ -1607,19 +1608,20 @@ def _add_bom_item_to_dict(item_dict, item, company, opts):
|
||||
if item.operation:
|
||||
key = (item.item_code, item.operation)
|
||||
|
||||
stock_qty = item.pop("stock_qty")
|
||||
if item.get("is_phantom_item"):
|
||||
_merge_phantom_bom_items(item_dict, item, company, opts)
|
||||
_merge_phantom_bom_items(item_dict, item, stock_qty, company, opts)
|
||||
elif key in item_dict:
|
||||
item_dict[key]["qty"] += flt(item.qty)
|
||||
else:
|
||||
item_dict[key] = item
|
||||
|
||||
|
||||
def _merge_phantom_bom_items(item_dict, item, company, opts):
|
||||
def _merge_phantom_bom_items(item_dict, item, stock_qty, company, opts):
|
||||
data = get_bom_items_as_dict(
|
||||
item.get("bom_no"),
|
||||
company,
|
||||
qty=item.get("qty"),
|
||||
qty=stock_qty,
|
||||
fetch_exploded=opts.fetch_exploded,
|
||||
fetch_secondary_items=opts.fetch_secondary_items,
|
||||
include_non_stock_items=opts.include_non_stock_items,
|
||||
|
||||
@@ -141,6 +141,36 @@ class TestBOM(ERPNextTestSuite):
|
||||
self.assertEqual(flt(items_dict[component].qty), 1.0)
|
||||
self.assertNotIn(rm_normal, items_dict)
|
||||
|
||||
@timeout
|
||||
def test_get_items_explodes_phantom_row_by_stock_qty(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
kit = make_item(
|
||||
properties={"is_stock_item": 0, "uoms": [{"uom": "Box", "conversion_factor": 5}]}
|
||||
).name
|
||||
phantom_bom = make_bom(item=kit, raw_materials=[rm], do_not_save=True)
|
||||
phantom_bom.is_phantom_bom = 1
|
||||
phantom_bom.save()
|
||||
phantom_bom.submit()
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
bom = make_bom(item=fg_item, raw_materials=[kit], do_not_save=True)
|
||||
bom.items[0].update({"qty": 2, "uom": "Box", "bom_no": phantom_bom.name})
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
for fetch_qty_in_stock_uom in (True, False):
|
||||
items_dict = get_bom_items_as_dict(
|
||||
bom.name,
|
||||
"_Test Company",
|
||||
qty=1,
|
||||
fetch_exploded=0,
|
||||
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
|
||||
)
|
||||
self.assertEqual(flt(items_dict[rm].qty), 10.0)
|
||||
|
||||
@timeout
|
||||
def test_get_items_amount_uses_each_lines_own_rate(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
|
||||
@@ -1789,6 +1789,100 @@ class TestJobCard(ERPNextTestSuite):
|
||||
8,
|
||||
)
|
||||
|
||||
def test_semi_fg_secondary_items_across_split_job_cards(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import make_job_card
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Split JC Scrap RM", {"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
fg = make_item("Split JC Scrap FG", {"is_stock_item": 1}).name
|
||||
scrap = make_item("Split JC Scrap", {"is_stock_item": 1, "valuation_rate": 5}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
fg_bom.append("secondary_items", {"item_code": scrap, "qty": 1, "secondary_item_type": "Scrap"})
|
||||
|
||||
operation = {
|
||||
"operation": "Split JC Scrap Op",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=10,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
|
||||
)
|
||||
job_card.for_quantity = 5
|
||||
job_card.secondary_items[0].stock_qty = 5
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
make_job_card(
|
||||
work_order.name,
|
||||
[
|
||||
{
|
||||
"name": work_order.operations[0].name,
|
||||
"operation": "Split JC Scrap Op",
|
||||
"qty": 5,
|
||||
"pending_qty": 5,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
|
||||
)
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-02 08:00:00", "to_time": "2024-02-02 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
stock_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
|
||||
scrap_qty = sum(row.qty for row in stock_entry.items if row.item_code == scrap)
|
||||
self.assertEqual(scrap_qty, 5)
|
||||
|
||||
def test_batch_split_operation_creates_child_batches(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -116,7 +116,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-23 16:52:37.669110",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card Item",
|
||||
|
||||
@@ -16,7 +16,7 @@ class JobCardItem(Document):
|
||||
|
||||
allow_alternative_item: DF.Check
|
||||
consumed_qty: DF.Float
|
||||
description: DF.Text | None
|
||||
description: DF.TextEditor | None
|
||||
item_code: DF.Link
|
||||
item_group: DF.Link | None
|
||||
item_name: DF.Data | None
|
||||
|
||||
@@ -32,7 +32,6 @@ from erpnext.manufacturing.doctype.production_plan.services.material_request imp
|
||||
)
|
||||
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
|
||||
cancel_stock_reservation_entries,
|
||||
get_non_completed_production_plans,
|
||||
get_reserved_qty_for_production_plan,
|
||||
get_reserved_qty_for_sub_assembly,
|
||||
make_stock_reservation_entries,
|
||||
@@ -267,8 +266,14 @@ class ProductionPlan(Document):
|
||||
data.db_update()
|
||||
|
||||
self.calculate_total_produced_qty()
|
||||
self.update_status_and_bin_qty()
|
||||
|
||||
def update_status_and_bin_qty(self):
|
||||
previous_status = self.status
|
||||
self.set_status()
|
||||
self.db_set("status", self.status)
|
||||
if previous_status != self.status and "Completed" in (previous_status, self.status):
|
||||
self.update_bin_qty()
|
||||
|
||||
def on_submit(self):
|
||||
self.update_bin_qty()
|
||||
@@ -362,11 +367,7 @@ class ProductionPlan(Document):
|
||||
return so_wise_planned_qty
|
||||
|
||||
def update_bin_qty(self):
|
||||
for d in self.mr_items:
|
||||
if d.warehouse:
|
||||
bin_name = get_or_make_bin(d.item_code, d.warehouse)
|
||||
bin = frappe.get_doc("Bin", bin_name, for_update=True)
|
||||
bin.update_reserved_qty_for_production_plan()
|
||||
self.update_raw_material_bin_qty()
|
||||
|
||||
for d in self.sub_assembly_items:
|
||||
if d.fg_warehouse and d.type_of_manufacturing == "In House":
|
||||
@@ -374,6 +375,13 @@ class ProductionPlan(Document):
|
||||
bin = frappe.get_doc("Bin", bin_name, for_update=True)
|
||||
bin.update_reserved_qty_for_for_sub_assembly()
|
||||
|
||||
def update_raw_material_bin_qty(self, item_codes: set[str] | None = None):
|
||||
for d in self.mr_items:
|
||||
if d.warehouse and (item_codes is None or d.item_code in item_codes):
|
||||
bin_name = get_or_make_bin(d.item_code, d.warehouse)
|
||||
bin = frappe.get_doc("Bin", bin_name, for_update=True)
|
||||
bin.update_reserved_qty_for_production_plan()
|
||||
|
||||
def delete_draft_work_order(self):
|
||||
for d in frappe.get_all(
|
||||
"Work Order", fields=["name"], filters={"docstatus": 0, "production_plan": ("=", self.name)}
|
||||
@@ -384,6 +392,9 @@ class ProductionPlan(Document):
|
||||
def set_status(self, close: bool | None = None, update_bin: bool = False):
|
||||
self.check_permission("write")
|
||||
|
||||
if close is None and self.status == "Closed":
|
||||
return
|
||||
|
||||
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
|
||||
|
||||
if close:
|
||||
|
||||
@@ -27,58 +27,85 @@ _RESERVATION_TABLES = {
|
||||
|
||||
|
||||
def get_reserved_qty_for_production_plan(item_code, warehouse):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
|
||||
|
||||
non_completed_production_plans = get_non_completed_production_plans()
|
||||
reserved = _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans)
|
||||
if reserved is None:
|
||||
plan_reservations = _get_plan_reservations(item_code)
|
||||
if not plan_reservations:
|
||||
return None
|
||||
|
||||
for_production = flt(
|
||||
get_reserved_qty_for_production(
|
||||
item_code, warehouse, non_completed_production_plans, check_production_plan=True
|
||||
work_order_reservations = _get_work_order_reservations(item_code, list(plan_reservations))
|
||||
reserved_qty = 0.0
|
||||
for plan, plan_qty_by_warehouse in plan_reservations.items():
|
||||
reserved_qty += _get_remaining_reserved_qty(
|
||||
plan_qty_by_warehouse, work_order_reservations.get(plan, {}), warehouse
|
||||
)
|
||||
)
|
||||
if for_production > reserved:
|
||||
|
||||
return reserved_qty
|
||||
|
||||
|
||||
def _get_remaining_reserved_qty(plan_qty_by_warehouse, work_order_qty_by_warehouse, warehouse):
|
||||
remaining_qty_by_warehouse = {
|
||||
plan_warehouse: max(qty - work_order_qty_by_warehouse.get(plan_warehouse, 0.0), 0.0)
|
||||
for plan_warehouse, qty in plan_qty_by_warehouse.items()
|
||||
}
|
||||
total_remaining_qty = sum(remaining_qty_by_warehouse.values())
|
||||
if not total_remaining_qty:
|
||||
return 0.0
|
||||
return reserved - for_production
|
||||
|
||||
matched_qty = sum(plan_qty_by_warehouse.values()) - total_remaining_qty
|
||||
unmatched_qty = min(sum(work_order_qty_by_warehouse.values()) - matched_qty, total_remaining_qty)
|
||||
remaining_qty = remaining_qty_by_warehouse.get(warehouse, 0.0)
|
||||
return remaining_qty - remaining_qty * unmatched_qty / total_remaining_qty
|
||||
|
||||
|
||||
def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans):
|
||||
def _get_plan_reservations(item_code):
|
||||
table = frappe.qb.DocType("Production Plan")
|
||||
child = frappe.qb.DocType("Material Request Plan Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.inner_join(child)
|
||||
.on(table.name == child.parent)
|
||||
.select(Sum(child.required_bom_qty))
|
||||
.where(_plan_reserved_filter(table, child, item_code, warehouse))
|
||||
.select(
|
||||
table.name.as_("production_plan"),
|
||||
child.warehouse,
|
||||
Sum(child.required_bom_qty).as_("reserved_qty"),
|
||||
)
|
||||
.where(
|
||||
(table.docstatus == 1)
|
||||
& (child.item_code == item_code)
|
||||
& (table.status.notin(["Completed", "Closed"]))
|
||||
)
|
||||
.groupby(table.name, child.warehouse)
|
||||
)
|
||||
if non_completed_production_plans:
|
||||
query = query.where(table.name.isin(non_completed_production_plans))
|
||||
|
||||
result = query.run()
|
||||
return flt(result[0][0]) if result and result[0][0] is not None else None
|
||||
return _group_by_plan_and_warehouse(query)
|
||||
|
||||
|
||||
def _plan_reserved_filter(table, child, item_code, warehouse):
|
||||
return (
|
||||
(table.docstatus == 1)
|
||||
& (child.item_code == item_code)
|
||||
& (child.warehouse == warehouse)
|
||||
& (table.status.notin(["Completed", "Closed"]))
|
||||
def _get_work_order_reservations(item_code, plan_names):
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
work_order_item = frappe.qb.DocType("Work Order Item")
|
||||
query = (
|
||||
frappe.qb.from_(work_order)
|
||||
.from_(work_order_item)
|
||||
.select(
|
||||
work_order.production_plan,
|
||||
work_order_item.source_warehouse.as_("warehouse"),
|
||||
Sum(work_order_item.required_qty).as_("reserved_qty"),
|
||||
)
|
||||
.where(
|
||||
(work_order_item.item_code == item_code)
|
||||
& (work_order_item.parent == work_order.name)
|
||||
& (work_order.docstatus == 1)
|
||||
& (IfNull(work_order_item.source_warehouse, "") != "")
|
||||
& work_order.production_plan.isin(plan_names)
|
||||
)
|
||||
.groupby(work_order.production_plan, work_order_item.source_warehouse)
|
||||
)
|
||||
return _group_by_plan_and_warehouse(query)
|
||||
|
||||
|
||||
def get_non_completed_production_plans():
|
||||
table = frappe.qb.DocType("Production Plan")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.name)
|
||||
.distinct()
|
||||
.where((table.docstatus == 1) & (table.status.notin(["Completed", "Closed"])))
|
||||
).run(pluck="name")
|
||||
def _group_by_plan_and_warehouse(query):
|
||||
reservations = {}
|
||||
for row in query.run(as_dict=True):
|
||||
reservations.setdefault(row.production_plan, {})[row.warehouse] = flt(row.reserved_qty)
|
||||
return reservations
|
||||
|
||||
|
||||
def get_reserved_qty_for_sub_assembly(item_code, warehouse):
|
||||
|
||||
@@ -6,7 +6,6 @@ from frappe.utils import add_to_date, flt, getdate, now_datetime, nowdate
|
||||
from erpnext.controllers.item_variant import create_variant
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_items_for_material_requests,
|
||||
get_non_completed_production_plans,
|
||||
get_sales_orders,
|
||||
get_warehouse_list,
|
||||
)
|
||||
@@ -1597,6 +1596,183 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(after_qty, before_qty)
|
||||
|
||||
def test_plan_reservation_offsets_work_order_in_another_warehouse(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
|
||||
get_reserved_qty_for_production_plan,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
plan_warehouse = "_Test Warehouse - _TC"
|
||||
work_order_warehouse = "_Test Warehouse 1 - _TC"
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, do_not_submit=1
|
||||
)
|
||||
plan.submit()
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
|
||||
bin_name = frappe.db.get_value("Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "name")
|
||||
bin = frappe.get_doc("Bin", bin_name)
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
|
||||
projected_qty = bin.projected_qty
|
||||
|
||||
work_order = submit_work_order_from_plan(plan, 5, work_order_warehouse)
|
||||
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 5)
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
self.assertEqual(bin.projected_qty, projected_qty + 5)
|
||||
|
||||
work_order.cancel()
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
|
||||
self.assertEqual(bin.projected_qty, projected_qty)
|
||||
|
||||
def test_plan_reservation_ignores_work_orders_of_other_plans(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
|
||||
get_reserved_qty_for_production_plan,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
first_warehouse = "_Test Warehouse - _TC"
|
||||
second_warehouse = "_Test Warehouse 1 - _TC"
|
||||
plans = []
|
||||
for warehouse in (first_warehouse, second_warehouse):
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
plans.append(
|
||||
create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
)
|
||||
|
||||
submit_work_order_from_plan(plans[1], 10, first_warehouse)
|
||||
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, first_warehouse), 10)
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, second_warehouse), 0)
|
||||
|
||||
def test_plan_reservation_kept_for_work_order_without_source_warehouse(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
|
||||
get_reserved_qty_for_production_plan,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
plan_warehouse = "_Test Warehouse - _TC"
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
|
||||
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
|
||||
submit_work_order_from_plan(plan, 5, None)
|
||||
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "reserved_qty_for_production_plan"
|
||||
),
|
||||
10,
|
||||
)
|
||||
|
||||
def test_plan_reservation_released_when_plan_completes(self):
|
||||
plan, work_order = make_plan_with_sub_assembly()
|
||||
work_order.submit()
|
||||
make_stock_entry(
|
||||
item_code=plan.sub_assembly_items[0].production_item,
|
||||
qty=5,
|
||||
rate=10,
|
||||
target=work_order.source_warehouse,
|
||||
)
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
|
||||
raw_material = plan.mr_items[0]
|
||||
bin = frappe.get_doc(
|
||||
"Bin", {"item_code": raw_material.item_code, "warehouse": raw_material.warehouse}
|
||||
)
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
|
||||
manufacture = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5))
|
||||
manufacture.submit()
|
||||
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
|
||||
|
||||
manufacture.cancel()
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
|
||||
def test_plan_reservation_released_when_last_work_order_is_closed(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import close_work_order
|
||||
|
||||
plan, work_order = make_plan_with_sub_assembly()
|
||||
work_order.submit()
|
||||
plan.make_work_order()
|
||||
sub_assembly = plan.sub_assembly_items[0]
|
||||
sub_assembly_work_order = frappe.get_doc(
|
||||
"Work Order", {"production_plan": plan.name, "production_item": sub_assembly.production_item}
|
||||
)
|
||||
sub_assembly_work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
|
||||
sub_assembly_work_order.submit()
|
||||
|
||||
make_stock_entry(
|
||||
item_code=sub_assembly.production_item, qty=5, rate=10, target=work_order.source_warehouse
|
||||
)
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
|
||||
bin = frappe.get_doc(
|
||||
"Bin", {"item_code": sub_assembly.production_item, "warehouse": sub_assembly.fg_warehouse}
|
||||
)
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
|
||||
close_work_order(sub_assembly_work_order.name, "Closed")
|
||||
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
|
||||
|
||||
def test_closed_plan_stays_closed_on_production(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
work_order = submit_work_order_from_plan(plan, 5, warehouse)
|
||||
plan.set_status(close=True)
|
||||
|
||||
make_stock_entry(item_code=rm_item, qty=5, rate=10, target=warehouse)
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Closed")
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": rm_item, "warehouse": warehouse}, "reserved_qty_for_production_plan"
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_plan_reservation_offsets_are_distributed_across_warehouses(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
|
||||
_get_remaining_reserved_qty,
|
||||
)
|
||||
|
||||
reservations = {"Warehouse A": 6, "Warehouse B": 4}
|
||||
cases = [
|
||||
({"Warehouse A": 5}, 1, 4),
|
||||
({"Warehouse C": 5}, 3, 2),
|
||||
({"Warehouse A": 8}, 0, 2),
|
||||
({"Warehouse C": 20}, 0, 0),
|
||||
]
|
||||
for work_order_reservations, warehouse_a_qty, warehouse_b_qty in cases:
|
||||
with self.subTest(work_order_reservations=work_order_reservations):
|
||||
self.assertEqual(
|
||||
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse A"),
|
||||
warehouse_a_qty,
|
||||
)
|
||||
self.assertEqual(
|
||||
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse B"),
|
||||
warehouse_b_qty,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_get_remaining_reserved_qty({"Warehouse A": 5}, {"Warehouse B": 4}, "Warehouse A"), 1
|
||||
)
|
||||
|
||||
def test_reserved_qty_for_production_plan_for_less_rm_qty(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
@@ -1612,7 +1788,6 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
|
||||
pln.make_work_order()
|
||||
|
||||
plans = []
|
||||
for row in frappe.get_all("Work Order", filters={"production_plan": pln.name}, fields=["name"]):
|
||||
wo_doc = frappe.get_doc("Work Order", row.name)
|
||||
wo_doc.source_warehouse = "_Test Warehouse - _TC"
|
||||
@@ -1629,16 +1804,12 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
wo_doc.submit()
|
||||
plans.append(pln.name)
|
||||
|
||||
bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC")
|
||||
after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
|
||||
|
||||
self.assertEqual(after_qty, before_qty)
|
||||
non_completed_plans = get_non_completed_production_plans()
|
||||
|
||||
for plan in plans:
|
||||
self.assertIn(plan, non_completed_plans)
|
||||
self.assertNotIn(frappe.db.get_value("Production Plan", pln.name, "status"), ["Completed", "Closed"])
|
||||
|
||||
def test_reserved_qty_for_production_plan_for_material_requests_with_multi_UOM(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
@@ -4047,6 +4218,51 @@ def create_production_plan(**args):
|
||||
return pln
|
||||
|
||||
|
||||
def submit_work_order_from_plan(plan, qty, source_warehouse):
|
||||
production_item = next(iter(plan.get_production_items().values()))
|
||||
production_item["qty"] = qty
|
||||
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
|
||||
work_order.source_warehouse = source_warehouse
|
||||
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
|
||||
work_order.fg_warehouse = "_Test Warehouse - _TC"
|
||||
for item in work_order.required_items:
|
||||
item.source_warehouse = source_warehouse
|
||||
work_order.submit()
|
||||
return work_order
|
||||
|
||||
|
||||
def make_plan_with_sub_assembly():
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
sub_assembly_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
make_bom(item=sub_assembly_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
make_bom(item=fg_item, raw_materials=[sub_assembly_item], source_warehouse=warehouse)
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=fg_item,
|
||||
planned_qty=5,
|
||||
ignore_existing_ordered_qty=1,
|
||||
sub_assembly_warehouse="_Test Warehouse 1 - _TC",
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
)
|
||||
plan.get_sub_assembly_items()
|
||||
for row in get_items_for_material_requests(plan.as_dict()):
|
||||
plan.append("mr_items", row)
|
||||
plan.submit()
|
||||
|
||||
production_item = next(iter(plan.get_production_items().values()))
|
||||
production_item["use_multi_level_bom"] = 0
|
||||
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
|
||||
work_order.source_warehouse = warehouse
|
||||
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
|
||||
work_order.fg_warehouse = warehouse
|
||||
for item in work_order.required_items:
|
||||
item.source_warehouse = warehouse
|
||||
return plan, work_order
|
||||
|
||||
|
||||
def make_bom(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -278,7 +278,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 00:00:00.000000",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Sub Assembly Item",
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProductionPlanSubAssemblyItem(Document):
|
||||
actual_qty: DF.Float
|
||||
bom_level: DF.Int
|
||||
bom_no: DF.Link | None
|
||||
description: DF.SmallText | None
|
||||
description: DF.TextEditor | None
|
||||
fg_warehouse: DF.Link | None
|
||||
indent: DF.Int
|
||||
item_name: DF.Data | None
|
||||
|
||||
@@ -49,6 +49,7 @@ _SERIAL_BATCH_FIELDS = [
|
||||
"`tabSerial and Batch Bundle`.`item_code`",
|
||||
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
|
||||
]
|
||||
CONSUMPTION_PURPOSES = ("Manufacture", "Material Consumption for Manufacture")
|
||||
|
||||
|
||||
class WorkOrderStockReservation:
|
||||
@@ -135,15 +136,17 @@ class WorkOrderStockReservation:
|
||||
|
||||
@staticmethod
|
||||
def _apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch):
|
||||
doc.db_set("transferred_qty", flt(qty_to_update), 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)
|
||||
qty_to_update = doc.matched_serial_batch_qty
|
||||
|
||||
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
|
||||
if doc.transferred_qty >= doc.reserved_qty:
|
||||
doc.db_set("status", "Closed", update_modified=False)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
doc.update_reserved_qty_in_voucher()
|
||||
|
||||
def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
|
||||
filters = {
|
||||
@@ -156,7 +159,7 @@ class WorkOrderStockReservation:
|
||||
if not self.doc.skip_transfer:
|
||||
filters["from_voucher_no"] = ("is", "set")
|
||||
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, "Manufacture")
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, CONSUMPTION_PURPOSES)
|
||||
names = frappe.get_all("Stock Reservation Entry", filters=filters, pluck="name", order_by="creation")
|
||||
for name in names:
|
||||
consumed_qty = self._apply_consumed_qty(name, consumed_qty, row_wise_serial_batch)
|
||||
@@ -172,9 +175,11 @@ class WorkOrderStockReservation:
|
||||
|
||||
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.db_set("consumed_qty", doc.matched_serial_batch_qty, update_modified=False)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
doc.update_reserved_qty_in_voucher()
|
||||
return consumed_qty
|
||||
|
||||
def validate_reserved_qty(self):
|
||||
@@ -663,7 +668,7 @@ def get_consumed_qty(work_order, item_code):
|
||||
def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code):
|
||||
return (
|
||||
(stock_entry.work_order == work_order)
|
||||
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
|
||||
& (stock_entry.purpose.isin(CONSUMPTION_PURPOSES))
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry_detail.s_warehouse.isnotnull())
|
||||
# An attributed row belongs to its original requirement, not both item codes.
|
||||
@@ -671,31 +676,27 @@ def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code)
|
||||
)
|
||||
|
||||
|
||||
def get_reserved_qty_for_production(
|
||||
item_code: str,
|
||||
warehouse: str,
|
||||
non_completed_production_plans: list | None = None,
|
||||
check_production_plan: bool = False,
|
||||
) -> float:
|
||||
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
|
||||
"""Get total reserved quantity for any item in specified warehouse"""
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
wo_item = frappe.qb.DocType("Work Order Item")
|
||||
qty_field = wo_item.required_qty if check_production_plan else _production_reserved_qty_field(wo, wo_item)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(wo)
|
||||
.from_(wo_item)
|
||||
.select(Sum(qty_field))
|
||||
.select(Sum(_production_reserved_qty_field(wo, wo_item)))
|
||||
.where(
|
||||
(wo_item.item_code == item_code)
|
||||
& (wo_item.parent == wo.name)
|
||||
& (wo.docstatus == 1)
|
||||
& (wo_item.source_warehouse == warehouse)
|
||||
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
|
||||
& (
|
||||
(wo_item.required_qty > wo_item.transferred_qty)
|
||||
| (wo_item.required_qty > wo_item.consumed_qty)
|
||||
)
|
||||
)
|
||||
)
|
||||
query = _apply_production_plan_filter(
|
||||
query, wo, wo_item, check_production_plan, non_completed_production_plans
|
||||
)
|
||||
return query.run()[0][0] or 0.0
|
||||
|
||||
|
||||
@@ -708,28 +709,12 @@ def _production_reserved_qty_field(wo, wo_item):
|
||||
return qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
|
||||
|
||||
|
||||
def _apply_production_plan_filter(query, wo, wo_item, check_production_plan, non_completed_production_plans):
|
||||
if check_production_plan:
|
||||
query = query.where(wo.production_plan.isnotnull())
|
||||
else:
|
||||
query = query.where(
|
||||
(wo.status.notin(["Stopped", "Completed", "Closed"]))
|
||||
& (
|
||||
(wo_item.required_qty > wo_item.transferred_qty)
|
||||
| (wo_item.required_qty > wo_item.consumed_qty)
|
||||
)
|
||||
)
|
||||
|
||||
if non_completed_production_plans:
|
||||
query = query.where(wo.production_plan.isin(non_completed_production_plans))
|
||||
return query
|
||||
|
||||
|
||||
def get_row_wise_serial_batch(work_order, purpose=None):
|
||||
purpose = purpose or "Material Transfer for Manufacture"
|
||||
purposes = [purpose] if isinstance(purpose, str) else purpose
|
||||
stock_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={"work_order": work_order, "purpose": purpose, "docstatus": 1},
|
||||
filters={"work_order": work_order, "purpose": ("in", purposes), "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
@@ -423,8 +423,8 @@ class StatusService:
|
||||
|
||||
doc = frappe.get_doc("Production Plan", self.doc.production_plan)
|
||||
doc.flags.ignore_permissions = True
|
||||
doc.set_status()
|
||||
doc.db_set("status", doc.status)
|
||||
doc.update_status_and_bin_qty()
|
||||
doc.update_raw_material_bin_qty({d.item_code for d in self.doc.required_items})
|
||||
|
||||
def _production_plan_ordered_qty(self):
|
||||
table = frappe.qb.DocType("Work Order")
|
||||
|
||||
@@ -4606,6 +4606,99 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual(issue.docstatus, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
|
||||
)
|
||||
def test_transfer_of_other_batch_keeps_reservation_open(self):
|
||||
production_item = "Test Other Batch Release FG"
|
||||
rm_item = "Test Other Batch Release RM"
|
||||
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-OTH-.###",
|
||||
"create_new_batch": 1,
|
||||
},
|
||||
)
|
||||
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
|
||||
|
||||
batches = []
|
||||
for _ in range(2):
|
||||
receipt = test_stock_entry.make_stock_entry(
|
||||
item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100
|
||||
)
|
||||
batches.append(get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle))
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
|
||||
)
|
||||
sre = frappe.get_doc(
|
||||
"Stock Reservation Entry",
|
||||
{"voucher_no": wo.name, "warehouse": source_warehouse, "docstatus": 1},
|
||||
)
|
||||
reserved_batch = sre.sb_entries[0].batch_no
|
||||
other_batch = batches[1] if batches[0] == reserved_batch else batches[0]
|
||||
|
||||
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50))
|
||||
for row in transfer.items:
|
||||
row.update(
|
||||
{"batch_no": other_batch, "use_serial_batch_fields": 1, "serial_and_batch_bundle": None}
|
||||
)
|
||||
transfer.insert()
|
||||
transfer.submit()
|
||||
|
||||
sre.reload()
|
||||
self.assertEqual(sre.status, "Reserved")
|
||||
self.assertEqual(sre.transferred_qty, 0)
|
||||
self.assertEqual([(row.batch_no, row.delivered_qty) for row in sre.sb_entries], [(reserved_batch, 0)])
|
||||
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 50)).submit()
|
||||
wo.reload()
|
||||
self.assertEqual(wo.required_items[0].stock_reserved_qty, 50)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
|
||||
)
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
|
||||
def test_material_consumption_uses_batch_reservation(self):
|
||||
production_item = "Test Consumption Reservation FG"
|
||||
rm_item = "Test Consumption Reservation RM"
|
||||
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-MCM-.###",
|
||||
"create_new_batch": 1,
|
||||
},
|
||||
)
|
||||
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
|
||||
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
|
||||
)
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50)).submit()
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Material Consumption for Manufacture", 50)).submit()
|
||||
|
||||
wip_reservation = frappe.db.get_value(
|
||||
"Stock Reservation Entry",
|
||||
{"voucher_no": wo.name, "warehouse": wo.wip_warehouse, "docstatus": 1},
|
||||
["consumed_qty", "status"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(wip_reservation.consumed_qty, 50)
|
||||
self.assertEqual(wip_reservation.status, "Delivered")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "allow_negative_stock": 0},
|
||||
|
||||
@@ -904,8 +904,6 @@ 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;
|
||||
@@ -1055,6 +1053,8 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
erpnext.work_order.setup_stock_reservation(frm);
|
||||
},
|
||||
|
||||
add_start_button(frm) {
|
||||
@@ -1071,6 +1071,7 @@ erpnext.work_order = {
|
||||
setup_stock_reservation(frm) {
|
||||
if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
|
||||
if (
|
||||
!["Closed", "Completed"].includes(frm.doc.status) &&
|
||||
frm.events.has_unreserved_stock(frm) &&
|
||||
(frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty)
|
||||
) {
|
||||
@@ -1082,13 +1083,11 @@ erpnext.work_order = {
|
||||
}
|
||||
|
||||
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(
|
||||
__("Unreserve"),
|
||||
() => erpnext.stock_reservation.unreserve_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Reserved Stock"),
|
||||
|
||||
@@ -422,7 +422,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Item Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -766,7 +766,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-18 18:03:37.509869",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -121,7 +121,7 @@ class WorkOrder(Document):
|
||||
bom_no: DF.Link
|
||||
company: DF.Link
|
||||
corrective_operation_cost: DF.Currency
|
||||
description: DF.SmallText | None
|
||||
description: DF.TextEditor | None
|
||||
disassembled_qty: DF.Float
|
||||
expected_delivery_date: DF.Date | None
|
||||
fg_warehouse: DF.Link | None
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -227,7 +227,7 @@
|
||||
"grid_page_length": 50,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 10:00:00.000000",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -20,7 +20,7 @@ class WorkOrderItem(Document):
|
||||
available_qty_at_source_warehouse: DF.Float
|
||||
available_qty_at_wip_warehouse: DF.Float
|
||||
consumed_qty: DF.Float
|
||||
description: DF.Text | None
|
||||
description: DF.TextEditor | None
|
||||
include_item_in_manufacturing: DF.Check
|
||||
is_additional_item: DF.Check
|
||||
is_customer_provided_item: DF.Check
|
||||
|
||||
@@ -529,3 +529,4 @@ erpnext.patches.v16_0.set_blanket_order_status
|
||||
erpnext.patches.v16_0.set_blanket_order_item_stock_uom
|
||||
erpnext.patches.v16_0.recalculate_subcontracting_order_service_cost
|
||||
erpnext.patches.v16_0.move_sub_assembly_rate_setting_to_bom_item
|
||||
erpnext.patches.v16_0.convert_plain_text_line_breaks_to_html
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import frappe
|
||||
from frappe.utils import escape_html
|
||||
|
||||
FIELDS_NOW_TEXT_EDITOR = {
|
||||
"Delivery Stop": "customer_address",
|
||||
"Project User": "project_status",
|
||||
}
|
||||
|
||||
|
||||
def execute():
|
||||
for doctype, fieldname in FIELDS_NOW_TEXT_EDITOR.items():
|
||||
rows = frappe.get_all(
|
||||
doctype,
|
||||
filters=[[fieldname, "like", "%\n%"], [fieldname, "not like", "%<%"]],
|
||||
fields=["name", fieldname],
|
||||
)
|
||||
for row in rows:
|
||||
html = escape_html(row[fieldname]).replace("\n", "<br>")
|
||||
frappe.db.set_value(doctype, row.name, fieldname, html, update_modified=False)
|
||||
@@ -79,13 +79,13 @@
|
||||
{
|
||||
"depends_on": "eval:parent.doctype == 'Project Update'",
|
||||
"fieldname": "project_status",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Project Status"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:21.859474",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Project User",
|
||||
|
||||
@@ -20,7 +20,7 @@ class ProjectUser(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
project_status: DF.Text | None
|
||||
project_status: DF.TextEditor | None
|
||||
user: DF.Link
|
||||
view_attachments: DF.Check
|
||||
welcome_email_sent: DF.Check
|
||||
|
||||
@@ -216,8 +216,11 @@ $.extend(erpnext.stock_reservation, {
|
||||
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);
|
||||
const sre_entries = r.message.filter(
|
||||
(sre) => erpnext.stock_reservation.get_held_qty(sre) > 0
|
||||
);
|
||||
if (sre_entries.length > 0) {
|
||||
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, sre_entries);
|
||||
} else {
|
||||
frappe.msgprint(__("No reserved stock to unreserve."));
|
||||
}
|
||||
@@ -253,7 +256,7 @@ $.extend(erpnext.stock_reservation, {
|
||||
sre: sre.name,
|
||||
item_code: sre.item_code,
|
||||
warehouse: sre.warehouse,
|
||||
qty: flt(sre.reserved_qty) - flt(sre.delivered_qty),
|
||||
qty: erpnext.stock_reservation.get_held_qty(sre),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,6 +264,12 @@ $.extend(erpnext.stock_reservation, {
|
||||
dialog.show();
|
||||
},
|
||||
|
||||
get_held_qty(sre) {
|
||||
return (
|
||||
flt(sre.reserved_qty) - flt(sre.delivered_qty) - flt(sre.transferred_qty) - flt(sre.consumed_qty)
|
||||
);
|
||||
},
|
||||
|
||||
cancel_stock_reservation(dialog, frm) {
|
||||
let data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
|
||||
let method = "erpnext.manufacturing.doctype.work_order.work_order.cancel_stock_reservation_entries";
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_address",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Customer Address",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_contact",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Customer Contact",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -183,7 +183,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:50.465672",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Stop",
|
||||
|
||||
@@ -17,8 +17,8 @@ class DeliveryStop(Document):
|
||||
address: DF.Link
|
||||
contact: DF.Link | None
|
||||
customer: DF.Link | None
|
||||
customer_address: DF.SmallText | None
|
||||
customer_contact: DF.SmallText | None
|
||||
customer_address: DF.TextEditor | None
|
||||
customer_contact: DF.TextEditor | None
|
||||
delivery_note: DF.Link | None
|
||||
details: DF.TextEditor | None
|
||||
distance: DF.Float
|
||||
|
||||
@@ -210,12 +210,7 @@ frappe.ui.form.on("Delivery Stop", {
|
||||
args: { address_dict: row.address },
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"customer_address",
|
||||
frappe.utils.html2text(r.message)
|
||||
);
|
||||
frappe.model.set_value(cdt, cdn, "customer_address", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -704,6 +704,10 @@ class StockReservationEntry(Document):
|
||||
|
||||
entry.db_update()
|
||||
|
||||
@property
|
||||
def matched_serial_batch_qty(self):
|
||||
return sum(min(flt(entry.delivered_qty), flt(entry.qty)) for entry in self.sb_entries)
|
||||
|
||||
|
||||
def validate_stock_reservation_settings(voucher: object) -> None:
|
||||
"""Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
|
||||
@@ -1932,6 +1936,8 @@ def _get_stock_reservation_entries_for_voucher(
|
||||
"voucher_detail_no",
|
||||
"reserved_qty",
|
||||
"delivered_qty",
|
||||
"transferred_qty",
|
||||
"consumed_qty",
|
||||
"stock_uom",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user