Compare commits

..

13 Commits

Author SHA1 Message Date
Mihir Kandoi
9a22514c93 fix(manufacturing): release plan reservations when the plan completes (#59449) 2026-09-25 14:13:48 +00:00
Shllokkk
8e378d923d Merge pull request #59448 from Shllokkk/bank-statement-import-validate-import-file
fix: replace removed validate_import_file call in Bank Statement Import
2026-09-25 19:04:32 +05:30
Mihir Kandoi
213284cfa2 fix(manufacturing): keep a closed production plan closed until it is reopened (#59454) 2026-09-25 13:05:57 +00:00
Shllokkk
c8511e01eb Merge pull request #59447 from Shllokkk/bank-statement-import-company-account-filter
fix: show only company bank accounts in Bank Statement Import
2026-09-25 18:03:29 +05:30
Mihir Kandoi
fd8e6230f3 fix(manufacturing): explode phantom BOM rows by their stock qty (#59445) 2026-09-25 18:02:33 +05:30
Shllokkk
8cdff3bfba fix: replace removed validate_import_file call in Bank Statement Import 2026-09-25 17:55:46 +05:30
Shllokkk
48720781ce fix: show only company bank accounts in Bank Statement Import 2026-09-25 17:54:32 +05:30
Pandiyan P
a710111db9 fix(manufacturing): offset plan reservations across warehouses (#59429)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-25 12:22:03 +00:00
Mihir Kandoi
1d1562a68e test(manufacturing): secondary items across split job cards (#59436) 2026-09-25 09:24:04 +00:00
Mihir Kandoi
12d342cc62 fix: use Text Editor for fields that store HTML (#59432) 2026-09-25 09:11:22 +00:00
Sudharsanan Ashok
75efa2d1cf fix(accounts): filter purchase register by supplier group from supplier master (#59430) 2026-09-25 14:36:02 +05:30
Mihir Kandoi
f6bd6733c8 fix: use Text Editor for manufacturing descriptions copied from items (#59434) 2026-09-25 09:02:15 +00:00
Mihir Kandoi
dbada3f461 fix: count only matched batches on work order reservations (#59424) 2026-09-25 08:47:42 +00:00
35 changed files with 643 additions and 130 deletions

View File

@@ -7,6 +7,7 @@ frappe.ui.form.on("Bank Statement Import", {
return {
filters: {
company: doc.company,
is_company_account: 1,
},
};
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
},
});

View File

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