mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-09 20:51:46 +00:00
Compare commits
74 Commits
l10n_devel
...
party-impo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04bea0733b | ||
|
|
179c7c0743 | ||
|
|
fafb0b1505 | ||
|
|
3a69ada5ff | ||
|
|
62be241701 | ||
|
|
cfbbf2f165 | ||
|
|
fe09f45ca7 | ||
|
|
0bb322f4d1 | ||
|
|
bf13284137 | ||
|
|
4089f138f2 | ||
|
|
110d0a38a6 | ||
|
|
7d1d5a1309 | ||
|
|
e8a6884d5e | ||
|
|
647452c95b | ||
|
|
40d59f9a15 | ||
|
|
4a31772f3d | ||
|
|
eff29565ae | ||
|
|
691c341edd | ||
|
|
4afba94d1c | ||
|
|
f7b2775829 | ||
|
|
24de81f9fa | ||
|
|
0eb61c9fac | ||
|
|
529a003ed1 | ||
|
|
53d3ba7a78 | ||
|
|
13d3101f12 | ||
|
|
126966d1db | ||
|
|
ca0a5cb67c | ||
|
|
1478e2a4cb | ||
|
|
1deae664ce | ||
|
|
8f0617c834 | ||
|
|
0428cddf5b | ||
|
|
424a1dfa87 | ||
|
|
bed957fa67 | ||
|
|
db99657c47 | ||
|
|
9df527bf3f | ||
|
|
eb7537c8df | ||
|
|
b8dd886cd4 | ||
|
|
7157e4357b | ||
|
|
94cd27ce5d | ||
|
|
9ef386dfd2 | ||
|
|
1e2e87daac | ||
|
|
0aec62a8dd | ||
|
|
24f1f3dea8 | ||
|
|
5e0f056284 | ||
|
|
1b335973b7 | ||
|
|
335dbdaca4 | ||
|
|
1e22695eae | ||
|
|
f61f6523b9 | ||
|
|
198eb60df7 | ||
|
|
4b3904c6d7 | ||
|
|
6c8f0b9b56 | ||
|
|
aed7c70b1c | ||
|
|
3497a6a6bf | ||
|
|
11a902eb5f | ||
|
|
b3f97cd389 | ||
|
|
97d41867b5 | ||
|
|
1a47c61b67 | ||
|
|
b589a4d335 | ||
|
|
55fe269046 | ||
|
|
5822cef1d9 | ||
|
|
5e372df533 | ||
|
|
1ca06024cb | ||
|
|
1aa6f8d1a7 | ||
|
|
1b2090820a | ||
|
|
e4b66dc731 | ||
|
|
ee8eb18daf | ||
|
|
594ff24dae | ||
|
|
67f4b7a483 | ||
|
|
8a2b2a2b68 | ||
|
|
8b2946ca6b | ||
|
|
ca5a673409 | ||
|
|
69a35a12cb | ||
|
|
e0b9351d49 | ||
|
|
136f92db04 |
@@ -71,4 +71,6 @@ def get_shipping_address(company: str, address: str | None = None):
|
||||
if address:
|
||||
address_as_dict = address[0]
|
||||
name, address_template = get_address_templates(address_as_dict)
|
||||
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
|
||||
return address_as_dict.get("name"), frappe.render_template(
|
||||
address_template, address_as_dict, restrict_globals=True
|
||||
)
|
||||
|
||||
@@ -640,7 +640,7 @@ class PaymentRequest(Document):
|
||||
}
|
||||
|
||||
if self.message:
|
||||
return frappe.render_template(self.message, context)
|
||||
return frappe.render_template(self.message, context, restrict_globals=True)
|
||||
|
||||
def set_failed(self):
|
||||
pass
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -888,7 +889,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -1032,7 +1033,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -114,6 +114,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.save()
|
||||
self.assertEqual(si.items[0].qty, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self):
|
||||
"""allow_negative_rates_for_items must not bypass the >=0 guard for a non-return
|
||||
invoice, since invoices post to the GL (unlike Sales Order)."""
|
||||
si = create_sales_invoice(qty=1, rate=100, do_not_save=True)
|
||||
si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
def test_timestamp_change(self):
|
||||
w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0])
|
||||
w.docstatus = 0
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -1066,7 +1067,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -16,6 +16,11 @@ from erpnext.stock.get_item_details import (
|
||||
get_conversion_factor,
|
||||
get_item_warehouse_,
|
||||
)
|
||||
from erpnext.stock.utils import (
|
||||
is_group_warehouse,
|
||||
validate_disabled_warehouse,
|
||||
validate_warehouse_company,
|
||||
)
|
||||
|
||||
|
||||
class ChildItemUpdater:
|
||||
@@ -340,7 +345,7 @@ def set_order_defaults(
|
||||
child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
|
||||
child_item.stock_uom = item.stock_uom
|
||||
child_item.uom = trans_item.get("uom") or item.stock_uom
|
||||
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
|
||||
child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype)
|
||||
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
|
||||
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
|
||||
child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
|
||||
@@ -349,20 +354,44 @@ def set_order_defaults(
|
||||
child_item.base_rate = 1
|
||||
child_item.base_amount = 1
|
||||
|
||||
if child_doctype == "Sales Order Item":
|
||||
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
|
||||
if not child_item.warehouse:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
|
||||
).format(frappe.bold(item.item_code))
|
||||
)
|
||||
|
||||
set_child_tax_template_and_map(item, child_item, p_doc)
|
||||
add_taxes_from_tax_template(child_item, p_doc)
|
||||
return child_item
|
||||
|
||||
|
||||
def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None:
|
||||
"""Return the warehouse picked in the Update Items dialog, else the configured default.
|
||||
|
||||
Validates whichever warehouse was resolved, since a submitted parent skips validate().
|
||||
"""
|
||||
warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
|
||||
|
||||
if not warehouse:
|
||||
if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company."
|
||||
).format(frappe.bold(item.item_code))
|
||||
)
|
||||
return None
|
||||
|
||||
validate_warehouse_company(warehouse, p_doc.company)
|
||||
validate_disabled_warehouse(warehouse)
|
||||
is_group_warehouse(warehouse)
|
||||
return warehouse
|
||||
|
||||
|
||||
def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool:
|
||||
"""Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse."""
|
||||
if child_doctype == "Sales Order Item":
|
||||
return True
|
||||
|
||||
if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"):
|
||||
return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_child_on_delete(row, parent, ordered_item=None) -> None:
|
||||
"""Raise if a partially transacted child item is being deleted."""
|
||||
if parent.doctype == "Sales Order":
|
||||
|
||||
@@ -319,6 +319,38 @@ class PurchaseOrder(BuyingController):
|
||||
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
|
||||
)
|
||||
|
||||
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
|
||||
|
||||
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
|
||||
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
|
||||
if not self.is_new():
|
||||
return
|
||||
|
||||
precision = self.items[0].precision("stock_qty")
|
||||
itemwise_step = frappe._dict()
|
||||
itemwise_stock_uom = frappe._dict()
|
||||
for d in self.get("items"):
|
||||
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
|
||||
itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step)
|
||||
itemwise_stock_uom[d.item_code] = d.stock_uom
|
||||
|
||||
for item_code, qty in itemwise_qty.items():
|
||||
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
|
||||
overage = flt(qty) - min_order_qty
|
||||
if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]:
|
||||
frappe.toast(
|
||||
_(
|
||||
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
|
||||
).format(
|
||||
item_code,
|
||||
flt(qty, precision),
|
||||
itemwise_stock_uom[item_code],
|
||||
min_order_qty,
|
||||
flt(overage, precision),
|
||||
),
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
def get_schedule_dates(self):
|
||||
for d in self.get("items"):
|
||||
if d.material_request_item and not d.schedule_date:
|
||||
|
||||
@@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po.save()
|
||||
self.assertEqual(po.items[1].qty, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0})
|
||||
def test_purchase_order_negative_grand_total_blocked_without_setting(self):
|
||||
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
|
||||
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_purchase_order_negative_grand_total_allowed_with_setting(self):
|
||||
"""Use a negative rate to represent a credit while order quantities remain positive."""
|
||||
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
|
||||
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
|
||||
po.save()
|
||||
po.submit()
|
||||
self.assertEqual(po.docstatus, 1)
|
||||
self.assertTrue(po.base_grand_total < 0)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self):
|
||||
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
|
||||
po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100})
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
|
||||
def test_purchase_order_zero_qty(self):
|
||||
po = create_purchase_order(qty=0, do_not_save=True)
|
||||
|
||||
@@ -320,6 +342,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
|
||||
po.load_from_db()
|
||||
existing_ordered_qty = get_ordered_qty()
|
||||
existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC")
|
||||
first_item_of_po = po.get("items")[0]
|
||||
|
||||
trans_item = json.dumps(
|
||||
@@ -330,16 +353,62 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
"qty": first_item_of_po.qty,
|
||||
"docname": first_item_of_po.name,
|
||||
},
|
||||
{"item_code": "_Test Item", "rate": 200, "qty": 7},
|
||||
{"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Purchase Order", trans_item, po.name)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(len(po.get("items")), 2)
|
||||
self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
|
||||
self.assertEqual(po.status, "To Receive and Bill")
|
||||
# ordered qty should increase on row addition
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7)
|
||||
# ordered qty should increase on row addition, in the warehouse passed for the new row
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
|
||||
self.assertEqual(
|
||||
get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"),
|
||||
existing_ordered_qty_in_new_warehouse + 7,
|
||||
)
|
||||
|
||||
def test_update_child_adding_new_item_without_any_default_warehouse(self):
|
||||
stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name
|
||||
service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name
|
||||
|
||||
po = create_purchase_order(do_not_save=1)
|
||||
po.save()
|
||||
po.submit()
|
||||
first_item_of_po = po.get("items")[0]
|
||||
|
||||
company_default = frappe.db.get_value("Company", po.company, "default_warehouse")
|
||||
frappe.db.set_value("Company", po.company, "default_warehouse", None)
|
||||
self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default)
|
||||
|
||||
def get_trans_items(item_code):
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": first_item_of_po.item_code,
|
||||
"rate": first_item_of_po.rate,
|
||||
"qty": first_item_of_po.qty,
|
||||
"docname": first_item_of_po.name,
|
||||
},
|
||||
{"item_code": item_code, "rate": 200, "qty": 7},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot find a default warehouse",
|
||||
update_child_qty_rate,
|
||||
"Purchase Order",
|
||||
get_trans_items(stock_item),
|
||||
po.name,
|
||||
)
|
||||
|
||||
update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.get("items")[-1].item_code, service_item)
|
||||
self.assertFalse(po.get("items")[-1].warehouse)
|
||||
|
||||
def test_update_child_removing_item(self):
|
||||
po = create_purchase_order(do_not_save=1)
|
||||
@@ -724,6 +793,30 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
below_minimum.items[0].conversion_factor = 0.6
|
||||
self.assertRaises(frappe.ValidationError, below_minimum.insert)
|
||||
|
||||
def test_marginal_min_order_qty_overage_toast(self):
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
if not frappe.db.exists("UOM", "Gram"):
|
||||
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
|
||||
|
||||
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
|
||||
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
|
||||
item_doc.save()
|
||||
item = item_doc.name
|
||||
|
||||
def insert_po(qty):
|
||||
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
|
||||
po.items[0].uom = "Pound"
|
||||
po.items[0].conversion_factor = 453.592292197
|
||||
frappe.clear_messages()
|
||||
po.insert()
|
||||
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
|
||||
|
||||
self.assertTrue(insert_po(110.232))
|
||||
self.assertFalse(insert_po(150))
|
||||
|
||||
def test_uom_integer_check_tolerates_conversion_dust(self):
|
||||
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@
|
||||
"label": "UOM Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"print_width": "100px",
|
||||
"reqd": 1,
|
||||
@@ -943,7 +944,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:30:04.600510",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"label": "Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -207,7 +208,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:26.235916",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Receipt Item Supplied",
|
||||
|
||||
@@ -324,14 +324,14 @@ class RequestforQuotation(BuyingController):
|
||||
|
||||
message_template = self.mfs_html if self.use_html else self.message_for_supplier
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
rendered_message = frappe.render_template(message_template, doc_args)
|
||||
rendered_message = frappe.render_template(message_template, doc_args, restrict_globals=True)
|
||||
|
||||
subject_source = (
|
||||
self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation")
|
||||
)
|
||||
rendered_subject = frappe.render_template(subject_source, doc_args)
|
||||
rendered_subject = frappe.render_template(subject_source, doc_args, restrict_globals=True)
|
||||
if preview:
|
||||
return {
|
||||
"message": rendered_message,
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -274,7 +275,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-15 00:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Item",
|
||||
|
||||
238
erpnext/buying/doctype/supplier/supplier_import_provider.py
Normal file
238
erpnext/buying/doctype/supplier/supplier_import_provider.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
"""Data Import provider for Supplier.
|
||||
|
||||
Imports a Supplier together with Contact and Address records. Contact and Address are
|
||||
separate doctypes linked through Dynamic Link rows; the provider exposes them as child
|
||||
sections in import schema and creates linked records during row import.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.data_import.import_provider import ImportProvider
|
||||
from frappe.core.doctype.data_import.importer import INSERT, UPDATE
|
||||
|
||||
from erpnext.selling.doctype.customer.mapper import parse_full_name
|
||||
|
||||
|
||||
class SupplierImportProvider(ImportProvider):
|
||||
def get_import_fields(self) -> dict:
|
||||
return {
|
||||
"fields": _doctype_docfields("Supplier"),
|
||||
"child_tables": [
|
||||
*_doctype_child_tables("Supplier"),
|
||||
{
|
||||
"fieldname": "contacts",
|
||||
"label": _("Contact"),
|
||||
"fields": _contact_docfields(),
|
||||
},
|
||||
{
|
||||
"fieldname": "addresses",
|
||||
"label": _("Address"),
|
||||
"fields": _doctype_docfields("Address", prefer_plain_label=True),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def validate(self, import_file) -> list[dict]:
|
||||
warnings = []
|
||||
for payload in import_file.get_payloads_for_import():
|
||||
doc = payload.doc
|
||||
row = payload.rows[0].row_number if payload.rows else None
|
||||
|
||||
for contact in doc.get("contacts") or []:
|
||||
# Keep parity with current Customer provider behavior: require at least one contact method.
|
||||
if not (contact.get("email_id") or contact.get("mobile_no")):
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("email_id", _("Email")), ("mobile_no", _("Mobile No")))
|
||||
if not contact.get(field)
|
||||
]
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Contact in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
|
||||
for address in doc.get("addresses") or []:
|
||||
if not address.get("address_line1"):
|
||||
continue
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("city", _("City")), ("country", _("Country")))
|
||||
if not address.get(field)
|
||||
]
|
||||
if missing:
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Address in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
def import_row(self, importer, doc):
|
||||
contact_rows = doc.pop("contacts", None) or []
|
||||
address_rows = doc.pop("addresses", None) or []
|
||||
has_child_rows = bool(contact_rows or address_rows)
|
||||
supplier, import_action = self._persist_supplier(importer, doc, has_child_rows)
|
||||
|
||||
self._create_contacts(supplier, contact_rows)
|
||||
self._create_addresses(supplier, address_rows)
|
||||
return supplier, import_action
|
||||
|
||||
def _persist_supplier(self, importer, doc, has_child_rows):
|
||||
if importer.import_type == INSERT:
|
||||
return importer.insert_record(doc), None
|
||||
|
||||
if importer.import_type == UPDATE:
|
||||
return importer.update_record(doc, raise_if_no_changes=not has_child_rows), None
|
||||
|
||||
return importer.upsert_record(doc)
|
||||
|
||||
def _create_contacts(self, supplier, rows):
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
email = row.pop("email_id", None)
|
||||
mobile = row.pop("mobile_no", None)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_contact", 0))
|
||||
|
||||
first_name, last_name, company_name = self._resolve_contact_names(supplier, row)
|
||||
|
||||
contact_values = {k: v for k, v in row.items() if v not in (None, "")}
|
||||
if first_name:
|
||||
contact_values["first_name"] = first_name
|
||||
if last_name:
|
||||
contact_values["last_name"] = last_name
|
||||
if company_name:
|
||||
contact_values["company_name"] = company_name
|
||||
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
**contact_values,
|
||||
"links": [{"link_doctype": "Supplier", "link_name": supplier.name}],
|
||||
}
|
||||
)
|
||||
if email:
|
||||
contact.add_email(email, is_primary=True)
|
||||
if mobile:
|
||||
contact.add_phone(mobile, is_primary_mobile_no=True)
|
||||
contact.insert()
|
||||
# First created contact is the default primary; an explicit flag overrides.
|
||||
if flagged or primary is None:
|
||||
primary = contact
|
||||
if primary:
|
||||
# Contact has no cross-contact auto-demotion (unlike Address's
|
||||
# validate_preferred_address), so explicitly demote any other primary Contact on
|
||||
# this party first — otherwise get_default_contact may return a Contact other
|
||||
# than supplier_primary_contact.
|
||||
_demote_other_primary_contacts("Supplier", supplier.name, primary.name)
|
||||
frappe.db.set_value("Contact", primary.name, "is_primary_contact", 1)
|
||||
supplier.db_set("supplier_primary_contact", primary.name)
|
||||
supplier.db_set("mobile_no", primary.mobile_no)
|
||||
supplier.db_set("email_id", primary.email_id)
|
||||
|
||||
def _resolve_contact_names(self, supplier, row):
|
||||
first_name = row.pop("first_name", None)
|
||||
last_name = row.pop("last_name", None)
|
||||
company_name = row.pop("company_name", None)
|
||||
supplier_get = getattr(supplier, "get", None)
|
||||
|
||||
def get_supplier_value(fieldname):
|
||||
if callable(supplier_get):
|
||||
return supplier_get(fieldname)
|
||||
return getattr(supplier, fieldname, None)
|
||||
|
||||
if supplier.supplier_type == "Individual":
|
||||
first_name = first_name or get_supplier_value("first_name")
|
||||
last_name = last_name or get_supplier_value("last_name")
|
||||
if not first_name and supplier.supplier_name:
|
||||
parsed_first, _, parsed_last = parse_full_name(supplier.supplier_name)
|
||||
first_name = parsed_first
|
||||
last_name = last_name or parsed_last
|
||||
|
||||
return first_name, last_name, company_name
|
||||
|
||||
def _create_addresses(self, supplier, rows):
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_address", 0))
|
||||
if not row.get("address_line1"):
|
||||
continue
|
||||
row["address_type"] = row.get("address_type") or "Billing"
|
||||
row["address_title"] = row.get("address_title") or supplier.supplier_name
|
||||
address = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Address",
|
||||
**{k: v for k, v in row.items() if v not in (None, "")},
|
||||
"links": [{"link_doctype": "Supplier", "link_name": supplier.name}],
|
||||
}
|
||||
)
|
||||
address.insert()
|
||||
# First created address is the default primary; an explicit flag overrides.
|
||||
# (Must not key off the loop index — skipped rows would leave no primary.)
|
||||
if flagged or primary is None:
|
||||
primary = address
|
||||
if primary:
|
||||
# Save (not db.set_value) so Address.validate_preferred_address() clears any
|
||||
# existing primary address on the party — a raw write would leave two flagged.
|
||||
primary.is_primary_address = 1
|
||||
primary.save()
|
||||
supplier.db_set("supplier_primary_address", primary.name)
|
||||
supplier.db_set("primary_address", get_address_display(primary.name))
|
||||
|
||||
|
||||
def _demote_other_primary_contacts(link_doctype: str, link_name: str, keep: str) -> None:
|
||||
"""Clear ``is_primary_contact`` on the party's other Contacts (keeps ``keep``)."""
|
||||
linked = frappe.get_all(
|
||||
"Dynamic Link",
|
||||
filters={"link_doctype": link_doctype, "link_name": link_name, "parenttype": "Contact"},
|
||||
pluck="parent",
|
||||
)
|
||||
for other in frappe.get_all(
|
||||
"Contact", filters={"name": ["in", linked or [""]], "is_primary_contact": 1}, pluck="name"
|
||||
):
|
||||
if other != keep:
|
||||
frappe.db.set_value("Contact", other, "is_primary_contact", 0)
|
||||
|
||||
|
||||
def _doctype_docfields(doctype: str, prefer_plain_label: bool = False) -> list[dict]:
|
||||
from frappe.model import display_fieldtypes, no_value_fields
|
||||
|
||||
fields = []
|
||||
for df in frappe.get_meta(doctype).fields:
|
||||
if df.fieldtype in no_value_fields or df.fieldtype in display_fieldtypes:
|
||||
continue
|
||||
if df.fieldname in ("lft", "rgt") or df.get("is_virtual"):
|
||||
continue
|
||||
field_dict = df.as_dict()
|
||||
if prefer_plain_label:
|
||||
field_dict["prefer_plain_label"] = 1
|
||||
fields.append(field_dict)
|
||||
return fields
|
||||
|
||||
|
||||
def _contact_docfields() -> list[dict]:
|
||||
fields = _doctype_docfields("Contact", prefer_plain_label=True)
|
||||
for field in fields:
|
||||
if field.get("fieldname") == "email_id":
|
||||
field["import_labels"] = ["Email ID"]
|
||||
return fields
|
||||
|
||||
|
||||
def _doctype_child_tables(doctype: str) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"fieldname": tf.fieldname,
|
||||
"label": _(tf.label or tf.fieldname),
|
||||
"fields": _doctype_docfields(tf.options),
|
||||
}
|
||||
for tf in frappe.get_meta(doctype).get_table_fields()
|
||||
]
|
||||
@@ -217,6 +217,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -614,7 +615,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -210,6 +210,23 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
frappe.msgprint(msg)
|
||||
|
||||
def is_negative_grand_total_allowed(self) -> bool:
|
||||
"""Return True if this document may save with a negative grand total.
|
||||
|
||||
Sales Order and Purchase Order never post to the GL, so a negative
|
||||
total is safe there whenever the user has explicitly opted into
|
||||
negative rates via Selling/Buying Settings. Every other
|
||||
AccountsController doctype (invoices, delivery notes, receipts,
|
||||
quotations, ...) keeps relying on the `is_return` escape hatch only.
|
||||
"""
|
||||
if self.doctype == "Sales Order":
|
||||
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
|
||||
|
||||
if self.doctype == "Purchase Order":
|
||||
return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items"))
|
||||
|
||||
return False
|
||||
|
||||
def validate(self):
|
||||
if not self.get("is_return") and not self.get("is_debit_note"):
|
||||
self.validate_qty_is_not_zero()
|
||||
@@ -262,7 +279,8 @@ class AccountsController(TransactionBase):
|
||||
self.calculate_taxes_and_totals()
|
||||
|
||||
if not self.meta.get_field("is_return") or not self.is_return:
|
||||
self.validate_value("base_grand_total", ">=", 0)
|
||||
if not self.is_negative_grand_total_allowed():
|
||||
self.validate_value("base_grand_total", ">=", 0)
|
||||
|
||||
validate_return(self)
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ class SellingController(StockController):
|
||||
|
||||
total += sales_person.allocated_percentage
|
||||
|
||||
if sales_team and total != 100.0:
|
||||
if sales_team and flt(total, self.precision("allocated_percentage", "sales_team")) != 100.0:
|
||||
throw(_("Total allocated percentage for sales team should be 100"))
|
||||
|
||||
def validate_sales_team(self, sales_team):
|
||||
|
||||
@@ -265,6 +265,9 @@ class StatusUpdater(Document):
|
||||
|
||||
def validate_qty(self):
|
||||
"""Validates qty at row level"""
|
||||
selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note")
|
||||
buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt")
|
||||
|
||||
for args in self.status_updater:
|
||||
if "target_ref_field" not in args or args.get("validate_qty") is False:
|
||||
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||
@@ -292,11 +295,8 @@ class StatusUpdater(Document):
|
||||
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be a negative number").format(d.item_code))
|
||||
|
||||
if (
|
||||
not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"]
|
||||
) or (
|
||||
not buying_negative_rate_allowed
|
||||
and self.doctype in ["Purchase Invoice", "Purchase Receipt"]
|
||||
if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or (
|
||||
not buying_negative_rate_allowed and self.doctype in buying_doctypes
|
||||
):
|
||||
if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0:
|
||||
frappe.throw(
|
||||
@@ -307,7 +307,7 @@ class StatusUpdater(Document):
|
||||
frappe.bold(_("`Allow Negative rates for Items`")),
|
||||
get_link_to_form(
|
||||
"Selling Settings"
|
||||
if self.doctype in ["Sales Invoice", "Delivery Note"]
|
||||
if self.doctype in selling_doctypes
|
||||
else "Buying Settings"
|
||||
),
|
||||
),
|
||||
|
||||
@@ -888,7 +888,7 @@ def make_bundle_for_material_transfer(**kwargs):
|
||||
row.stock_value_difference = abs(row.stock_value_difference)
|
||||
if kwargs.type_of_transaction == "Outward":
|
||||
row.qty *= -1
|
||||
row.stock_value_difference *= row.stock_value_difference
|
||||
row.stock_value_difference *= -1
|
||||
row.is_outward = 1
|
||||
|
||||
row.warehouse = kwargs.warehouse
|
||||
|
||||
@@ -30,7 +30,7 @@ class ContractTemplate(Document):
|
||||
|
||||
def validate(self):
|
||||
if self.contract_terms:
|
||||
validate_template(self.contract_terms)
|
||||
validate_template(self.contract_terms, restrict_globals=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -41,6 +41,6 @@ def get_contract_template(template_name: str, doc: str | dict | Document):
|
||||
contract_terms = None
|
||||
|
||||
if contract_template.contract_terms:
|
||||
contract_terms = frappe.render_template(contract_template.contract_terms, doc)
|
||||
contract_terms = frappe.render_template(contract_template.contract_terms, doc, restrict_globals=True)
|
||||
|
||||
return {"contract_template": contract_template, "contract_terms": contract_terms}
|
||||
|
||||
@@ -171,8 +171,8 @@ def send_mail(entry, email_campaign):
|
||||
context = {"doc": frappe.get_doc("Email Group", recipient)}
|
||||
|
||||
# Render template
|
||||
subject = frappe.render_template(email_template.get("subject"), context)
|
||||
content = frappe.render_template(email_template.response_, context)
|
||||
subject = frappe.render_template(email_template.get("subject"), context, restrict_globals=True)
|
||||
content = frappe.render_template(email_template.response_, context, restrict_globals=True)
|
||||
|
||||
frappe.db.savepoint("email_campaign_send")
|
||||
try:
|
||||
|
||||
@@ -752,3 +752,13 @@ repost_allowed_doctypes = [
|
||||
"Payment Entry",
|
||||
"Purchase Receipt",
|
||||
]
|
||||
|
||||
|
||||
# Data Import
|
||||
# -----------
|
||||
# Custom Import Providers plug provider-owned field schema, validation and import logic into
|
||||
# the standard Data Import. See frappe.core.doctype.data_import.import_provider.
|
||||
data_import_providers = {
|
||||
"Customer": "erpnext.selling.doctype.customer.customer_import_provider.CustomerImportProvider",
|
||||
"Supplier": "erpnext.buying.doctype.supplier.supplier_import_provider.SupplierImportProvider",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -314,6 +314,7 @@ class BOM(WebsiteGenerator):
|
||||
self.clear_inspection()
|
||||
self.validate_main_item()
|
||||
self.validate_currency()
|
||||
self.set_operation_finished_goods()
|
||||
self.set_materials_based_on_operation_bom()
|
||||
self.set_conversion_rate()
|
||||
self.set_plc_conversion_rate()
|
||||
@@ -340,15 +341,42 @@ class BOM(WebsiteGenerator):
|
||||
self.set_fg_cost_allocation()
|
||||
self.validate_total_cost_allocation()
|
||||
|
||||
def set_operation_finished_goods(self):
|
||||
"""Fill each operation's FG item where it is unambiguous: the final operation produces
|
||||
this BOM's item, an operation with a BOM produces that BOM's item. Runs before
|
||||
set_materials_based_on_operation_bom so derived rows get their materials expanded."""
|
||||
if not self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
for row in self.operations:
|
||||
if row.is_final_finished_good and not row.finished_good:
|
||||
row.finished_good = self.item
|
||||
elif row.bom_no and not row.finished_good:
|
||||
row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item")
|
||||
|
||||
def validate_semi_finished_goods(self):
|
||||
if not self.track_semi_finished_goods or not self.operations:
|
||||
return
|
||||
|
||||
fg_items = []
|
||||
for row in self.operations:
|
||||
if not row.finished_good:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled."
|
||||
).format(row.idx, bold(row.operation)),
|
||||
)
|
||||
|
||||
if not row.is_final_finished_good:
|
||||
continue
|
||||
|
||||
if row.finished_good != self.item:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}."
|
||||
).format(row.idx, bold(row.operation), bold(self.item)),
|
||||
)
|
||||
|
||||
fg_items.append(row.finished_good)
|
||||
|
||||
if not fg_items:
|
||||
@@ -800,15 +828,10 @@ class BOM(WebsiteGenerator):
|
||||
row.update(get_item_details(row.get("item_code")))
|
||||
row.operation_row_id = operation_row_id
|
||||
|
||||
item_row = self.get_item_data(row.name) if row.name else None
|
||||
item_row = self.get_item_data(row.item_code, operation_row_id)
|
||||
|
||||
if item_row:
|
||||
item_row.update(
|
||||
{
|
||||
"item_code": row.get("item_code"),
|
||||
"qty": row.get("qty"),
|
||||
}
|
||||
)
|
||||
item_row.qty = row.get("qty")
|
||||
else:
|
||||
row.idx = None
|
||||
row.name = None
|
||||
@@ -827,9 +850,9 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
return False
|
||||
|
||||
def get_item_data(self, name):
|
||||
def get_item_data(self, item_code, operation_row_id):
|
||||
for row in self.items:
|
||||
if row.item_code == name:
|
||||
if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id):
|
||||
return row
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -7,7 +7,7 @@ from functools import partial
|
||||
|
||||
import frappe
|
||||
from frappe.tests import timeout
|
||||
from frappe.utils import cstr, flt
|
||||
from frappe.utils import cint, cstr, flt
|
||||
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
set_backflush_based_on,
|
||||
@@ -919,6 +919,207 @@ class TestBOM(ERPNextTestSuite):
|
||||
for row in bom.items:
|
||||
self.assertEqual(row.stock_uom, "Kg")
|
||||
|
||||
@timeout
|
||||
def test_track_semi_finished_goods_requires_finished_good_on_operations(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
|
||||
# the first operation produces nothing derivable: no FG item, no BOM to take it from
|
||||
self.assertRaises(frappe.ValidationError, bom.insert)
|
||||
|
||||
bom.operations[0].finished_good = sfg_item
|
||||
bom.insert()
|
||||
|
||||
# the final operation's FG item is derived from the BOM's own item
|
||||
self.assertEqual(bom.operations[1].finished_good, fg_item)
|
||||
|
||||
@timeout
|
||||
def test_add_raw_materials_when_item_is_used_by_another_operation(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
bom.insert()
|
||||
|
||||
def rows_for(item_code, operation_row_id):
|
||||
return [
|
||||
row
|
||||
for row in bom.items
|
||||
if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id
|
||||
]
|
||||
|
||||
# the item already used by operation 1 gets its own new row under operation 2
|
||||
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}])
|
||||
self.assertEqual(len(rows_for(rm_item, 2)), 1)
|
||||
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0)
|
||||
self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0)
|
||||
|
||||
# adding it again for the same operation updates the row instead of stacking another
|
||||
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}])
|
||||
self.assertEqual(len(rows_for(rm_item, 2)), 1)
|
||||
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0)
|
||||
|
||||
@timeout
|
||||
def test_operation_bom_materials_expand_on_single_pass_submit(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm_item, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"bom_no": sfg_bom.name,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
bom.submit()
|
||||
|
||||
self.assertEqual(bom.docstatus, 1)
|
||||
self.assertEqual(bom.operations[0].finished_good, sfg_item)
|
||||
self.assertTrue(
|
||||
any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items)
|
||||
)
|
||||
|
||||
@timeout
|
||||
def test_final_operation_must_produce_the_bom_item(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
|
||||
# the final operation claims to produce the semi FG, not this BOM's item
|
||||
self.assertRaises(frappe.ValidationError, bom.insert)
|
||||
|
||||
bom.operations[1].finished_good = fg_item
|
||||
bom.insert()
|
||||
|
||||
|
||||
def get_default_bom(item_code="_Test FG Item 2"):
|
||||
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
|
||||
|
||||
@@ -140,7 +140,8 @@
|
||||
{
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor"
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9"
|
||||
},
|
||||
{
|
||||
"fetch_from": "item_code.stock_uom",
|
||||
@@ -264,7 +265,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 21:15:55.187671",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Creator Item",
|
||||
|
||||
@@ -177,7 +177,8 @@
|
||||
{
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor"
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9"
|
||||
},
|
||||
{
|
||||
"fieldname": "rate_amount_section",
|
||||
@@ -327,7 +328,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 19:00:38.646539",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Item",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "FG / Semi FG Item",
|
||||
"mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1",
|
||||
"options": "Item"
|
||||
},
|
||||
{
|
||||
@@ -307,7 +308,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-25 17:15:42.044630",
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"non_negative": 1,
|
||||
"precision": "9",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -217,7 +218,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-16 16:51:40.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Secondary Item",
|
||||
|
||||
@@ -891,6 +891,9 @@ class JobCard(Document):
|
||||
frappe.msgprint(message, alert=True, indicator="orange")
|
||||
|
||||
def validate_transfer_qty(self):
|
||||
if self.track_semi_finished_goods and self.skip_material_transfer:
|
||||
return
|
||||
|
||||
if (
|
||||
not self.finished_good
|
||||
and not self.is_corrective_job_card
|
||||
@@ -1111,6 +1114,9 @@ class JobCard(Document):
|
||||
wo.calculate_operating_cost()
|
||||
wo.set_actual_dates()
|
||||
|
||||
if wo.track_semi_finished_goods:
|
||||
wo.set_process_loss_qty()
|
||||
|
||||
if time_data:
|
||||
wo.status = "In Process"
|
||||
|
||||
@@ -1461,12 +1467,12 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
if self.track_semi_finished_goods and previous_operations:
|
||||
manufactured_qty = self.get_manufactured_qty_per_operation(
|
||||
[row.name for row in previous_operations]
|
||||
)
|
||||
totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations])
|
||||
|
||||
for row in previous_operations:
|
||||
row.manufactured_qty = flt(manufactured_qty.get(row.name))
|
||||
operation_totals = totals.get(row.name)
|
||||
row.manufactured_qty = flt(operation_totals and operation_totals.manufactured_qty)
|
||||
row.process_loss_qty = flt(operation_totals and operation_totals.process_loss_qty)
|
||||
|
||||
return previous_operations
|
||||
|
||||
@@ -1475,7 +1481,11 @@ class JobCard(Document):
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(job_card)
|
||||
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
|
||||
.select(
|
||||
job_card.operation_id,
|
||||
Sum(job_card.manufactured_qty).as_("manufactured_qty"),
|
||||
Sum(job_card.process_loss_qty).as_("process_loss_qty"),
|
||||
)
|
||||
.where(
|
||||
(job_card.work_order == self.work_order)
|
||||
& (job_card.docstatus == 1)
|
||||
@@ -1483,9 +1493,9 @@ class JobCard(Document):
|
||||
& (job_card.operation_id.isin(operation_ids))
|
||||
)
|
||||
.groupby(job_card.operation_id)
|
||||
).run()
|
||||
).run(as_dict=True)
|
||||
|
||||
return dict(data)
|
||||
return {row.operation_id: row for row in data}
|
||||
|
||||
def get_current_operation_completed_qty(self):
|
||||
current_operation_qty = 0.0
|
||||
@@ -1537,19 +1547,35 @@ class JobCard(Document):
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
if manufactured_qty < current_operation_qty:
|
||||
if manufactured_qty >= current_operation_qty:
|
||||
return
|
||||
|
||||
if manufactured_qty + flt(row.process_loss_qty) >= current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}, as {4} was booked as process loss there."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
bold(self.get_qty_with_uom(flt(row.process_loss_qty), row.finished_good)),
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
def validate_work_order(self):
|
||||
if self.is_work_order_closed():
|
||||
frappe.throw(_("You cannot make any changes to Job Card since Work Order is closed."))
|
||||
@@ -1801,10 +1827,11 @@ class JobCard(Document):
|
||||
def build_manufacture_stock_entry(self):
|
||||
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
|
||||
|
||||
consumed_process_loss = self.get_consumed_process_loss()
|
||||
return ManufactureEntry(
|
||||
{
|
||||
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty,
|
||||
"process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0),
|
||||
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
|
||||
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
|
||||
"job_card": self.name,
|
||||
"skip_material_transfer": self.skip_material_transfer,
|
||||
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
|
||||
|
||||
@@ -1447,6 +1447,204 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(flt(job_card.manufactured_qty), 3)
|
||||
self.assertEqual(job_card.status, "Completed")
|
||||
|
||||
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Process Loss Rollup RM 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Process Loss Rollup FG 1", {"is_stock_item": 1}).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})
|
||||
|
||||
operation = {
|
||||
"operation": "Process Loss Rollup Op A",
|
||||
"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 = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=8,
|
||||
for_quantity=10,
|
||||
pending_qty=0,
|
||||
process_loss_qty=2,
|
||||
end_time="2024-05-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 2)
|
||||
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty")
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
|
||||
def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self):
|
||||
"""Loss booked by an earlier operation shrinks what the final operation can produce,
|
||||
so it has to show up on the work order even though the final operation loses nothing."""
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Intermediate Loss RM 1", {"is_stock_item": 1}).name
|
||||
sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
|
||||
operations = [
|
||||
{
|
||||
"operation": "Intermediate Loss Op A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"sequence_id": 1,
|
||||
},
|
||||
{
|
||||
"operation": "Intermediate Loss Op B",
|
||||
"finished_good": fg,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
},
|
||||
]
|
||||
|
||||
for row in operations:
|
||||
row.update(
|
||||
{
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good_qty": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
)
|
||||
make_workstation(row)
|
||||
make_operation(row)
|
||||
fg_bom.append("operations", row)
|
||||
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
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,
|
||||
)
|
||||
for row in work_order.operations:
|
||||
row.time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
def get_job_card(operation):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card",
|
||||
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
|
||||
"name",
|
||||
),
|
||||
)
|
||||
|
||||
jc_a = get_job_card("Intermediate Loss Op A")
|
||||
jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"})
|
||||
jc_a.save()
|
||||
jc_a.complete_job_card(
|
||||
qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00"
|
||||
)
|
||||
jc_a.reload()
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
|
||||
# Operation A handed over only 8 units, so the final operation works on 8.
|
||||
jc_b = get_job_card("Intermediate Loss Op B")
|
||||
jc_b.for_quantity = 8
|
||||
for row in jc_b.items:
|
||||
row.required_qty = 8
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8},
|
||||
)
|
||||
jc_b.save()
|
||||
jc_b.submit()
|
||||
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
|
||||
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -1726,6 +1924,299 @@ class TestJobCard(ERPNextTestSuite):
|
||||
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
|
||||
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
|
||||
|
||||
def test_manufacture_entry_process_loss_not_taken_from_previous_operation(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm1 = make_item("PL Scope RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name
|
||||
sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name
|
||||
fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
operation1 = {
|
||||
"operation": "PL Scope Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": "PL Scope Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
jc_a = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name"
|
||||
),
|
||||
)
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_a.pending_qty = 0
|
||||
jc_a.process_loss_qty = 2
|
||||
jc_a.submit()
|
||||
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
me_a.submit()
|
||||
self.assertEqual(flt(me_a.process_loss_qty), 2.0)
|
||||
|
||||
jc_b = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name"
|
||||
),
|
||||
)
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 2
|
||||
jc_b.submit()
|
||||
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
# operation A's loss must not leak into operation B's entry
|
||||
self.assertEqual(flt(me_b.process_loss_qty), 0.0)
|
||||
fg_row = next(row for row in me_b.items if row.is_finished_item)
|
||||
self.assertEqual(flt(fg_row.qty), 3.0)
|
||||
me_b.submit()
|
||||
|
||||
def make_semi_fg_work_order(self, prefix, qty=5):
|
||||
"""Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B
|
||||
consumes it. Both operations skip material transfer; stock is pre-seeded."""
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm1 = make_item(f"{prefix} RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name
|
||||
sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name
|
||||
fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
operation1 = {
|
||||
"operation": f"{prefix} Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": f"{prefix} Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=qty,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
for item_code in (rm1, rm2, sfg):
|
||||
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
return work_order
|
||||
|
||||
def get_semi_fg_job_card(self, work_order, operation):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"),
|
||||
)
|
||||
|
||||
def test_partial_manufacture_entry_then_finish(self):
|
||||
work_order = self.make_semi_fg_work_order("PL Partial")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B")
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 0
|
||||
jc_b.process_loss_qty = 2
|
||||
jc_b.submit()
|
||||
|
||||
# book 1 of the 3 finished units now; the full process loss goes with this first entry,
|
||||
# so it accounts for 3 of 5 and its materials are trimmed to the same share
|
||||
first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
fg_row = next(row for row in first.items if row.is_finished_item)
|
||||
fg_row.qty = 1
|
||||
for row in first.items:
|
||||
if row.s_warehouse and not row.is_finished_item:
|
||||
row.qty = flt(row.qty) * 3 / 5
|
||||
first.save()
|
||||
first.submit()
|
||||
|
||||
# the follow-up entry must be generated net of the already-booked loss and still submit
|
||||
jc_b.reload()
|
||||
second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
fg_row = next(row for row in second.items if row.is_finished_item)
|
||||
self.assertEqual(flt(fg_row.qty), 2.0)
|
||||
self.assertEqual(flt(second.process_loss_qty), 0.0)
|
||||
second.submit()
|
||||
|
||||
jc_b.reload()
|
||||
self.assertEqual(flt(jc_b.manufactured_qty), 3.0)
|
||||
|
||||
# across both entries, consumption adds up to the job card's requirement of 5, no more
|
||||
consumed = frappe.get_all(
|
||||
"Stock Entry Detail",
|
||||
filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]},
|
||||
fields=["item_code", {"SUM": "qty", "as": "qty"}],
|
||||
group_by="item_code",
|
||||
)
|
||||
self.assertTrue(consumed)
|
||||
for row in consumed:
|
||||
self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries")
|
||||
|
||||
def test_update_after_submit_keeps_manufacture_entry_intact(self):
|
||||
work_order = self.make_semi_fg_work_order("PL Update")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_a.pending_qty = 0
|
||||
jc_a.process_loss_qty = 2
|
||||
jc_a.submit()
|
||||
|
||||
entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
entry.submit()
|
||||
|
||||
if not frappe.db.exists("Print Heading", "_Test SFG Heading"):
|
||||
frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert()
|
||||
|
||||
entry.reload()
|
||||
entry.select_print_heading = "_Test SFG Heading"
|
||||
entry.save()
|
||||
|
||||
entry.reload()
|
||||
self.assertEqual(flt(entry.process_loss_qty), 2.0)
|
||||
|
||||
def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self):
|
||||
work_order = self.make_semi_fg_work_order("PL NoBom")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
# Op B has no operation BOM, so its entries carry no For Quantity to validate against
|
||||
jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B")
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 0
|
||||
jc_b.process_loss_qty = 2
|
||||
jc_b.submit()
|
||||
|
||||
draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
draft_one.submit()
|
||||
|
||||
stale = frappe.get_doc("Stock Entry", draft_two.name)
|
||||
self.assertRaises(frappe.ValidationError, stale.submit)
|
||||
|
||||
def test_semi_fg_auto_pull_with_uom_conversion(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -2302,3 +2793,43 @@ class TestJobCardLogic(ERPNextTestSuite):
|
||||
self.assertTrue(jc.has_overlap(1, sequential))
|
||||
self.assertFalse(jc.has_overlap(2, sequential))
|
||||
self.assertTrue(jc.has_overlap(2, overlapping))
|
||||
|
||||
def test_previous_operation_shortfall_from_process_loss_gets_the_right_message(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.operation = "_Test Painting"
|
||||
jc.stock_uom = "Nos"
|
||||
row = frappe._dict(
|
||||
operation="_Test Assembly", manufactured_qty=8, process_loss_qty=2, finished_good=None
|
||||
)
|
||||
|
||||
with self.assertRaises(OperationSequenceError) as loss_error:
|
||||
jc.validate_previous_operation_manufactured_qty(row, 10)
|
||||
self.assertIn("process loss", str(loss_error.exception))
|
||||
|
||||
row.process_loss_qty = 0
|
||||
with self.assertRaises(OperationSequenceError) as pending_error:
|
||||
jc.validate_previous_operation_manufactured_qty(row, 10)
|
||||
self.assertIn("Submit the manufacturing entry", str(pending_error.exception))
|
||||
|
||||
jc.validate_previous_operation_manufactured_qty(row, 8)
|
||||
|
||||
def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.track_semi_finished_goods = 1
|
||||
jc.skip_material_transfer = 1
|
||||
jc.for_quantity = 10
|
||||
jc.transferred_qty = 0
|
||||
jc.append("items", {"item_code": "_Test Item"})
|
||||
|
||||
jc.validate_transfer_qty()
|
||||
|
||||
# with transfer enabled, a legacy card without an FG item keeps the strict check
|
||||
jc.skip_material_transfer = 0
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
|
||||
|
||||
jc.finished_good = "_Test Item"
|
||||
jc.validate_transfer_qty()
|
||||
|
||||
jc.finished_good = None
|
||||
jc.track_semi_finished_goods = 0
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -266,7 +267,7 @@
|
||||
"grid_page_length": 50,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-30 17:01:25.996352",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Material Request Plan Item",
|
||||
|
||||
@@ -11,6 +11,7 @@ existing imports of ``...services.material_planning`` keep working through here.
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from decimal import ROUND_CEILING, Decimal
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
@@ -493,8 +494,16 @@ def get_material_request_items(
|
||||
)
|
||||
item_group_defaults = get_item_group_defaults(row.item_code, company)
|
||||
conversion_factor = _mr_purchase_conversion_factor(row)
|
||||
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
|
||||
return _material_request_item_row(
|
||||
row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
|
||||
row,
|
||||
sales_order,
|
||||
target_warehouse,
|
||||
bin_dict,
|
||||
required_qty,
|
||||
conversion_factor,
|
||||
item_group_defaults,
|
||||
min_order_qty,
|
||||
)
|
||||
|
||||
|
||||
@@ -539,6 +548,18 @@ def _adjust_required_qty_for_uom(row, required_qty):
|
||||
return required_qty
|
||||
|
||||
|
||||
def _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty=0):
|
||||
"""Convert to purchase UOM; a binding minimum order qty takes the smallest
|
||||
representable quantity whose stock equivalent still meets it."""
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
quantity = flt(required_qty / conversion_factor, precision)
|
||||
if min_order_qty and quantity * conversion_factor < min_order_qty <= required_qty:
|
||||
grid = Decimal(10) ** -precision
|
||||
exact = Decimal(str(min_order_qty)) / Decimal(str(conversion_factor))
|
||||
quantity = flt(exact.quantize(grid, rounding=ROUND_CEILING))
|
||||
return quantity
|
||||
|
||||
|
||||
def _mr_purchase_conversion_factor(row):
|
||||
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
|
||||
if (
|
||||
@@ -551,7 +572,14 @@ def _mr_purchase_conversion_factor(row):
|
||||
|
||||
|
||||
def _material_request_item_row(
|
||||
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
|
||||
row,
|
||||
sales_order,
|
||||
warehouse,
|
||||
bin_dict,
|
||||
required_qty,
|
||||
conversion_factor,
|
||||
item_group_defaults,
|
||||
min_order_qty=0,
|
||||
):
|
||||
warehouse = (
|
||||
warehouse
|
||||
@@ -559,11 +587,10 @@ def _material_request_item_row(
|
||||
or row.get("default_warehouse")
|
||||
or item_group_defaults.get("default_warehouse")
|
||||
)
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
return {
|
||||
"item_code": row.item_code,
|
||||
"item_name": row.item_name,
|
||||
"quantity": flt(required_qty / conversion_factor, precision),
|
||||
"quantity": _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty),
|
||||
"conversion_factor": conversion_factor,
|
||||
"required_bom_qty": row.get("qty"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
@@ -640,7 +667,8 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
|
||||
min_order_qty = flt(item.get("min_order_qty")) if consider_minimum_order_qty else 0
|
||||
item["quantity"] = _quantity_in_purchase_uom(required_qty, item.get("conversion_factor"), min_order_qty)
|
||||
new_mr_items.append(item)
|
||||
|
||||
|
||||
|
||||
@@ -2384,6 +2384,73 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
|
||||
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
|
||||
|
||||
def test_min_order_qty_conversion_takes_grid_ceiling(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
|
||||
_quantity_in_purchase_uom,
|
||||
)
|
||||
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197, 50000), 110.232)
|
||||
self.assertEqual(_quantity_in_purchase_uom(2000, 0.453592, 2000), 4409.249)
|
||||
self.assertEqual(_quantity_in_purchase_uom(10, 0.5, 10), 20.0)
|
||||
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197), 110.231)
|
||||
|
||||
def test_min_order_qty_grid_ceiling_in_plan_items(self):
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
conversion_factor = 453.592292197
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(
|
||||
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
|
||||
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
|
||||
).name
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(item_code=fg_item, planned_qty=1, do_not_submit=1)
|
||||
pln.consider_minimum_order_qty = 1
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
|
||||
self.assertEqual(mr_items[0].get("quantity"), 110.232)
|
||||
self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000)
|
||||
|
||||
def test_min_order_qty_grid_ceiling_from_other_locations(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
original_precision = frappe.db.get_default("float_precision")
|
||||
frappe.db.set_default("float_precision", "3")
|
||||
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
|
||||
|
||||
conversion_factor = 453.592292197
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(
|
||||
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
|
||||
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
|
||||
).name
|
||||
|
||||
rm_warehouse = create_warehouse("MOQ Ceiling RM Warehouse", company="_Test Company")
|
||||
source_warehouse = create_warehouse("MOQ Ceiling Source Warehouse", company="_Test Company")
|
||||
make_stock_entry(item_code=rm_item, qty=4, rate=100, target=source_warehouse)
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(item_code=fg_item, planned_qty=10, do_not_submit=1)
|
||||
pln.for_warehouse = rm_warehouse
|
||||
pln.consider_minimum_order_qty = 1
|
||||
pln.ignore_existing_ordered_qty = 1
|
||||
mr_items = get_items_for_material_requests(
|
||||
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
|
||||
)
|
||||
|
||||
rows_by_type = {d.get("material_request_type"): d for d in mr_items}
|
||||
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
|
||||
self.assertEqual(rows_by_type["Purchase"].get("quantity"), 110.232)
|
||||
|
||||
def test_fg_item_quantity(self):
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
@@ -9,6 +9,7 @@ the controller; work_order.py re-exports them for backward compatibility.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from functools import partial
|
||||
|
||||
import frappe
|
||||
@@ -476,42 +477,104 @@ def create_pick_list(
|
||||
):
|
||||
frappe.has_permission("Pick List", "create", throw=True)
|
||||
|
||||
for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
|
||||
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
|
||||
postprocess = partial(
|
||||
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
|
||||
)
|
||||
if for_qty is None:
|
||||
for_qty = frappe.parse_json(target_doc or "{}").get("for_qty")
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess), target_doc)
|
||||
for_qty = _validated_for_qty(for_qty)
|
||||
work_order = frappe.get_doc("Work Order", source_name)
|
||||
allocation = _allocate_material_demand(work_order, for_qty / flt(work_order.qty))
|
||||
postprocess = partial(_set_pick_list_item_qty, allocation_by_item=allocation)
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess, allocation), target_doc)
|
||||
_validate_material_is_pending(doc.locations)
|
||||
doc.purpose = "Material Transfer for Manufacture"
|
||||
doc.for_qty = for_qty
|
||||
doc.set_item_locations()
|
||||
return doc
|
||||
|
||||
|
||||
def _pick_list_mapping(postprocess):
|
||||
def _pick_list_mapping(postprocess, allocation):
|
||||
return {
|
||||
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
|
||||
"Work Order Item": {
|
||||
"doctype": "Pick List Item",
|
||||
"field_no_map": ["transferred_qty"],
|
||||
"postprocess": postprocess,
|
||||
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
|
||||
"condition": lambda doc: _allocation_key(doc) in allocation,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished_goods_qty):
|
||||
pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
|
||||
desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
|
||||
def _allocate_material_demand(work_order, fraction):
|
||||
"""Fraction of each (item, warehouse, operation row) group's requirement, capped
|
||||
at the group's proportional share of the item's pending pool."""
|
||||
required_by_item = {}
|
||||
covered_by_item = {}
|
||||
required_by_group = {}
|
||||
for row in work_order.required_items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
covered_by_item.setdefault(
|
||||
row.item_code,
|
||||
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty),
|
||||
)
|
||||
key = _allocation_key(row)
|
||||
required_by_group[key] = required_by_group.get(key, 0.0) + flt(row.required_qty)
|
||||
|
||||
qty = 0
|
||||
if desire_to_transfer <= pending_to_issue:
|
||||
qty = desire_to_transfer
|
||||
elif pending_to_issue > 0:
|
||||
qty = pending_to_issue
|
||||
pending_pool = {
|
||||
item_code: required_qty - covered_by_item[item_code]
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
}
|
||||
|
||||
if not qty:
|
||||
allocation = {}
|
||||
for key, required_qty in required_by_group.items():
|
||||
item_code = key[0]
|
||||
if required_by_item[item_code] <= 0:
|
||||
continue
|
||||
|
||||
pool_share = pending_pool[item_code] * required_qty / required_by_item[item_code]
|
||||
qty = min(required_qty * fraction, pool_share)
|
||||
if qty > 0:
|
||||
allocation[key] = qty
|
||||
return allocation
|
||||
|
||||
|
||||
def _allocation_key(row):
|
||||
"""Manual rows have no operation_row_id; their operation label splits them."""
|
||||
return (row.item_code, row.source_warehouse, cint(row.operation_row_id) or row.operation)
|
||||
|
||||
|
||||
def _merge_allocation_per_item(allocation):
|
||||
"""Material Request rejects repeated item codes unless Buying Settings allows them."""
|
||||
merged = {}
|
||||
key_by_item = {}
|
||||
for key, qty in allocation.items():
|
||||
item_code = key[0]
|
||||
if item_code in key_by_item:
|
||||
merged[key_by_item[item_code]] += qty
|
||||
else:
|
||||
key_by_item[item_code] = key
|
||||
merged[key] = qty
|
||||
return merged
|
||||
|
||||
|
||||
def _validated_for_qty(for_qty):
|
||||
qty = flt(for_qty)
|
||||
if not math.isfinite(qty) or qty <= 0:
|
||||
frappe.throw(_("Quantity must be greater than zero."))
|
||||
return qty
|
||||
|
||||
|
||||
def _validate_material_is_pending(rows):
|
||||
if not rows:
|
||||
frappe.throw(
|
||||
_("All required items have already been transferred, requested or picked."),
|
||||
title=_("No Pending Materials"),
|
||||
)
|
||||
|
||||
|
||||
def _set_pick_list_item_qty(source, target, source_parent, allocation_by_item):
|
||||
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
|
||||
if qty <= 0:
|
||||
target.delete()
|
||||
return
|
||||
|
||||
@@ -523,15 +586,32 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_material_request(source_name: str, target_doc: str | dict | Document | None = None):
|
||||
def make_material_request(
|
||||
source_name: str, target_doc: str | dict | Document | None = None, for_qty: float | None = None
|
||||
):
|
||||
frappe.has_permission("Material Request", "create", throw=True)
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
|
||||
if for_qty is None and frappe.flags.args:
|
||||
for_qty = frappe.flags.args.for_qty
|
||||
|
||||
work_order = frappe.get_doc("Work Order", source_name)
|
||||
fraction = 1.0
|
||||
if for_qty is not None:
|
||||
fraction = _validated_for_qty(for_qty) / flt(work_order.qty)
|
||||
|
||||
allocation = _allocate_material_demand(work_order, fraction)
|
||||
if not cint(frappe.db.get_single_value("Buying Settings", "allow_multiple_items")):
|
||||
allocation = _merge_allocation_per_item(allocation)
|
||||
postprocess = partial(_set_material_request_item, allocation_by_item=allocation)
|
||||
doc = get_mapped_doc(
|
||||
"Work Order", source_name, _material_request_mapping(postprocess, allocation), target_doc
|
||||
)
|
||||
_validate_material_is_pending(doc.items)
|
||||
doc.material_request_type = "Material Transfer"
|
||||
return doc
|
||||
|
||||
|
||||
def _material_request_mapping():
|
||||
def _material_request_mapping(postprocess, allocation):
|
||||
return {
|
||||
"Work Order": {
|
||||
"doctype": "Material Request",
|
||||
@@ -541,19 +621,23 @@ def _material_request_mapping():
|
||||
"Work Order Item": {
|
||||
"doctype": "Material Request Item",
|
||||
"field_map": [
|
||||
("required_qty", "qty"),
|
||||
("stock_uom", "uom"),
|
||||
("source_warehouse", "from_warehouse"),
|
||||
],
|
||||
"postprocess": _set_material_request_item,
|
||||
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
|
||||
"postprocess": postprocess,
|
||||
"condition": lambda doc: _allocation_key(doc) in allocation,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _set_material_request_item(source, target, source_parent):
|
||||
def _set_material_request_item(source, target, source_parent, allocation_by_item):
|
||||
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
|
||||
if qty <= 0:
|
||||
target.delete()
|
||||
return
|
||||
|
||||
target.warehouse = source_parent.wip_warehouse
|
||||
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
|
||||
target.qty = qty
|
||||
target.schedule_date = nowdate()
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ callers and the whitelisted entry point keep working unchanged.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
from pypika import functions as fn
|
||||
|
||||
@@ -198,6 +199,97 @@ class RequiredItemsService:
|
||||
for row in self.doc.required_items:
|
||||
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
|
||||
|
||||
def validate_incoming_material_demand(self, incoming_qty_by_item):
|
||||
"""Reject demand exceeding the pending requirement; callers must hold the
|
||||
work order row lock (for_update=True)."""
|
||||
required_by_item = {}
|
||||
uom_by_item = {}
|
||||
for row in self.doc.required_items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
uom_by_item.setdefault(row.item_code, row.stock_uom)
|
||||
|
||||
transferred = self._material_transfer_qty_by_item(is_return=0)
|
||||
requested = self._material_request_pending_qty_by_item()
|
||||
picked = self._pick_list_pending_qty_by_item()
|
||||
|
||||
for item_code, incoming_qty in incoming_qty_by_item.items():
|
||||
if item_code not in required_by_item:
|
||||
continue
|
||||
|
||||
pending = (
|
||||
required_by_item[item_code]
|
||||
- flt(transferred.get(item_code))
|
||||
- flt(requested.get(item_code))
|
||||
- flt(picked.get(item_code))
|
||||
)
|
||||
if flt(incoming_qty - pending, 6) > 0:
|
||||
frappe.throw(
|
||||
_("Only {0} {1} of {2} is pending in Work Order {3}.").format(
|
||||
max(pending, 0.0), uom_by_item[item_code], item_code, self.doc.name
|
||||
),
|
||||
title=_("Exceeds Pending Qty"),
|
||||
)
|
||||
|
||||
def update_requested_qty_for_required_items(self):
|
||||
"""Refresh per-row qty requested via open Material Requests but not yet transferred."""
|
||||
requested_items = self._material_request_pending_qty_by_item()
|
||||
for row in self.doc.required_items:
|
||||
row.db_set("requested_qty", (requested_items.get(row.item_code) or 0.0), update_modified=False)
|
||||
|
||||
def _material_request_pending_qty_by_item(self):
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
mr_item = frappe.qb.DocType("Material Request Item")
|
||||
query = (
|
||||
frappe.qb.from_(mr)
|
||||
.inner_join(mr_item)
|
||||
.on(mr_item.parent == mr.name)
|
||||
.select(mr_item.item_code, fn.Sum(mr_item.stock_qty - mr_item.ordered_qty).as_("qty"))
|
||||
.where(
|
||||
(mr.docstatus == 1)
|
||||
& (mr.work_order == self.doc.name)
|
||||
& (mr.material_request_type == "Material Transfer")
|
||||
& (mr.status != "Stopped")
|
||||
& (mr_item.stock_qty > mr_item.ordered_qty)
|
||||
)
|
||||
.groupby(mr_item.item_code)
|
||||
)
|
||||
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
|
||||
|
||||
def update_picked_qty_for_required_items(self):
|
||||
"""Refresh per-row qty picked but not yet transferred. Rows of a live material
|
||||
request count as requested_qty instead, until that request stops or cancels."""
|
||||
picked_items = self._pick_list_pending_qty_by_item()
|
||||
for row in self.doc.required_items:
|
||||
row.db_set("picked_qty", (picked_items.get(row.item_code) or 0.0), update_modified=False)
|
||||
|
||||
def _pick_list_pending_qty_by_item(self):
|
||||
pick_list = frappe.qb.DocType("Pick List")
|
||||
pick_list_item = frappe.qb.DocType("Pick List Item")
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
query = (
|
||||
frappe.qb.from_(pick_list)
|
||||
.inner_join(pick_list_item)
|
||||
.on(pick_list_item.parent == pick_list.name)
|
||||
.left_join(mr)
|
||||
.on(pick_list_item.material_request == mr.name)
|
||||
.select(
|
||||
pick_list_item.item_code,
|
||||
fn.Sum(pick_list_item.picked_qty - pick_list_item.transferred_qty).as_("qty"),
|
||||
)
|
||||
.where(
|
||||
(pick_list.docstatus == 1)
|
||||
& (pick_list.work_order == self.doc.name)
|
||||
& (pick_list_item.picked_qty > pick_list_item.transferred_qty)
|
||||
& (
|
||||
(fn.Coalesce(pick_list_item.material_request_item, "") == "")
|
||||
| (mr.docstatus != 1)
|
||||
| (mr.status == "Stopped")
|
||||
)
|
||||
)
|
||||
.groupby(pick_list_item.item_code)
|
||||
)
|
||||
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
|
||||
|
||||
def _material_transfer_qty_by_item(self, is_return):
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
@@ -291,6 +291,12 @@ class StatusService:
|
||||
)
|
||||
|
||||
def set_process_loss_qty(self):
|
||||
self.doc.db_set("process_loss_qty", self._process_loss_qty())
|
||||
|
||||
def _process_loss_qty(self):
|
||||
if self.doc.track_semi_finished_goods:
|
||||
return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations))
|
||||
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
process_loss_qty = (
|
||||
frappe.qb.from_(table)
|
||||
@@ -302,7 +308,7 @@ class StatusService:
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
self.doc.db_set("process_loss_qty", flt(process_loss_qty))
|
||||
return flt(process_loss_qty)
|
||||
|
||||
def update_production_plan_status(self):
|
||||
production_plan = frappe.get_doc("Production Plan", self.doc.production_plan)
|
||||
|
||||
@@ -1638,6 +1638,359 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
|
||||
def test_material_request_qty_scales_with_requested_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
self.assertEqual(len(mr.items), len(required_qty))
|
||||
for row in mr.items:
|
||||
self.assertEqual(row.qty, required_qty[row.item_code] * 4 / 10)
|
||||
|
||||
mr = make_material_request(work_order.name)
|
||||
for row in mr.items:
|
||||
self.assertEqual(row.qty, required_qty[row.item_code])
|
||||
|
||||
def test_material_request_qty_capped_at_pending_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
partially_transferred = work_order.required_items[0]
|
||||
partially_transferred.db_set("transferred_qty", flt(partially_transferred.required_qty) - 1)
|
||||
work_order.reload()
|
||||
|
||||
mr = make_material_request(work_order.name, for_qty=10)
|
||||
requested_qty = {row.item_code: row.qty for row in mr.items}
|
||||
self.assertEqual(requested_qty[partially_transferred.item_code], 1)
|
||||
|
||||
def test_material_request_maps_only_selected_rows(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
selected = work_order.required_items[0]
|
||||
|
||||
try:
|
||||
frappe.flags.selected_children = {"required_items": [selected.name]}
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
finally:
|
||||
frappe.flags.selected_children = None
|
||||
|
||||
self.assertEqual([row.item_code for row in mr.items], [selected.item_code])
|
||||
self.assertEqual(mr.items[0].qty, flt(selected.required_qty) * 4 / 10)
|
||||
|
||||
def test_material_request_rejects_nonpositive_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=0)
|
||||
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=-1)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("inf")
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("nan")
|
||||
)
|
||||
|
||||
def test_pick_list_rejects_nonpositive_qty(self):
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=0)
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=-1)
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("inf"))
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("nan"))
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name)
|
||||
|
||||
def submit_material_request(self, work_order_name, for_qty=None):
|
||||
mr = make_material_request(work_order_name, for_qty=for_qty)
|
||||
mr.schedule_date = today()
|
||||
for item in mr.items:
|
||||
item.schedule_date = today()
|
||||
mr.insert()
|
||||
mr.submit()
|
||||
return mr
|
||||
|
||||
def receive_test_fg_raw_materials(self):
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=1000.0
|
||||
)
|
||||
|
||||
def test_requested_qty_tracks_open_material_requests(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
|
||||
|
||||
remainder_mr = make_material_request(work_order.name, for_qty=10)
|
||||
for row in remainder_mr.items:
|
||||
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
|
||||
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
|
||||
|
||||
mr.cancel()
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, 0)
|
||||
|
||||
def test_requested_qty_moves_to_transferred_qty_on_stock_entry(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
|
||||
|
||||
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
|
||||
stock_entry.insert()
|
||||
stock_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, 0)
|
||||
self.assertEqual(row.transferred_qty, mr_qty[row.item_code])
|
||||
|
||||
remainder_mr = make_material_request(work_order.name)
|
||||
for row in remainder_mr.items:
|
||||
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
|
||||
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
|
||||
|
||||
def test_picked_qty_tracks_open_pick_lists(self):
|
||||
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
pick_list = create_pick_list(work_order.name, for_qty=4)
|
||||
pick_list.insert()
|
||||
pick_list.submit()
|
||||
picked_qty = {row.item_code: flt(row.stock_qty) for row in pick_list.locations}
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.picked_qty, picked_qty[row.item_code])
|
||||
|
||||
remainder_pick_list = create_pick_list(work_order.name, for_qty=10)
|
||||
for row in remainder_pick_list.locations:
|
||||
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
|
||||
self.assertEqual(row.qty, flt(required_row.required_qty) - picked_qty[row.item_code])
|
||||
|
||||
remainder_pick_list.insert()
|
||||
remainder_pick_list.submit()
|
||||
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=10)
|
||||
|
||||
def test_material_request_submit_rejects_exceeding_pending_qty(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
def full_draft():
|
||||
mr = make_material_request(work_order.name)
|
||||
mr.schedule_date = today()
|
||||
for item in mr.items:
|
||||
item.schedule_date = today()
|
||||
mr.insert()
|
||||
return mr
|
||||
|
||||
first, second = full_draft(), full_draft()
|
||||
first.submit()
|
||||
self.assertRaises(frappe.ValidationError, second.submit)
|
||||
|
||||
def test_picked_qty_counts_pick_list_of_stopped_material_request(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
|
||||
|
||||
pick_list = mr_to_pick_list(mr.name)
|
||||
pick_list.insert()
|
||||
pick_list.submit()
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
|
||||
self.assertEqual(row.picked_qty, 0)
|
||||
|
||||
mr.reload()
|
||||
mr.update_status("Stopped")
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
self.assertEqual(row.requested_qty, 0)
|
||||
self.assertEqual(row.picked_qty, mr_qty[row.item_code])
|
||||
|
||||
def test_pending_demand_shared_across_duplicate_item_rows(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": first.source_warehouse,
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
total_required = flt(first.required_qty) + 5
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
requested = sum(flt(row.qty) for row in mr.items if row.item_code == first.item_code)
|
||||
self.assertAlmostEqual(requested, total_required * 4 / 10, places=6)
|
||||
|
||||
work_order.reload()
|
||||
for row in work_order.required_items:
|
||||
if row.item_code == first.item_code:
|
||||
self.assertAlmostEqual(row.requested_qty, requested, places=6)
|
||||
|
||||
remainder_mr = make_material_request(work_order.name, for_qty=10)
|
||||
remainder = sum(flt(row.qty) for row in remainder_mr.items if row.item_code == first.item_code)
|
||||
self.assertAlmostEqual(remainder, total_required - requested, places=6)
|
||||
|
||||
def test_allocation_splits_by_source_warehouse(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
rows = {row.from_warehouse: flt(row.qty) for row in mr.items if row.item_code == first.item_code}
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
|
||||
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
|
||||
|
||||
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
rows = [row for row in mr.items if row.item_code == first.item_code]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertAlmostEqual(flt(rows[0].qty), (flt(first.required_qty) + 5) * 4 / 10, places=6)
|
||||
|
||||
def test_remainder_allocation_splits_proportionally_across_groups(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
duplicate = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": "_Test Warehouse 1 - _TC",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
duplicate.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
|
||||
self.submit_material_request(work_order.name, for_qty=4)
|
||||
work_order.reload()
|
||||
remainder = make_material_request(work_order.name, for_qty=10)
|
||||
rows = {
|
||||
row.from_warehouse: flt(row.qty) for row in remainder.items if row.item_code == first.item_code
|
||||
}
|
||||
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 6 / 10, places=6)
|
||||
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 6 / 10, places=6)
|
||||
|
||||
def test_allocation_splits_manual_rows_by_operation_label(self):
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
first = work_order.required_items[0]
|
||||
for operation in ("_Test Operation A", "_Test Operation B"):
|
||||
row = work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": first.item_code,
|
||||
"required_qty": 5,
|
||||
"stock_uom": first.stock_uom,
|
||||
"source_warehouse": first.source_warehouse,
|
||||
"operation": operation,
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
row.db_insert()
|
||||
work_order.reload()
|
||||
|
||||
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
|
||||
mr = make_material_request(work_order.name, for_qty=4)
|
||||
rows = [flt(row.qty) for row in mr.items if row.item_code == first.item_code]
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertAlmostEqual(sum(rows), (flt(first.required_qty) + 10) * 4 / 10, places=6)
|
||||
|
||||
def test_pick_list_rejects_over_pick_against_material_request(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
|
||||
|
||||
self.receive_test_fg_raw_materials()
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = self.submit_material_request(work_order.name, for_qty=4)
|
||||
pick_list = mr_to_pick_list(mr.name)
|
||||
pick_list.insert()
|
||||
pick_list.locations[0].picked_qty = flt(pick_list.locations[0].stock_qty) + 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pick_list.submit)
|
||||
|
||||
def test_backflushed_batch_raw_materials_based_on_transferred(self):
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings",
|
||||
@@ -5108,6 +5461,24 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(flt(qty_by_item.get(item_a)), 10.0)
|
||||
self.assertEqual(flt(qty_by_item.get(item_b)), 10.0)
|
||||
|
||||
def test_wip_warehouse_required_when_tracking_semi_finished_goods(self):
|
||||
wo = frappe.new_doc("Work Order")
|
||||
wo.track_semi_finished_goods = 1
|
||||
wo.skip_transfer = 0
|
||||
wo.fg_warehouse = "_Test Warehouse 1 - _TC"
|
||||
|
||||
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
|
||||
|
||||
wo.wip_warehouse = "_Test Warehouse - _TC"
|
||||
wo.validate_warehouse()
|
||||
|
||||
# the top-level target warehouse stays optional; operations may carry their own
|
||||
wo.fg_warehouse = None
|
||||
wo.validate_warehouse()
|
||||
|
||||
wo.track_semi_finished_goods = 0
|
||||
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
|
||||
|
||||
|
||||
def get_reserved_entries(voucher_no, warehouse=None):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -852,7 +852,10 @@ erpnext.work_order = {
|
||||
function () {
|
||||
let purpose = "Material Transfer for Manufacture";
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, purpose, qty, 1)
|
||||
.show_prompt_for_qty_input(frm, purpose, {
|
||||
qty: qty,
|
||||
additional_transfer_entry: 1,
|
||||
})
|
||||
.then((data) => {
|
||||
return frappe.xcall(
|
||||
"erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
|
||||
@@ -1038,6 +1041,26 @@ erpnext.work_order = {
|
||||
return flt(max, precision("qty"));
|
||||
},
|
||||
|
||||
get_max_requestable_qty: (frm) => {
|
||||
const required = {};
|
||||
const covered = {};
|
||||
(frm.doc.required_items || []).forEach((row) => {
|
||||
required[row.item_code] = (required[row.item_code] || 0) + flt(row.required_qty);
|
||||
if (!(row.item_code in covered)) {
|
||||
covered[row.item_code] =
|
||||
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty);
|
||||
}
|
||||
});
|
||||
|
||||
let max_fraction = 0;
|
||||
Object.keys(required).forEach((item_code) => {
|
||||
if (required[item_code] <= 0) return;
|
||||
const pending = required[item_code] - covered[item_code];
|
||||
max_fraction = Math.max(max_fraction, pending / required[item_code]);
|
||||
});
|
||||
return flt(max_fraction * flt(frm.doc.qty), precision("qty"));
|
||||
},
|
||||
|
||||
show_disassembly_prompt: function (frm) {
|
||||
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
||||
|
||||
@@ -1092,20 +1115,20 @@ erpnext.work_order = {
|
||||
});
|
||||
},
|
||||
|
||||
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
|
||||
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
|
||||
show_prompt_for_qty_input: function (frm, purpose, { qty, additional_transfer_entry, target } = {}) {
|
||||
let max = qty == null ? this.get_max_transferable_qty(frm, purpose) : qty;
|
||||
|
||||
let fields = [
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Qty for {0}", [__(purpose)]),
|
||||
label: __("Qty for {0}", [target || __(purpose)]),
|
||||
fieldname: "qty",
|
||||
description: __("Max: {0}", [max]),
|
||||
default: max,
|
||||
},
|
||||
];
|
||||
|
||||
if (!additional_transfer_entry) {
|
||||
if (!additional_transfer_entry && !target) {
|
||||
fields.push({
|
||||
fieldtype: "Check",
|
||||
label: __("Consider Process Loss"),
|
||||
@@ -1127,6 +1150,11 @@ erpnext.work_order = {
|
||||
(data) => {
|
||||
max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100;
|
||||
|
||||
if (!data.qty || data.qty <= 0) {
|
||||
frappe.msgprint(__("Quantity must be greater than zero."));
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
if (data.qty > max) {
|
||||
frappe.msgprint(__("Quantity must not be more than {0}", [max]));
|
||||
reject();
|
||||
@@ -1169,15 +1197,32 @@ erpnext.work_order = {
|
||||
}
|
||||
},
|
||||
|
||||
make_material_request: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
|
||||
frm,
|
||||
});
|
||||
make_material_request: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||
const max = this.get_max_requestable_qty(frm);
|
||||
if (max <= 0) {
|
||||
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
|
||||
return;
|
||||
}
|
||||
|
||||
const get_material_request = (for_qty) =>
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
|
||||
frm,
|
||||
args: { for_qty: for_qty },
|
||||
});
|
||||
|
||||
this.show_prompt_for_qty_input(frm, purpose, {
|
||||
qty: max,
|
||||
target: __("Material Request"),
|
||||
}).then((data) => get_material_request(data.qty));
|
||||
},
|
||||
|
||||
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||
const max = this.get_max_transferable_qty(frm, purpose);
|
||||
const max = this.get_max_requestable_qty(frm);
|
||||
if (max <= 0) {
|
||||
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
|
||||
return;
|
||||
}
|
||||
|
||||
const get_pick_list = (for_qty) =>
|
||||
frappe
|
||||
@@ -1190,11 +1235,10 @@ erpnext.work_order = {
|
||||
frappe.set_route("Form", pick_list.doctype, pick_list.name);
|
||||
});
|
||||
|
||||
if (max <= 0) {
|
||||
get_pick_list(frm.doc.qty);
|
||||
} else {
|
||||
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
|
||||
}
|
||||
this.show_prompt_for_qty_input(frm, purpose, {
|
||||
qty: max,
|
||||
target: __("Pick List"),
|
||||
}).then((data) => get_pick_list(data.qty));
|
||||
},
|
||||
|
||||
make_consumption_se: function (frm, backflush_raw_materials_based_on) {
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Work-in-Progress Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods",
|
||||
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -739,7 +739,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 21:35:34.175667",
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -601,12 +601,9 @@ class WorkOrder(Document):
|
||||
)
|
||||
|
||||
def validate_warehouse(self):
|
||||
if self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
if not self.wip_warehouse and not self.skip_transfer:
|
||||
frappe.throw(_("Work-in-Progress Warehouse is required before Submit"))
|
||||
if not self.fg_warehouse:
|
||||
if not self.fg_warehouse and not self.track_semi_finished_goods:
|
||||
frappe.throw(_("Target Warehouse is required before Submit"))
|
||||
|
||||
def before_submit(self):
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"amount",
|
||||
"column_break_11",
|
||||
"transferred_qty",
|
||||
"requested_qty",
|
||||
"picked_qty",
|
||||
"consumed_qty",
|
||||
"returned_qty",
|
||||
"section_break_idhr",
|
||||
@@ -93,6 +95,22 @@
|
||||
"label": "Transferred Qty",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!parent.skip_transfer",
|
||||
"fieldname": "requested_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Requested Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!parent.skip_transfer",
|
||||
"fieldname": "picked_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Picked Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:!parent.subcontracting_inward_order",
|
||||
@@ -209,7 +227,7 @@
|
||||
"grid_page_length": 50,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-12 12:05:16.687866",
|
||||
"modified": "2026-08-07 10:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -31,8 +31,10 @@ class WorkOrderItem(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
picked_qty: DF.Float
|
||||
rate: DF.Currency
|
||||
required_qty: DF.Float
|
||||
requested_qty: DF.Float
|
||||
returned_qty: DF.Float
|
||||
source_warehouse: DF.Link | None
|
||||
stock_reserved_qty: DF.Float
|
||||
|
||||
@@ -508,3 +508,5 @@ erpnext.patches.v16_0.move_warehouse_defaults_to_company
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty
|
||||
erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import frappe
|
||||
from frappe import qb
|
||||
|
||||
|
||||
@@ -13,5 +14,8 @@ def execute():
|
||||
"Payment Reconciliation Allocation",
|
||||
]
|
||||
for x in doctypes:
|
||||
# child tables may not exist yet on sites where this pre-model-sync patch runs first
|
||||
if not frappe.db.table_exists(x):
|
||||
continue
|
||||
dt = qb.DocType(x)
|
||||
qb.from_(dt).delete().run()
|
||||
|
||||
53
erpnext/patches/v16_0/rename_italy_customer_name_fields.py
Normal file
53
erpnext/patches/v16_0/rename_italy_customer_name_fields.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import frappe
|
||||
|
||||
RENAMED_FIELDS = {
|
||||
"first_name": "italy_customer_first_name",
|
||||
"last_name": "italy_customer_last_name",
|
||||
}
|
||||
|
||||
|
||||
def execute():
|
||||
"""Rename Italy's Customer name fields, which clash with the standard quick-entry
|
||||
first_name/last_name fields, and restore any Italy custom field columns that a
|
||||
previously interrupted fixture run left missing."""
|
||||
if not has_italy_fixtures():
|
||||
return
|
||||
|
||||
duplicate_fieldnames = [
|
||||
fieldname for fieldname in RENAMED_FIELDS if frappe.db.exists("Custom Field", f"Customer-{fieldname}")
|
||||
]
|
||||
|
||||
from erpnext.regional.italy.setup import get_custom_fields, make_custom_fields
|
||||
|
||||
make_custom_fields()
|
||||
for doctype in get_custom_fields():
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
frappe.db.updatedb(doctype)
|
||||
|
||||
for old_fieldname, new_fieldname in RENAMED_FIELDS.items():
|
||||
copy_customer_names(old_fieldname, new_fieldname)
|
||||
|
||||
for old_fieldname in duplicate_fieldnames:
|
||||
frappe.delete_doc("Custom Field", f"Customer-{old_fieldname}", force=True)
|
||||
|
||||
if duplicate_fieldnames:
|
||||
frappe.clear_cache(doctype="Customer")
|
||||
|
||||
|
||||
def has_italy_fixtures():
|
||||
return bool(
|
||||
frappe.db.exists("Company", {"country": "Italy"})
|
||||
or frappe.db.exists("Custom Field", "Company-fiscal_regime")
|
||||
)
|
||||
|
||||
|
||||
def copy_customer_names(old_fieldname, new_fieldname):
|
||||
customer = frappe.qb.DocType("Customer")
|
||||
old_column = customer[old_fieldname]
|
||||
new_column = customer[new_fieldname]
|
||||
(
|
||||
frappe.qb.update(customer)
|
||||
.set(new_column, old_column)
|
||||
.where(old_column.isnotnull() & (old_column != ""))
|
||||
.where(new_column.isnull() | (new_column == ""))
|
||||
).run()
|
||||
@@ -0,0 +1,38 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
|
||||
def execute():
|
||||
"""Backfill requested_qty and picked_qty for work orders with open demand;
|
||||
fulfilled documents leave the zero default."""
|
||||
work_orders = set(
|
||||
frappe.get_all(
|
||||
"Material Request",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"material_request_type": "Material Transfer",
|
||||
"work_order": ("is", "set"),
|
||||
"status": ("!=", "Stopped"),
|
||||
"per_ordered": ("<", 100),
|
||||
},
|
||||
pluck="work_order",
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
work_orders.update(
|
||||
frappe.get_all(
|
||||
"Pick List",
|
||||
filters={"docstatus": 1, "work_order": ("is", "set"), "status": ("!=", "Completed")},
|
||||
pluck="work_order",
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
|
||||
for name in work_orders:
|
||||
if frappe.db.get_value("Work Order", name, "docstatus") != 1:
|
||||
continue
|
||||
|
||||
service = RequiredItemsService(frappe.get_doc("Work Order", name))
|
||||
service.update_requested_qty_for_required_items()
|
||||
service.update_picked_qty_for_required_items()
|
||||
@@ -90,6 +90,7 @@ class Task(NestedSet):
|
||||
self.validate_completed_on()
|
||||
self.set_default_end_date_if_missing()
|
||||
self.validate_parent_is_group()
|
||||
self.validate_web_form_project_permission()
|
||||
|
||||
def validate_dates(self):
|
||||
self.validate_from_to_dates("exp_start_date", "exp_end_date")
|
||||
@@ -313,6 +314,23 @@ class Task(NestedSet):
|
||||
if project_user:
|
||||
return True
|
||||
|
||||
def validate_web_form_project_permission(self):
|
||||
project_unchanged = not self.is_new() and self.project == self.get_db_value("project")
|
||||
|
||||
if (
|
||||
not frappe.flags.in_web_form
|
||||
or not self.project
|
||||
or project_unchanged
|
||||
or frappe.has_permission("Project", "write", doc=self.project)
|
||||
or self.has_webform_permission()
|
||||
):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("You are not permitted to create a Task for Project {0}").format(self.project),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
def populate_depends_on(self):
|
||||
if self.parent_task:
|
||||
parent = frappe.get_doc("Task", self.parent_task)
|
||||
|
||||
@@ -456,7 +456,7 @@ const set_employee_and_company = function (frm) {
|
||||
const options = { user_id: frappe.session.user };
|
||||
const fields = ["name", "company"];
|
||||
frappe.db.get_value("Employee", options, fields).then(({ message }) => {
|
||||
if (message) {
|
||||
if (message.name && message.company) {
|
||||
// there is an employee with the currently logged in user_id
|
||||
frm.set_value("employee", message.name);
|
||||
frm.set_value("company", message.company);
|
||||
|
||||
@@ -1802,7 +1802,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
item.conversion_factor = 1.0;
|
||||
if (item.stock_qty) {
|
||||
item.conversion_factor = flt(item.stock_qty) / flt(item.qty);
|
||||
item.conversion_factor = flt(
|
||||
flt(item.stock_qty) / flt(item.qty),
|
||||
precision("conversion_factor", item)
|
||||
);
|
||||
}
|
||||
|
||||
refresh_field("conversion_factor", item.name, item.parentfield);
|
||||
|
||||
@@ -742,6 +742,7 @@ erpnext.utils.update_child_items = function (opts) {
|
||||
qty: d.qty,
|
||||
rate: d.rate,
|
||||
uom: d.uom,
|
||||
warehouse: d.warehouse,
|
||||
fg_item: d.fg_item,
|
||||
fg_item_qty: d.fg_item_qty,
|
||||
description: d.description,
|
||||
@@ -829,6 +830,7 @@ erpnext.utils.update_child_items = function (opts) {
|
||||
item_name,
|
||||
bom_no,
|
||||
description,
|
||||
warehouse,
|
||||
} = r.message;
|
||||
const row = dialog.fields_dict.trans_items.df.data.find(
|
||||
(row) => row.name == me.doc.name
|
||||
@@ -842,6 +844,7 @@ erpnext.utils.update_child_items = function (opts) {
|
||||
item_name: item_name,
|
||||
bom_no: bom_no,
|
||||
description: me.doc.description || description,
|
||||
warehouse: me.doc.docname ? me.doc.warehouse : warehouse,
|
||||
});
|
||||
dialog.fields_dict.trans_items.grid.refresh();
|
||||
}
|
||||
@@ -929,6 +932,29 @@ erpnext.utils.update_child_items = function (opts) {
|
||||
});
|
||||
}
|
||||
|
||||
const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse");
|
||||
if (warehouse_df) {
|
||||
fields.splice(3, 0, {
|
||||
fieldtype: "Link",
|
||||
fieldname: "warehouse",
|
||||
options: "Warehouse",
|
||||
in_list_view: 1,
|
||||
label: __(warehouse_df.label),
|
||||
// only new rows may set it, existing rows would leave their
|
||||
// reserved qty stranded in the previous warehouse's bin
|
||||
read_only_depends_on: "eval:doc.docname",
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
disabled: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) {
|
||||
fields.push(
|
||||
{
|
||||
|
||||
@@ -99,8 +99,8 @@
|
||||
{%- if doc.customer_data.customer_type == "Individual" %}
|
||||
<CodiceFiscale>{{ doc.customer_data.fiscal_code }}</CodiceFiscale>
|
||||
<Anagrafica>
|
||||
<Nome>{{ doc.customer_data.first_name }}</Nome>
|
||||
<Cognome>{{ doc.customer_data.last_name }}</Cognome>
|
||||
<Nome>{{ doc.customer_data.italy_customer_first_name }}</Nome>
|
||||
<Cognome>{{ doc.customer_data.italy_customer_last_name }}</Cognome>
|
||||
</Anagrafica>
|
||||
{%- else %}
|
||||
<IdFiscaleIVA>
|
||||
|
||||
@@ -23,6 +23,10 @@ def setup(company=None, patch=True):
|
||||
|
||||
|
||||
def make_custom_fields(update=True):
|
||||
create_custom_fields(get_custom_fields(), ignore_validate=frappe.flags.in_patch, update=update)
|
||||
|
||||
|
||||
def get_custom_fields():
|
||||
invoice_item_fields = [
|
||||
dict(
|
||||
fieldname="tax_rate",
|
||||
@@ -96,7 +100,7 @@ def make_custom_fields(update=True):
|
||||
),
|
||||
]
|
||||
|
||||
custom_fields = {
|
||||
return {
|
||||
"Company": [
|
||||
dict(
|
||||
fieldname="sb_e_invoicing",
|
||||
@@ -232,18 +236,18 @@ def make_custom_fields(update=True):
|
||||
depends_on='eval:doc.customer_type=="Company"',
|
||||
),
|
||||
dict(
|
||||
fieldname="first_name",
|
||||
fieldname="italy_customer_first_name",
|
||||
label="First Name",
|
||||
fieldtype="Data",
|
||||
insert_after="salutation",
|
||||
insert_after="customer_type",
|
||||
print_hide=1,
|
||||
depends_on='eval:doc.customer_type!="Company"',
|
||||
),
|
||||
dict(
|
||||
fieldname="last_name",
|
||||
fieldname="italy_customer_last_name",
|
||||
label="Last Name",
|
||||
fieldtype="Data",
|
||||
insert_after="first_name",
|
||||
insert_after="italy_customer_first_name",
|
||||
print_hide=1,
|
||||
depends_on='eval:doc.customer_type!="Company"',
|
||||
),
|
||||
@@ -461,8 +465,6 @@ def make_custom_fields(update=True):
|
||||
],
|
||||
}
|
||||
|
||||
create_custom_fields(custom_fields, ignore_validate=frappe.flags.in_patch, update=update)
|
||||
|
||||
|
||||
def setup_report():
|
||||
report_name = "Electronic Invoice Register"
|
||||
|
||||
@@ -199,7 +199,8 @@ class Customer(TransactionBase):
|
||||
self.loyalty_program_tier = customer.loyalty_program_tier
|
||||
|
||||
if self.sales_team:
|
||||
if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100:
|
||||
total = sum(flt(member.allocated_percentage) for member in self.sales_team)
|
||||
if flt(total, self.precision("allocated_percentage", "sales_team")) != 100:
|
||||
frappe.throw(_("Total contribution percentage should be equal to 100"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
|
||||
249
erpnext/selling/doctype/customer/customer_import_provider.py
Normal file
249
erpnext/selling/doctype/customer/customer_import_provider.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
"""Data Import provider for Customer.
|
||||
|
||||
Imports a Customer together with its Contact and Address records. In the schema Contact and
|
||||
Address are separate doctypes linked to the Customer via a Dynamic Link; the provider exposes
|
||||
them as child tables in the import and wires up the links + primary flags in ``import_row``.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.data_import.import_provider import ImportProvider
|
||||
from frappe.core.doctype.data_import.importer import INSERT, UPDATE
|
||||
|
||||
from .mapper import parse_full_name
|
||||
|
||||
|
||||
class CustomerImportProvider(ImportProvider):
|
||||
def get_import_fields(self) -> dict:
|
||||
"""Field schema for the picker: a Customer with its own child tables, plus Contact and
|
||||
Address (linked doctypes exposed as extra child tables).
|
||||
|
||||
For Customer the columns ARE those doctypes' fields, so we read complete docfields from
|
||||
meta. (This is the provider's choice — Data Import itself never reads meta here.)
|
||||
"""
|
||||
return {
|
||||
"fields": _doctype_docfields("Customer"),
|
||||
"child_tables": [
|
||||
*_doctype_child_tables("Customer"),
|
||||
{
|
||||
"fieldname": "contacts",
|
||||
"label": _("Contact"),
|
||||
"fields": _contact_docfields(),
|
||||
},
|
||||
{
|
||||
"fieldname": "addresses",
|
||||
"label": _("Address"),
|
||||
"fields": _doctype_docfields("Address", prefer_plain_label=True),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def validate(self, import_file) -> list[dict]:
|
||||
"""Business checks on top of the framework's basic Select/Link/Date validation."""
|
||||
warnings = []
|
||||
for payload in import_file.get_payloads_for_import():
|
||||
doc = payload.doc
|
||||
row = payload.rows[0].row_number if payload.rows else None
|
||||
for contact in doc.get("contacts") or []:
|
||||
# A Contact is only useful with a way to reach it — warn when both are missing.
|
||||
if not (contact.get("email_id") or contact.get("mobile_no")):
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("email_id", _("Email")), ("mobile_no", _("Mobile No")))
|
||||
if not contact.get(field)
|
||||
]
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Contact in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
for address in doc.get("addresses") or []:
|
||||
if not address.get("address_line1"):
|
||||
continue
|
||||
missing = [
|
||||
label
|
||||
for field, label in (("city", _("City")), ("country", _("Country")))
|
||||
if not address.get(field)
|
||||
]
|
||||
if missing:
|
||||
warnings.append(
|
||||
{
|
||||
"row": row,
|
||||
"message": _("Address in row {0} is missing {1}").format(row, ", ".join(missing)),
|
||||
}
|
||||
)
|
||||
return warnings
|
||||
|
||||
def import_row(self, importer, doc):
|
||||
"""Persist Customer per Import Type, then create linked Contacts/Addresses."""
|
||||
contact_rows = doc.pop("contacts", None) or []
|
||||
address_rows = doc.pop("addresses", None) or []
|
||||
has_child_rows = bool(contact_rows or address_rows)
|
||||
customer, import_action = self._persist_customer(importer, doc, has_child_rows)
|
||||
|
||||
self._create_contacts(customer, contact_rows)
|
||||
self._create_addresses(customer, address_rows)
|
||||
return customer, import_action
|
||||
|
||||
def _persist_customer(self, importer, doc, has_child_rows):
|
||||
"""Use the core Importer paths so provider imports honor Insert/Update/Upsert semantics."""
|
||||
if importer.import_type == INSERT:
|
||||
return importer.insert_record(doc), None
|
||||
|
||||
if importer.import_type == UPDATE:
|
||||
# Allow child-only updates (e.g. add Contact/Address) without failing on unchanged Customer fields.
|
||||
return importer.update_record(doc, raise_if_no_changes=not has_child_rows), None
|
||||
|
||||
return importer.upsert_record(doc)
|
||||
|
||||
def _create_contacts(self, customer, rows):
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
email = row.pop("email_id", None)
|
||||
mobile = row.pop("mobile_no", None)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_contact", 0))
|
||||
|
||||
first_name, last_name, company_name = self._resolve_contact_names(customer, row)
|
||||
|
||||
contact_values = {k: v for k, v in row.items() if v not in (None, "")}
|
||||
if first_name:
|
||||
contact_values["first_name"] = first_name
|
||||
if last_name:
|
||||
contact_values["last_name"] = last_name
|
||||
if company_name:
|
||||
contact_values["company_name"] = company_name
|
||||
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
**contact_values,
|
||||
"links": [{"link_doctype": "Customer", "link_name": customer.name}],
|
||||
}
|
||||
)
|
||||
if email:
|
||||
contact.add_email(email, is_primary=True)
|
||||
if mobile:
|
||||
contact.add_phone(mobile, is_primary_mobile_no=True)
|
||||
contact.insert()
|
||||
# First created contact is the default primary; an explicit flag overrides.
|
||||
if flagged or primary is None:
|
||||
primary = contact
|
||||
if primary:
|
||||
# Contact has no cross-contact auto-demotion (unlike Address's
|
||||
# validate_preferred_address), so explicitly demote any other primary Contact on
|
||||
# this party first — otherwise get_default_contact may return a Contact other
|
||||
# than customer_primary_contact.
|
||||
_demote_other_primary_contacts("Customer", customer.name, primary.name)
|
||||
frappe.db.set_value("Contact", primary.name, "is_primary_contact", 1)
|
||||
customer.db_set("customer_primary_contact", primary.name)
|
||||
customer.db_set("mobile_no", primary.mobile_no)
|
||||
customer.db_set("email_id", primary.email_id)
|
||||
|
||||
def _resolve_contact_names(self, customer, row):
|
||||
"""Resolve Contact names from row data and fall back to Customer data when needed."""
|
||||
first_name = row.pop("first_name", None)
|
||||
last_name = row.pop("last_name", None)
|
||||
company_name = row.pop("company_name", None)
|
||||
customer_get = getattr(customer, "get", None)
|
||||
|
||||
def get_customer_value(fieldname):
|
||||
if callable(customer_get):
|
||||
return customer_get(fieldname)
|
||||
return getattr(customer, fieldname, None)
|
||||
|
||||
if customer.customer_type == "Individual":
|
||||
first_name = first_name or get_customer_value("first_name")
|
||||
last_name = last_name or get_customer_value("last_name")
|
||||
if not first_name and customer.customer_name:
|
||||
parsed_first, _, parsed_last = parse_full_name(customer.customer_name)
|
||||
first_name = parsed_first
|
||||
last_name = last_name or parsed_last
|
||||
|
||||
return first_name, last_name, company_name
|
||||
|
||||
def _create_addresses(self, customer, rows):
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
|
||||
primary = None
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
flagged = frappe.utils.cint(row.pop("is_primary_address", 0))
|
||||
if not row.get("address_line1"):
|
||||
continue
|
||||
row["address_type"] = row.get("address_type") or "Billing"
|
||||
row["address_title"] = row.get("address_title") or customer.customer_name
|
||||
address = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Address",
|
||||
**{k: v for k, v in row.items() if v not in (None, "")},
|
||||
"links": [{"link_doctype": "Customer", "link_name": customer.name}],
|
||||
}
|
||||
)
|
||||
address.insert()
|
||||
# First created address is the default primary; an explicit flag overrides.
|
||||
# (Must not key off the loop index — skipped rows would leave no primary.)
|
||||
if flagged or primary is None:
|
||||
primary = address
|
||||
if primary:
|
||||
# Save (not db.set_value) so Address.validate_preferred_address() clears any
|
||||
# existing primary address on the party — a raw write would leave two flagged.
|
||||
primary.is_primary_address = 1
|
||||
primary.save()
|
||||
customer.db_set("customer_primary_address", primary.name)
|
||||
customer.db_set("primary_address", get_address_display(primary.name))
|
||||
|
||||
|
||||
def _demote_other_primary_contacts(link_doctype: str, link_name: str, keep: str) -> None:
|
||||
"""Clear ``is_primary_contact`` on the party's other Contacts (keeps ``keep``)."""
|
||||
linked = frappe.get_all(
|
||||
"Dynamic Link",
|
||||
filters={"link_doctype": link_doctype, "link_name": link_name, "parenttype": "Contact"},
|
||||
pluck="parent",
|
||||
)
|
||||
for other in frappe.get_all(
|
||||
"Contact", filters={"name": ["in", linked or [""]], "is_primary_contact": 1}, pluck="name"
|
||||
):
|
||||
if other != keep:
|
||||
frappe.db.set_value("Contact", other, "is_primary_contact", 0)
|
||||
|
||||
|
||||
def _doctype_docfields(doctype: str, prefer_plain_label: bool = False) -> list[dict]:
|
||||
"""Non-table importable fields of ``doctype`` as complete docfield dicts."""
|
||||
from frappe.model import display_fieldtypes, no_value_fields
|
||||
|
||||
fields = []
|
||||
for df in frappe.get_meta(doctype).fields:
|
||||
if df.fieldtype in no_value_fields or df.fieldtype in display_fieldtypes:
|
||||
continue
|
||||
if df.fieldname in ("lft", "rgt") or df.get("is_virtual"):
|
||||
continue
|
||||
field_dict = df.as_dict()
|
||||
if prefer_plain_label:
|
||||
field_dict["prefer_plain_label"] = 1
|
||||
fields.append(field_dict)
|
||||
return fields
|
||||
|
||||
|
||||
def _contact_docfields() -> list[dict]:
|
||||
"""Contact fields with plain-label and import-header aliases for common CSV headers."""
|
||||
fields = _doctype_docfields("Contact", prefer_plain_label=True)
|
||||
for field in fields:
|
||||
if field.get("fieldname") == "email_id":
|
||||
field["import_labels"] = ["Email ID"]
|
||||
return fields
|
||||
|
||||
|
||||
def _doctype_child_tables(doctype: str) -> list[dict]:
|
||||
"""``doctype``'s own child tables as schema groups (fieldname, label, fields)."""
|
||||
return [
|
||||
{
|
||||
"fieldname": tf.fieldname,
|
||||
"label": _(tf.label or tf.fieldname),
|
||||
"fields": _doctype_docfields(tf.options),
|
||||
}
|
||||
for tf in frappe.get_meta(doctype).get_table_fields()
|
||||
]
|
||||
@@ -38,6 +38,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -106,7 +107,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-21 18:11:30.134073",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Delivery Schedule Item",
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -729,7 +730,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 19:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation Item",
|
||||
|
||||
@@ -36,6 +36,7 @@ from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.get_item_details import get_bin_details
|
||||
from erpnext.stock.utils import InvalidWarehouseCompany
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -159,6 +160,38 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
)
|
||||
update_child_qty_rate("Sales Order", trans_item, so.name)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
|
||||
def test_sales_order_negative_grand_total_blocked_without_setting(self):
|
||||
so = make_sales_order(qty=1, rate=100, do_not_save=True)
|
||||
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
|
||||
self.assertRaises(frappe.ValidationError, so.save)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_sales_order_negative_grand_total_allowed_with_setting(self):
|
||||
"""Use a negative rate to represent a credit while order quantities remain positive."""
|
||||
so = make_sales_order(qty=1, rate=100, do_not_save=True)
|
||||
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
|
||||
so.save()
|
||||
so.submit()
|
||||
self.assertEqual(so.docstatus, 1)
|
||||
self.assertTrue(so.base_grand_total < 0)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
|
||||
def test_sales_order_negative_rate_error_links_to_selling_settings(self):
|
||||
so = make_sales_order(qty=1, rate=100, do_not_save=True)
|
||||
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -10})
|
||||
so.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
so.submit()
|
||||
|
||||
self.assertIn("selling-settings", str(error.exception))
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_sales_order_negative_rate_setting_does_not_allow_negative_quantity(self):
|
||||
so = make_sales_order(qty=-1, rate=100, do_not_save=True)
|
||||
self.assertRaises(frappe.NonNegativeError, so.save)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_sales_order_qty(self):
|
||||
so = make_sales_order(qty=1, do_not_save=True)
|
||||
@@ -587,6 +620,116 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
self.assertEqual(updated_total, prev_total + 1400)
|
||||
self.assertNotEqual(updated_total_in_words, prev_total_in_words)
|
||||
|
||||
def test_update_child_adding_new_item_with_warehouse(self):
|
||||
so = make_sales_order(item_code="_Test Item", qty=4)
|
||||
|
||||
first_item_of_so = so.get("items")[0]
|
||||
self.assertNotEqual(first_item_of_so.warehouse, "_Test Warehouse 2 - _TC")
|
||||
|
||||
def get_trans_item(warehouse):
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": first_item_of_so.item_code,
|
||||
"rate": first_item_of_so.rate,
|
||||
"qty": first_item_of_so.qty,
|
||||
"docname": first_item_of_so.name,
|
||||
"warehouse": warehouse,
|
||||
},
|
||||
{"item_code": "_Test Item 2", "rate": 200, "qty": 7, "warehouse": warehouse},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
InvalidWarehouseCompany,
|
||||
update_child_qty_rate,
|
||||
"Sales Order",
|
||||
get_trans_item("_Test Warehouse 2 - _TC1"),
|
||||
so.name,
|
||||
)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Group node warehouse",
|
||||
update_child_qty_rate,
|
||||
"Sales Order",
|
||||
get_trans_item("_Test Warehouse Group - _TC"),
|
||||
so.name,
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Warehouse", "_Test Disabled Warehouse - _TC"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Warehouse",
|
||||
"warehouse_name": "_Test Disabled Warehouse",
|
||||
"company": "_Test Company",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Disabled Warehouse",
|
||||
update_child_qty_rate,
|
||||
"Sales Order",
|
||||
get_trans_item("_Test Disabled Warehouse - _TC"),
|
||||
so.name,
|
||||
)
|
||||
|
||||
update_child_qty_rate("Sales Order", get_trans_item("_Test Warehouse 2 - _TC"), so.name)
|
||||
|
||||
so.reload()
|
||||
# the new row picks up the warehouse selected in the dialog
|
||||
self.assertEqual(so.get("items")[-1].item_code, "_Test Item 2")
|
||||
self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
|
||||
# existing rows keep theirs, so their reserved qty stays in the same bin
|
||||
self.assertEqual(so.get("items")[0].warehouse, first_item_of_so.warehouse)
|
||||
|
||||
def test_update_child_adding_new_item_without_any_default_warehouse(self):
|
||||
item_code = make_item("_Test Item Without Default Warehouse", {"is_stock_item": 1}).name
|
||||
so = make_sales_order(item_code="_Test Item", qty=4)
|
||||
existing_item = so.get("items")[0]
|
||||
|
||||
# a company gets a default warehouse when its warehouses are created
|
||||
company_default = frappe.db.get_value("Company", so.company, "default_warehouse")
|
||||
frappe.db.set_value("Company", so.company, "default_warehouse", None)
|
||||
self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default)
|
||||
|
||||
def get_trans_items(warehouse=None):
|
||||
new_row = {"item_code": item_code, "rate": 200, "qty": 7}
|
||||
if warehouse:
|
||||
new_row["warehouse"] = warehouse
|
||||
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": existing_item.item_code,
|
||||
"rate": existing_item.rate,
|
||||
"qty": existing_item.qty,
|
||||
"docname": existing_item.name,
|
||||
},
|
||||
new_row,
|
||||
]
|
||||
)
|
||||
|
||||
# no default in the Item Master, Item Group, Brand or Company
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot find a default warehouse",
|
||||
update_child_qty_rate,
|
||||
"Sales Order",
|
||||
get_trans_items(),
|
||||
so.name,
|
||||
)
|
||||
|
||||
update_child_qty_rate("Sales Order", get_trans_items("_Test Warehouse - _TC"), so.name)
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(len(so.get("items")), 2)
|
||||
self.assertEqual(so.get("items")[0].warehouse, existing_item.warehouse)
|
||||
self.assertEqual(so.get("items")[-1].item_code, item_code)
|
||||
self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse - _TC")
|
||||
|
||||
def test_update_child_removing_item(self):
|
||||
so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]})
|
||||
create_dn_against_so(so.name, 2)
|
||||
@@ -3104,6 +3247,17 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
so.save()
|
||||
self.assertEqual(sum(d.allocated_percentage for d in so.sales_team), 100)
|
||||
|
||||
with self.subTest("floating-point drift in the total is tolerated"):
|
||||
# 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point
|
||||
so = make_sales_order(do_not_save=True)
|
||||
for sales_person, percentage in (
|
||||
("_Test Sales Person", 10.0),
|
||||
("_Test Sales Person 1", 58.02),
|
||||
("_Test Sales Person 2", 31.98),
|
||||
):
|
||||
so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage})
|
||||
so.save()
|
||||
|
||||
def test_sales_team_disabled_sales_person_rejected(self):
|
||||
frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 0)
|
||||
try:
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -1055,7 +1056,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -982,7 +983,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note Item",
|
||||
|
||||
@@ -430,7 +430,7 @@ def notify_customers(delivery_trip: str):
|
||||
frappe.sendmail(
|
||||
recipients=contact_info.email_id,
|
||||
subject=dispatch_template.subject,
|
||||
message=frappe.render_template(dispatch_template.response, context),
|
||||
message=frappe.render_template(dispatch_template.response, context, restrict_globals=True),
|
||||
attachments=get_attachments(stop),
|
||||
)
|
||||
|
||||
|
||||
@@ -1508,7 +1508,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None):
|
||||
"UOM Conversion Factor", {"to_uom": from_uom, "from_uom": to_uom}, ["value"], as_dict=1
|
||||
)
|
||||
if inverse_match:
|
||||
return 1 / inverse_match.value
|
||||
return flt(1 / inverse_match.value, frappe.get_precision("UOM Conversion Factor", "value"))
|
||||
|
||||
# This attempts to try and get conversion from intermediate UOM.
|
||||
# case:
|
||||
@@ -1528,7 +1528,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None):
|
||||
)
|
||||
|
||||
if intermediate_match:
|
||||
return intermediate_match[0].value
|
||||
return flt(intermediate_match[0].value, frappe.get_precision("UOM Conversion Factor", "value"))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -288,51 +288,6 @@ def get_items_based_on_default_supplier(supplier: str):
|
||||
return supplier_items
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_purchase_order_based_on_supplier(
|
||||
source_name: str, target_doc: str | dict | Document | None = None, args: dict | None = None
|
||||
):
|
||||
mr = source_name
|
||||
|
||||
supplier_items = get_items_based_on_default_supplier(args.get("supplier"))
|
||||
|
||||
def postprocess(source, target_doc):
|
||||
target_doc.supplier = args.get("supplier")
|
||||
if getdate(target_doc.schedule_date) < getdate(nowdate()):
|
||||
target_doc.schedule_date = None
|
||||
target_doc.set(
|
||||
"items",
|
||||
[d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0],
|
||||
)
|
||||
|
||||
set_missing_values(source, target_doc)
|
||||
|
||||
target_doc = get_mapped_doc(
|
||||
"Material Request",
|
||||
mr,
|
||||
{
|
||||
"Material Request": {
|
||||
"doctype": "Purchase Order",
|
||||
},
|
||||
"Material Request Item": {
|
||||
"doctype": "Purchase Order Item",
|
||||
"field_map": [
|
||||
["name", "material_request_item"],
|
||||
["parent", "material_request"],
|
||||
["uom", "stock_uom"],
|
||||
["uom", "uom"],
|
||||
],
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.ordered_qty < doc.qty,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
postprocess,
|
||||
)
|
||||
|
||||
return target_doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_supplier_quotation(source_name: str, target_doc: str | dict | Document | None = None):
|
||||
def postprocess(source, target_doc):
|
||||
|
||||
@@ -315,7 +315,8 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Work Order",
|
||||
"options": "Work Order",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "terms_tab",
|
||||
@@ -376,7 +377,7 @@
|
||||
"idx": 70,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-30 11:04:31.517204",
|
||||
"modified": "2026-08-07 10:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Material Request",
|
||||
|
||||
@@ -273,6 +273,7 @@ class MaterialRequest(BuyingController):
|
||||
def on_submit(self):
|
||||
self.update_requested_qty_in_production_plan()
|
||||
self.update_requested_qty()
|
||||
self.update_requested_qty_in_work_order()
|
||||
if self.material_request_type == "Purchase":
|
||||
self.update_prevdoc_status()
|
||||
if frappe.db.exists("Budget", {"applicable_on_material_request": 1, "docstatus": 1}):
|
||||
@@ -283,6 +284,20 @@ class MaterialRequest(BuyingController):
|
||||
|
||||
def before_submit(self):
|
||||
self.set_status(update=True)
|
||||
self.validate_pending_qty_in_work_order()
|
||||
|
||||
def validate_pending_qty_in_work_order(self):
|
||||
if not self.work_order or self.material_request_type != "Material Transfer":
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
|
||||
incoming = {}
|
||||
for row in self.items:
|
||||
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.stock_qty)
|
||||
|
||||
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
|
||||
|
||||
def before_cancel(self):
|
||||
# if MRQ is already closed, no point saving the document
|
||||
@@ -301,6 +316,7 @@ class MaterialRequest(BuyingController):
|
||||
self.status_can_change(status)
|
||||
self.set_status(update=True, status=status)
|
||||
self.update_requested_qty()
|
||||
self.update_requested_qty_in_work_order()
|
||||
|
||||
def status_can_change(self, status):
|
||||
"""
|
||||
@@ -330,6 +346,7 @@ class MaterialRequest(BuyingController):
|
||||
def on_cancel(self):
|
||||
self.update_requested_qty_in_production_plan(cancel=True)
|
||||
self.update_requested_qty()
|
||||
self.update_requested_qty_in_work_order()
|
||||
if self.material_request_type == "Purchase":
|
||||
self.update_prevdoc_status()
|
||||
|
||||
@@ -417,6 +434,19 @@ class MaterialRequest(BuyingController):
|
||||
update_modified,
|
||||
)
|
||||
|
||||
self.update_requested_qty_in_work_order()
|
||||
|
||||
def update_requested_qty_in_work_order(self):
|
||||
"""Refresh both counters: stop and cancel also flip pick list coverage."""
|
||||
if not self.work_order or self.material_request_type != "Material Transfer":
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
service = RequiredItemsService(frappe.get_doc("Work Order", self.work_order))
|
||||
service.update_requested_qty_for_required_items()
|
||||
service.update_picked_qty_for_required_items()
|
||||
|
||||
def update_requested_qty(self, mr_item_rows=None):
|
||||
"""update requested qty (before ordered_qty is updated)"""
|
||||
item_wh_list = []
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -545,7 +546,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-06 20:47:27.317226",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Material Request Item",
|
||||
|
||||
@@ -243,7 +243,8 @@
|
||||
{
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor"
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9"
|
||||
},
|
||||
{
|
||||
"fieldname": "rate",
|
||||
@@ -349,7 +350,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packed Item",
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"fieldname": "work_order",
|
||||
"fieldtype": "Link",
|
||||
"label": "Work Order",
|
||||
"options": "Work Order"
|
||||
"options": "Work Order",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "locations",
|
||||
@@ -278,7 +279,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-01 14:27:50.617011",
|
||||
"modified": "2026-08-07 10:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List",
|
||||
|
||||
@@ -240,6 +240,45 @@ class PickList(TransactionBase):
|
||||
def before_submit(self):
|
||||
self.validate_sales_order()
|
||||
self.validate_picked_items()
|
||||
self.validate_pending_qty_in_work_order()
|
||||
|
||||
def validate_pending_qty_in_work_order(self):
|
||||
"""Rows covered by a live material request must stay within that request;
|
||||
every other row must fit the work order's pending requirement."""
|
||||
if not self.work_order:
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
work_order = frappe.get_doc("Work Order", self.work_order, for_update=True)
|
||||
live_requests = {}
|
||||
request_pending = {}
|
||||
incoming = {}
|
||||
|
||||
for row in self.locations:
|
||||
if row.material_request not in live_requests:
|
||||
live_requests[row.material_request] = is_live_material_request(row.material_request)
|
||||
|
||||
if not (row.material_request_item and live_requests[row.material_request]):
|
||||
incoming[row.item_code] = incoming.get(row.item_code, 0.0) + flt(row.picked_qty)
|
||||
continue
|
||||
|
||||
if row.material_request_item not in request_pending:
|
||||
stock_qty, ordered_qty = frappe.db.get_value(
|
||||
"Material Request Item", row.material_request_item, ["stock_qty", "ordered_qty"]
|
||||
)
|
||||
request_pending[row.material_request_item] = flt(stock_qty) - flt(ordered_qty)
|
||||
|
||||
if flt(row.picked_qty - request_pending[row.material_request_item], 6) > 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: picked qty {1} {2} exceeds the pending qty in Material Request {3}.").format(
|
||||
row.idx, row.picked_qty, row.stock_uom, row.material_request
|
||||
),
|
||||
title=_("Exceeds Requested Qty"),
|
||||
)
|
||||
request_pending[row.material_request_item] -= flt(row.picked_qty)
|
||||
|
||||
RequiredItemsService(work_order).validate_incoming_material_demand(incoming)
|
||||
|
||||
def validate_sales_order(self):
|
||||
"""Raises an exception if the `Sales Order` has reserved stock."""
|
||||
@@ -281,6 +320,7 @@ class PickList(TransactionBase):
|
||||
self.update_bundle_picked_qty()
|
||||
self.update_reference_qty()
|
||||
self.update_sales_order_picking_status()
|
||||
self.update_picked_qty_in_work_order()
|
||||
self.update_prevdoc_status()
|
||||
|
||||
def validate_expired_batches(self):
|
||||
@@ -358,6 +398,7 @@ class PickList(TransactionBase):
|
||||
self.update_bundle_picked_qty()
|
||||
self.update_reference_qty()
|
||||
self.update_sales_order_picking_status()
|
||||
self.update_picked_qty_in_work_order()
|
||||
self.delink_serial_and_batch_bundle()
|
||||
self.update_prevdoc_status()
|
||||
|
||||
@@ -494,6 +535,15 @@ class PickList(TransactionBase):
|
||||
for sales_order in sales_orders:
|
||||
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status()
|
||||
|
||||
def update_picked_qty_in_work_order(self):
|
||||
if not self.work_order:
|
||||
return
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
work_order = frappe.get_doc("Work Order", self.work_order)
|
||||
RequiredItemsService(work_order).update_picked_qty_for_required_items()
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_stock_reservation_entries(self, notify: bool = True) -> None:
|
||||
"""Creates Stock Reservation Entries for Sales Order Items against Pick List."""
|
||||
@@ -936,6 +986,15 @@ def update_pick_list_status(pick_list):
|
||||
if pick_list:
|
||||
doc = frappe.get_doc("Pick List", pick_list)
|
||||
doc.run_method("update_status")
|
||||
doc.update_picked_qty_in_work_order()
|
||||
|
||||
|
||||
def is_live_material_request(material_request):
|
||||
if not material_request:
|
||||
return False
|
||||
|
||||
docstatus, status = frappe.db.get_value("Material Request", material_request, ["docstatus", "status"])
|
||||
return docstatus == 1 and status != "Stopped"
|
||||
|
||||
|
||||
def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -307,7 +308,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List Item",
|
||||
|
||||
@@ -291,6 +291,7 @@
|
||||
"label": "Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"print_width": "100px",
|
||||
"reqd": 1,
|
||||
@@ -1144,7 +1145,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-16 15:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Purchase Receipt Item",
|
||||
|
||||
@@ -106,12 +106,13 @@
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"no_copy": 1,
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-07-08 09:19:26.711470",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Putaway Rule",
|
||||
|
||||
@@ -265,6 +265,9 @@ class QualityInspection(Document):
|
||||
self.modified,
|
||||
)
|
||||
|
||||
if self.reference_type and self.reference_name:
|
||||
frappe.get_lazy_doc(self.reference_type, self.reference_name).notify_update()
|
||||
|
||||
def inspect_and_set_status(self):
|
||||
for reading in self.readings:
|
||||
if not reading.manual_inspection: # dont auto set status if manual
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
@@ -78,6 +79,27 @@ class TestQualityInspection(ERPNextTestSuite):
|
||||
qa.delete()
|
||||
dn.delete()
|
||||
|
||||
def test_doc_update_published_for_reference_on_submit(self):
|
||||
"""Submitting a QI publishes doc_update so open reference forms resync their timestamp."""
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
qa = create_quality_inspection(
|
||||
reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True
|
||||
)
|
||||
|
||||
with patch.object(frappe, "publish_realtime") as publish_realtime:
|
||||
qa.submit()
|
||||
|
||||
reference_updates = [
|
||||
call
|
||||
for call in publish_realtime.call_args_list
|
||||
if call.args and call.args[0] == "doc_update" and call.kwargs.get("docname") == dn.name
|
||||
]
|
||||
self.assertEqual(len(reference_updates), 1)
|
||||
|
||||
message = reference_updates[0].args[1]
|
||||
self.assertEqual(message["doctype"], "Delivery Note")
|
||||
self.assertEqual(message["modified"], frappe.db.get_value("Delivery Note", dn.name, "modified"))
|
||||
|
||||
def test_value_based_qi_readings(self):
|
||||
# Test QI based on acceptance values (Non formula)
|
||||
dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
|
||||
|
||||
@@ -653,6 +653,55 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
|
||||
# incoming rate after reposting should be 150
|
||||
self.assertSLEs(se, [{"incoming_rate": 150}])
|
||||
|
||||
def test_repost_multi_line_moving_average_return(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
item = self.make_item(properties={"valuation_method": "Moving Average"}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
make_purchase_receipt(item_code=item, qty=100, rate=100, warehouse=warehouse)
|
||||
|
||||
pr = make_purchase_receipt(item_code=item, qty=400, rate=200, warehouse=warehouse, do_not_submit=1)
|
||||
for qty in (100, 300, 100):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"qty": qty,
|
||||
"received_qty": qty,
|
||||
"rate": 200,
|
||||
"uom": pr.items[0].uom,
|
||||
"conversion_factor": 1.0,
|
||||
},
|
||||
)
|
||||
pr.save()
|
||||
pr.submit()
|
||||
|
||||
return_pr = make_return_doc(pr.doctype, pr.name)
|
||||
return_pr.save()
|
||||
return_pr.submit()
|
||||
|
||||
expected_sles = [
|
||||
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 600.0},
|
||||
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 500.0},
|
||||
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 200.0},
|
||||
{"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 100.0},
|
||||
]
|
||||
|
||||
for _ in range(2):
|
||||
riv = frappe.get_doc(
|
||||
doctype="Repost Item Valuation",
|
||||
based_on="Transaction",
|
||||
voucher_type=pr.doctype,
|
||||
voucher_no=pr.name,
|
||||
posting_date=pr.posting_date,
|
||||
posting_time=pr.posting_time,
|
||||
)
|
||||
riv.submit()
|
||||
|
||||
self.assertSLEs(return_pr, expected_sles)
|
||||
|
||||
def test_remove_attached_file(self):
|
||||
item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1})
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
self.validate_batch()
|
||||
self.validate_inspection()
|
||||
self.validate_fg_completed_qty()
|
||||
self.validate_job_card_pending_production()
|
||||
self.validate_difference_account()
|
||||
self.validate_job_card_item()
|
||||
self.set_purpose_for_stock_entry()
|
||||
@@ -1452,23 +1453,15 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
return
|
||||
|
||||
precision = self.precision("process_loss_qty")
|
||||
if self.work_order:
|
||||
data = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
filters={"parent": self.work_order},
|
||||
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
|
||||
process_loss_qty = self.get_pending_process_loss_qty()
|
||||
if process_loss_qty and flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
|
||||
self.process_loss_qty = flt(process_loss_qty, precision)
|
||||
|
||||
frappe.msgprint(
|
||||
_("The Process Loss Qty has been reset as per the job card's Process Loss Qty"),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
if data and data[0].process_loss_qty:
|
||||
process_loss_qty = data[0].process_loss_qty
|
||||
if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
|
||||
self.process_loss_qty = flt(process_loss_qty, precision)
|
||||
|
||||
frappe.msgprint(
|
||||
_("The Process Loss Qty has been reset as per the job card's Process Loss Qty"),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
if not self.process_loss_percentage and not self.process_loss_qty:
|
||||
self.process_loss_percentage = frappe.get_cached_value(
|
||||
"BOM", self.bom_no, "process_loss_percentage"
|
||||
@@ -1483,6 +1476,60 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
(flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100
|
||||
)
|
||||
|
||||
def validate_job_card_pending_production(self):
|
||||
"""A draft created before other entries were submitted must not book more than the job
|
||||
card still has left; without this, a stale draft over-produces the finished good."""
|
||||
if self.purpose != "Manufacture" or not self.job_card:
|
||||
return
|
||||
|
||||
if self._action == "update_after_submit":
|
||||
return
|
||||
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
if job_card.is_corrective_job_card or job_card.is_subcontracted:
|
||||
return
|
||||
|
||||
precision = frappe.get_precision("Stock Entry Detail", "qty")
|
||||
pending_qty = flt(
|
||||
flt(job_card.get_qty_to_produce())
|
||||
- flt(job_card.manufactured_qty)
|
||||
- flt(job_card.get_consumed_process_loss()),
|
||||
precision,
|
||||
)
|
||||
finished_qty = flt(sum(flt(d.transfer_qty) for d in self.items if d.is_finished_item), precision)
|
||||
entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision)
|
||||
|
||||
if entry_qty > pending_qty:
|
||||
uom = job_card.stock_uom
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Job Card {0} has only {1} left to produce, but this entry books {2} ({3} finished goods and {4} process loss). Cancel or update its other manufacture entries first."
|
||||
).format(
|
||||
frappe.bold(self.job_card),
|
||||
frappe.bold(f"{pending_qty} {uom}"),
|
||||
frappe.bold(f"{entry_qty} {uom}"),
|
||||
f"{finished_qty} {uom}",
|
||||
f"{flt(self.process_loss_qty, precision)} {uom}",
|
||||
)
|
||||
)
|
||||
|
||||
def get_pending_process_loss_qty(self):
|
||||
"""Loss this entry should still book: the job card's unbooked loss when the entry
|
||||
belongs to one, else the largest operation loss on the work order (legacy flow)."""
|
||||
if self.job_card:
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0)
|
||||
|
||||
if self.work_order:
|
||||
data = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
filters={"parent": self.work_order},
|
||||
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
|
||||
)
|
||||
return flt(data[0].process_loss_qty) if data else 0
|
||||
|
||||
return 0
|
||||
|
||||
def set_work_order_details(self):
|
||||
if self.work_order:
|
||||
# common validations
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
"label": "Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -700,7 +701,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
|
||||
@@ -126,6 +126,7 @@ class ManufactureEntry:
|
||||
if backflush_based_on != "BOM":
|
||||
available_serial_batches = self.get_transferred_serial_batches()
|
||||
|
||||
production_share = self.get_production_share()
|
||||
for item_code, _dict in item_dict.items():
|
||||
_dict.s_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
|
||||
_dict.t_warehouse = ""
|
||||
@@ -140,11 +141,29 @@ class ManufactureEntry:
|
||||
|
||||
_dict.qty = calculated_qty
|
||||
self.update_available_serial_batches(_dict, available_serial_batches)
|
||||
elif self.skip_material_transfer:
|
||||
set_previous_operation_serial_batch(self.stock_entry, _dict)
|
||||
else:
|
||||
remaining_qty = max(flt(_dict.qty) - flt(_dict.consumed_qty), 0)
|
||||
_dict.qty = min(flt(_dict.qty) * production_share, remaining_qty)
|
||||
if not _dict.qty:
|
||||
continue
|
||||
|
||||
if self.skip_material_transfer:
|
||||
set_previous_operation_serial_batch(self.stock_entry, _dict)
|
||||
|
||||
self.stock_entry.append("items", _dict)
|
||||
|
||||
def get_production_share(self):
|
||||
"""Fraction of the job card's production this entry accounts for; raw materials are
|
||||
generated proportionally so several partial entries never consume more than required."""
|
||||
for_quantity, pending_qty = frappe.db.get_value(
|
||||
"Job Card", self.job_card, ["for_quantity", "pending_qty"]
|
||||
)
|
||||
qty_to_produce = flt(for_quantity) - flt(pending_qty)
|
||||
if not qty_to_produce:
|
||||
return 1
|
||||
|
||||
return min(flt(self.for_quantity) / qty_to_produce, 1)
|
||||
|
||||
def parse_available_serial_batches(self, item_dict, available_serial_batches):
|
||||
key = (item_dict.item_code, item_dict.from_warehouse)
|
||||
if key not in available_serial_batches:
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"label": "Conversion Factor",
|
||||
"non_negative": 1,
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Float"
|
||||
"oldfieldtype": "Float",
|
||||
"precision": "9"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_nmeg",
|
||||
@@ -38,7 +39,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-11 23:02:54.800673",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "UOM Conversion Detail",
|
||||
|
||||
@@ -7,6 +7,8 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form, parse_json
|
||||
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
SLE_FIELDS = (
|
||||
"name",
|
||||
"posting_date",
|
||||
@@ -53,6 +55,9 @@ def add_invariant_check_fields(sles, filters):
|
||||
balance_qty = 0.0
|
||||
balance_stock_value = 0.0
|
||||
|
||||
company = frappe.get_cached_value("Warehouse", filters.warehouse, "company")
|
||||
valuation_method = get_valuation_method(filters.item_code, company)
|
||||
|
||||
incorrect_idx = None
|
||||
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
|
||||
currency_precision = (
|
||||
@@ -90,7 +95,7 @@ def add_invariant_check_fields(sles, filters):
|
||||
)
|
||||
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
|
||||
|
||||
if maintains_fifo_queue(sle):
|
||||
if maintains_fifo_queue(sle, valuation_method):
|
||||
add_fifo_fields(sle, sles[idx - 1] if idx else None)
|
||||
|
||||
if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
|
||||
@@ -104,8 +109,10 @@ def add_invariant_check_fields(sles, filters):
|
||||
return sles
|
||||
|
||||
|
||||
def maintains_fifo_queue(sle):
|
||||
# no queue is maintained for serialized/batchwise-valued stock
|
||||
def maintains_fifo_queue(sle, valuation_method):
|
||||
if valuation_method == "Moving Average":
|
||||
return False
|
||||
|
||||
return not (
|
||||
sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
|
||||
)
|
||||
@@ -138,6 +145,8 @@ def is_sle_has_correct_data(sle, float_precision, currency_precision):
|
||||
return (
|
||||
flt(sle.difference_in_qty, float_precision) == 0.0
|
||||
and flt(sle.diff_value_diff, currency_precision) == 0.0
|
||||
and flt(sle.fifo_qty_diff, float_precision) == 0.0
|
||||
and flt(sle.fifo_value_diff, currency_precision) == 0.0
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
@@ -59,6 +61,34 @@ class TestStockLedgerInvariantCheck(ERPNextTestSuite):
|
||||
self.assertEqual(len(data), 2) # incorrect entry + one before it for context
|
||||
self.assertEqual(data[-1].name, sle.name)
|
||||
|
||||
def test_show_incorrect_entries_catches_queue_mismatch(self):
|
||||
item = self.make_movements()
|
||||
|
||||
sle = frappe.get_last_doc(
|
||||
"Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
|
||||
)
|
||||
tampered_queue = json.dumps([[sle.qty_after_transaction + 5, 100]])
|
||||
frappe.db.set_value("Stock Ledger Entry", sle.name, "stock_queue", tampered_queue)
|
||||
|
||||
data = self.run_report(item_code=item, show_incorrect_entries=1)
|
||||
self.assertEqual(len(data), 2)
|
||||
self.assertEqual(data[-1].name, sle.name)
|
||||
|
||||
def test_moving_average_item_skips_fifo_queue_checks(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item(properties={"valuation_method": "Moving Average"}).name
|
||||
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
|
||||
make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4)
|
||||
|
||||
data = self.run_report(item_code=item)
|
||||
self.assertTrue(data)
|
||||
for row in data:
|
||||
self.assertIsNone(row.fifo_qty_diff)
|
||||
self.assertIsNone(row.fifo_value_diff)
|
||||
|
||||
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
|
||||
|
||||
def test_batch_item_skips_fifo_queue_checks(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Stock Qty vs Serial No Count"] = {
|
||||
onload: function (report) {
|
||||
report.page.add_inner_button(__("Sync Serial No Status"), () => {
|
||||
const warehouse = report.get_filter_value("warehouse");
|
||||
if (!warehouse) {
|
||||
frappe.msgprint(__("Please select a warehouse first."));
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This will update the warehouse and status of Serial Nos counted in {0} to match the stock ledger. Continue?",
|
||||
[warehouse.bold()]
|
||||
),
|
||||
() => {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count.sync_serial_no_status",
|
||||
args: { warehouse: warehouse },
|
||||
freeze: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
filters: [
|
||||
{
|
||||
fieldname: "company",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Order
|
||||
from frappe.query_builder.functions import Coalesce
|
||||
from frappe.utils import cstr, flt
|
||||
from pypika import analytics as an
|
||||
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_no_status
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -77,3 +83,172 @@ def get_data(warehouse, show_disabled_items):
|
||||
data.append(row)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
SYNC_CHUNK_SIZE = 1000
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def sync_serial_no_status(warehouse: str, item_code: str | None = None):
|
||||
if not frappe.has_permission("Serial No", "write"):
|
||||
frappe.throw(_("Not permitted to update Serial No"), frappe.PermissionError)
|
||||
|
||||
warehouse = cstr(warehouse)
|
||||
item_code = cstr(item_code) if item_code else None
|
||||
if not frappe.db.exists("Warehouse", warehouse):
|
||||
frappe.throw(_("Warehouse {0} does not exist").format(warehouse))
|
||||
|
||||
if item_code and not frappe.db.exists("Item", item_code):
|
||||
frappe.throw(_("Item {0} does not exist").format(item_code))
|
||||
|
||||
frappe.enqueue(
|
||||
sync_serial_no_status_for_warehouse,
|
||||
queue="long",
|
||||
warehouse=warehouse,
|
||||
item_code=item_code,
|
||||
)
|
||||
frappe.msgprint(
|
||||
_("Serial No status sync has been queued. Reload the report after a few minutes."),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
|
||||
def sync_serial_no_status_for_warehouse(warehouse, item_code=None):
|
||||
filters = {"has_serial_no": 1}
|
||||
if item_code:
|
||||
filters["name"] = item_code
|
||||
|
||||
for item in frappe.get_all("Item", filters=filters, pluck="name"):
|
||||
sync_serial_no_status_for_item(item, warehouse)
|
||||
|
||||
|
||||
def sync_serial_no_status_for_item(item_code, warehouse):
|
||||
"""Correct Serial No records this report counts in the warehouse but whose last
|
||||
stock ledger movement says the stock left it. Reposting rebuilds qty and valuation
|
||||
from the ledger but never rewrites Serial No warehouse/status, so records orphaned
|
||||
by cancelled or amended vouchers keep inflating the serial count."""
|
||||
serial_nos = frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"item_code": item_code, "warehouse": warehouse, "status": ("in", ["Active", "Expired"])},
|
||||
pluck="name",
|
||||
)
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
last_moves = get_last_ledger_moves(item_code, serial_nos)
|
||||
for serial_no in serial_nos:
|
||||
row = last_moves.get(serial_no)
|
||||
if row and flt(row.qty) > 0 and row.warehouse == warehouse:
|
||||
continue
|
||||
|
||||
set_serial_no_state_from_ledger(serial_no, row)
|
||||
|
||||
|
||||
def set_serial_no_state_from_ledger(serial_no, row):
|
||||
if not row:
|
||||
frappe.db.set_value(
|
||||
"Serial No", serial_no, {"warehouse": None, "status": "Inactive"}, update_modified=False
|
||||
)
|
||||
return
|
||||
|
||||
status = get_serial_no_status(
|
||||
frappe._dict(
|
||||
actual_qty=flt(row.qty),
|
||||
warehouse=row.warehouse,
|
||||
voucher_type=row.voucher_type,
|
||||
voucher_no=row.voucher_no,
|
||||
is_cancelled=0,
|
||||
)
|
||||
)
|
||||
warehouse = row.warehouse if status == "Active" else None
|
||||
frappe.db.set_value(
|
||||
"Serial No", serial_no, {"warehouse": warehouse, "status": status}, update_modified=False
|
||||
)
|
||||
|
||||
|
||||
def get_last_ledger_moves(item_code, serial_nos):
|
||||
last_moves = get_last_bundle_moves(item_code, serial_nos)
|
||||
if missing := [serial_no for serial_no in serial_nos if serial_no not in last_moves]:
|
||||
set_legacy_last_moves(item_code, missing, last_moves)
|
||||
|
||||
return last_moves
|
||||
|
||||
|
||||
def get_last_bundle_moves(item_code, serial_nos):
|
||||
last_moves = {}
|
||||
for start in range(0, len(serial_nos), SYNC_CHUNK_SIZE):
|
||||
for row in get_last_bundle_moves_chunk(item_code, serial_nos[start : start + SYNC_CHUNK_SIZE]):
|
||||
last_moves[row.serial_no] = row
|
||||
|
||||
return last_moves
|
||||
|
||||
|
||||
def get_last_bundle_moves_chunk(item_code, serial_nos):
|
||||
"""A bundle can be created much before its Stock Ledger Entry, so same-posting-datetime
|
||||
ties are broken on the creation of the bundle's own SLE. The SLE join also keeps only
|
||||
real stock movements - reservation bundles (Pick List) carry no SLE."""
|
||||
entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
bundle = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
row_number = (
|
||||
an.RowNumber()
|
||||
.over(entry.serial_no)
|
||||
.orderby(Coalesce(entry.posting_datetime, bundle.posting_datetime), order=Order.desc)
|
||||
.orderby(sle.creation, order=Order.desc)
|
||||
)
|
||||
|
||||
ranked = (
|
||||
frappe.qb.from_(entry)
|
||||
.inner_join(bundle)
|
||||
.on(entry.parent == bundle.name)
|
||||
.inner_join(sle)
|
||||
.on(sle.serial_and_batch_bundle == bundle.name)
|
||||
.select(
|
||||
entry.serial_no,
|
||||
entry.qty,
|
||||
Coalesce(entry.warehouse, bundle.warehouse).as_("warehouse"),
|
||||
bundle.voucher_type,
|
||||
bundle.voucher_no,
|
||||
row_number.as_("row_no"),
|
||||
)
|
||||
.where(
|
||||
(bundle.docstatus == 1)
|
||||
& (Coalesce(bundle.is_cancelled, 0) == 0)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (bundle.item_code == item_code)
|
||||
& (entry.serial_no.isin(serial_nos))
|
||||
)
|
||||
).as_("ranked")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(ranked)
|
||||
.select(ranked.serial_no, ranked.qty, ranked.warehouse, ranked.voucher_type, ranked.voucher_no)
|
||||
.where(ranked.row_no == 1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
|
||||
def set_legacy_last_moves(item_code, serial_nos, last_moves):
|
||||
"""Movements posted before Serial and Batch Bundle exist only as newline-separated
|
||||
text on Stock Ledger Entry."""
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
pending = set(serial_nos)
|
||||
rows = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"item_code": item_code, "is_cancelled": 0, "serial_no": ("is", "set")},
|
||||
fields=["serial_no", "actual_qty", "warehouse", "voucher_type", "voucher_no"],
|
||||
order_by="posting_datetime asc, creation asc",
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
qty = 1 if flt(row.actual_qty) > 0 else -1
|
||||
for serial_no in get_serial_nos(row.serial_no):
|
||||
if serial_no in pending:
|
||||
last_moves[serial_no] = frappe._dict(
|
||||
qty=qty,
|
||||
warehouse=row.warehouse,
|
||||
voucher_type=row.voucher_type,
|
||||
voucher_no=row.voucher_no,
|
||||
)
|
||||
|
||||
@@ -46,3 +46,35 @@ class TestStockQtyVsSerialNoCount(ERPNextTestSuite):
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_sync_serial_no_status(self):
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count import (
|
||||
sync_serial_no_status_for_warehouse,
|
||||
)
|
||||
|
||||
item = "_Test Serialized Item With Series"
|
||||
warehouse = "Stores - _TC"
|
||||
se = make_stock_entry(item_code=item, to_warehouse=warehouse, qty=2, rate=100)
|
||||
serial_no = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
{"parent": se.items[0].serial_and_batch_bundle},
|
||||
pluck="serial_no",
|
||||
)[0]
|
||||
|
||||
create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered")
|
||||
|
||||
frappe.db.set_value("Serial No", serial_no, {"status": "Active", "warehouse": warehouse})
|
||||
|
||||
sync_serial_no_status_for_warehouse(warehouse, item_code=item)
|
||||
|
||||
details = frappe.db.get_value("Serial No", serial_no, ["status", "warehouse"], as_dict=True)
|
||||
self.assertEqual(details.status, "Delivered")
|
||||
self.assertFalse(details.warehouse)
|
||||
|
||||
@@ -38,7 +38,6 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
|
||||
from erpnext.stock.utils import (
|
||||
get_combine_datetime,
|
||||
get_incoming_outgoing_rate_for_cancel,
|
||||
get_incoming_rate,
|
||||
get_or_make_bin,
|
||||
get_serial_nos_data,
|
||||
get_stock_balance,
|
||||
@@ -1460,23 +1459,7 @@ class update_entries_after:
|
||||
and not sle.get("batch_no")
|
||||
and not sle.get("serial_and_batch_bundle")
|
||||
):
|
||||
rate = get_incoming_rate(
|
||||
{
|
||||
"item_code": sle.item_code,
|
||||
"warehouse": sle.warehouse,
|
||||
"posting_date": sle.posting_date,
|
||||
"posting_time": sle.posting_time,
|
||||
"qty": sle.actual_qty,
|
||||
"serial_no": sle.get("serial_no"),
|
||||
"batch_no": sle.get("batch_no"),
|
||||
"serial_and_batch_bundle": sle.get("serial_and_batch_bundle"),
|
||||
"company": sle.company,
|
||||
"voucher_type": sle.voucher_type,
|
||||
"voucher_no": sle.voucher_no,
|
||||
"allow_zero_valuation": self.allow_zero_rate,
|
||||
"sle": sle.name,
|
||||
}
|
||||
)
|
||||
rate = self.get_moving_average_rate_for_return(sle)
|
||||
|
||||
if not rate and sle.voucher_type in ["Delivery Note", "Sales Invoice"]:
|
||||
rate = get_rate_for_return(
|
||||
@@ -1544,6 +1527,38 @@ class update_entries_after:
|
||||
|
||||
return rate
|
||||
|
||||
def get_moving_average_rate_for_return(self, sle):
|
||||
"""Rate just before this entry, taken from the in-memory running state so a
|
||||
multi-line return never reads a sibling row of its own voucher."""
|
||||
rate = flt(self.wh_data.valuation_rate)
|
||||
if rate:
|
||||
return rate
|
||||
|
||||
previous_sle = get_previous_sle_of_current_voucher(
|
||||
frappe._dict(
|
||||
item_code=sle.item_code,
|
||||
warehouse=sle.warehouse,
|
||||
posting_date=sle.posting_date,
|
||||
posting_time=sle.posting_time,
|
||||
voucher_no=sle.voucher_no,
|
||||
),
|
||||
exclude_current_voucher=True,
|
||||
)
|
||||
|
||||
rate = previous_sle.get("valuation_rate")
|
||||
if rate is None:
|
||||
rate = get_valuation_rate(
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
sle.voucher_type,
|
||||
sle.voucher_no,
|
||||
self.allow_zero_rate,
|
||||
currency=erpnext.get_company_currency(sle.company),
|
||||
company=sle.company,
|
||||
)
|
||||
|
||||
return flt(rate)
|
||||
|
||||
def update_outgoing_rate_on_transaction(self, sle):
|
||||
"""
|
||||
Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -128,7 +129,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:45.904619",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting BOM",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -186,7 +187,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-18 18:04:04.204651",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Inward Order Item",
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -425,7 +426,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-27 23:03:36.436504",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Order Item",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -176,7 +177,7 @@
|
||||
"hide_toolbar": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-30 16:00:43.379828",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Order Supplied Item",
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -657,7 +658,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Receipt Item",
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -275,7 +276,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Receipt Supplied Item",
|
||||
|
||||
Reference in New Issue
Block a user