diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 99ac592097b..72f80b62dd1 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -7,6 +7,7 @@ from frappe import _ from frappe.utils import flt, getdate from pypika import Tuple +from erpnext.accounts.report.utils import validate_mandatory_date_range from erpnext.accounts.utils import get_currency_precision @@ -33,9 +34,7 @@ def execute(filters=None): def validate_filters(filters): """Validate if dates are properly set""" - filters = frappe._dict(filters or {}) - if filters.from_date > filters.to_date: - frappe.throw(_("From Date must be before To Date")) + validate_mandatory_date_range(filters or {}) def get_result(filters, tds_accounts, tax_category_map, net_total_map): diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index a1b9c22f63f..5e7c5d46a98 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -5,6 +5,7 @@ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details imp get_result, get_tds_docs, ) +from erpnext.accounts.report.utils import validate_mandatory_date_range from erpnext.accounts.utils import get_fiscal_year @@ -33,8 +34,7 @@ def execute(filters=None): def validate_filters(filters): """Validate if dates are properly set and lie in the same fiscal year""" - if filters.from_date > filters.to_date: - frappe.throw(_("From Date must be before To Date")) + validate_mandatory_date_range(filters) from_year = get_fiscal_year(filters.from_date)[0] to_year = get_fiscal_year(filters.to_date)[0] diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 8d1730ab294..3661e787f41 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -1,4 +1,5 @@ import frappe +from frappe import _ from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Sum from frappe.utils import flt, formatdate, get_datetime_str, get_table_name @@ -16,6 +17,19 @@ from erpnext.setup.utils import get_exchange_rate __exchange_rates = {} +def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"): + from_date = filters.get(from_field) + to_date = filters.get(to_field) + + if not from_date or not to_date: + frappe.throw( + _("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date"))) + ) + + if from_date > to_date: + frappe.throw(_("From Date must be before To Date")) + + def get_currency(filters): """ Returns a dictionary containing currency information. The keys of the dict are diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index d996ff9b2ac..8e216644857 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -238,7 +238,7 @@ def get_invoice_summary(items, taxes): # Preflight for successful e-invoice export. def sales_invoice_validate(doc): # Validate company - if doc.doctype != "Sales Invoice": + if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes": return if not doc.company_address: @@ -322,7 +322,7 @@ def sales_invoice_validate(doc): # Ensure payment details are valid for e-invoice. def sales_invoice_on_submit(doc, method): # Validate payment details - if get_company_country(doc.company) not in [ + if doc.is_opening == "Yes" or get_company_country(doc.company) not in [ "Italy", "Italia", "Italian Republic", @@ -388,7 +388,7 @@ def generate_single_invoice(docname): # Delete e-invoice attachment on cancel. def sales_invoice_on_cancel(doc, method): - if get_company_country(doc.company) not in [ + if doc.is_opening == "Yes" or get_company_country(doc.company) not in [ "Italy", "Italia", "Italian Republic", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d601dc093b8..f93c41bacc5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1321,6 +1321,7 @@ class StockEntry(StockController): # Set rate for outgoing items outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate) finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) + has_consumption_basis = self.has_consumption_basis() items = [] # Set basic rate for incoming items @@ -1328,6 +1329,8 @@ class StockEntry(StockController): if d.s_warehouse or d.set_basic_rate_manually: continue + rate_derived_from_consumption = False + if d.allow_zero_valuation_rate: d.basic_rate = 0.0 items.append(d.item_code) @@ -1335,12 +1338,17 @@ class StockEntry(StockController): elif d.is_finished_item: if self.purpose == "Manufacture": d.basic_rate = self.get_basic_rate_for_manufactured_item( - finished_item_qty, outgoing_items_cost + finished_item_qty, outgoing_items_cost, has_consumption_basis ) + rate_derived_from_consumption = has_consumption_basis elif self.purpose == "Repack": d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) + # Repack rate comes from consumed source-warehouse rows, not consumption entries + rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items")) - if not d.basic_rate and not d.allow_zero_valuation_rate: + # A rate of zero derived from the consumed items is their actual cost, not a missing + # rate. Falling back to the item's valuation here would value free inputs as output. + if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption: if self.is_new(): raise_error_if_no_rate = False @@ -1375,6 +1383,31 @@ class StockEntry(StockController): frappe.msgprint(message, alert=True) + def has_consumption_basis(self) -> bool: + """Whether the cost of the consumed items is known, even when that cost is zero.""" + if any(d.s_warehouse for d in self.get("items")): + return True + + settings = frappe.get_single("Manufacturing Settings") + if settings.material_consumption and settings.get_rm_cost_from_consumption_entry and self.work_order: + return bool(self.get_consumption_entries()) + + return False + + def get_consumption_entries(self) -> list[str]: + # Cached: queried in both has_consumption_basis() and get_basic_rate_for_manufactured_item() + if getattr(self, "_consumption_entries", None) is None: + self._consumption_entries = frappe.get_all( + "Stock Entry", + filters={ + "docstatus": 1, + "work_order": self.work_order, + "purpose": "Material Consumption for Manufacture", + }, + pluck="name", + ) + return self._consumption_entries + def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): outgoing_items_cost = 0.0 for d in self.get("items"): @@ -1428,21 +1461,16 @@ class StockEntry(StockController): ) return flt(outgoing_items_cost / total_fg_qty) - def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float: + def get_basic_rate_for_manufactured_item( + self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False + ) -> float: settings = frappe.get_single("Manufacturing Settings") scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item]) if settings.material_consumption: if settings.get_rm_cost_from_consumption_entry and self.work_order: # Validate only if Material Consumption Entry exists for the Work Order. - if frappe.db.exists( - "Stock Entry", - { - "docstatus": 1, - "work_order": self.work_order, - "purpose": "Material Consumption for Manufacture", - }, - ): + if self.get_consumption_entries(): for item in self.items: if not item.is_finished_item and not item.is_scrap_item: label = frappe.get_meta(settings.doctype).get_label( @@ -1489,7 +1517,9 @@ class StockEntry(StockController): ) ).run()[0][0] or 0 - elif not outgoing_items_cost: + # Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a + # real cost, so substituting BOM rates would value free inputs as output. + elif not outgoing_items_cost and not has_consumption_basis: bom_items = self.get_bom_raw_materials(finished_item_qty) outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()]) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index b344b772cbb..bcaa90e104f 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2574,6 +2574,149 @@ class TestStockEntry(FrappeTestCase): material_request.reload() self.assertEqual(material_request.transfer_status, "Completed") + def test_manufacture_with_zero_valued_raw_material(self): + # A finished good produced from free inputs is worth nothing. Falling back to the item's + # own valuation would create value out of nothing and inflate it on every production run. + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + fg_warehouse = "Finished Goods - _TC" + + rm_receipt = make_stock_entry(item_code=rm_item, target=warehouse, qty=100, rate=0, do_not_save=True) + rm_receipt.items[0].allow_zero_valuation_rate = 1 + rm_receipt.save() + rm_receipt.submit() + + # the finished good already carries a valuation in the target warehouse + make_stock_entry(item_code=fg_item, target=fg_warehouse, qty=10, rate=100) + + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Manufacture" + se.company = "_Test Company" + se.append( + "items", + {"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1}, + ) + se.append( + "items", + { + "item_code": fg_item, + "t_warehouse": fg_warehouse, + "qty": 10, + "is_finished_item": 1, + "conversion_factor": 1, + }, + ) + se.save() + + self.assertEqual(se.items[0].basic_amount, 0) + self.assertEqual(se.items[1].basic_rate, 0) + self.assertEqual(se.items[1].basic_amount, 0) + + se.submit() + + fg_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": se.name, "item_code": fg_item, "is_cancelled": 0}, + ["incoming_rate", "stock_value_difference"], + as_dict=True, + ) + + self.assertEqual(fg_sle.incoming_rate, 0) + self.assertEqual(fg_sle.stock_value_difference, 0) + + def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no): + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_from_wo, + ) + + receipt = make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, rate=0, do_not_save=True) + receipt.items[0].allow_zero_valuation_rate = 1 + receipt.save() + receipt.submit() + + wo = make_wo_order_test_record(production_item=fg_item, bom_no=bom_no, qty=10) + + transfer = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Material Transfer for Manufacture", 10)) + transfer.items[0].s_warehouse = "Stores - _TC" + transfer.insert().submit() + + return wo + + @change_settings( + "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 0} + ) + def test_manufacture_does_not_fall_back_to_bom_cost_for_free_raw_material(self): + # The BOM is only an estimate for when nothing was consumed. Items that were consumed and + # cost nothing are a real cost, so a BOM rate must not stand in for them. + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_from_wo, + ) + + rm_item = make_item(properties={"is_stock_item": 1}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + + frappe.get_doc( + { + "doctype": "Item Price", + "item_code": rm_item, + "price_list": "_Test Price List India", + "price_list_rate": 150, + "buying": 1, + } + ).insert() + + # price the BOM off the price list so that it carries a rate the free stock does not + bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_save=True) + bom.rm_cost_as_per = "Price List" + bom.buying_price_list = "_Test Price List India" + bom.currency = "INR" + bom.save() + bom.submit() + + wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom.name) + + manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10)) + manufacture.save() + + fg_row = next(d for d in manufacture.items if d.is_finished_item) + self.assertEqual(fg_row.basic_rate, 0) + self.assertEqual(fg_row.basic_amount, 0) + + @change_settings( + "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} + ) + def test_manufacture_with_zero_valued_consumption_entry(self): + # The raw material is consumed by a separate entry, so the Manufacture entry carries no + # consumed rows of its own. Its cost is still known, and it is zero. + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_from_wo, + ) + + rm_item = make_item(properties={"is_stock_item": 1}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + + # the finished good already carries a valuation in the work order's target warehouse + make_stock_entry(item_code=fg_item, target="_Test Warehouse 1 - _TC", qty=10, rate=100) + + bom = make_bom(item=fg_item, raw_materials=[rm_item]).name + wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom) + + consumption = frappe.get_doc( + make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10) + ) + consumption.insert().submit() + + manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10)) + manufacture.save() + + fg_row = next(d for d in manufacture.items if d.is_finished_item) + self.assertEqual(fg_row.basic_rate, 0) + self.assertEqual(fg_row.basic_amount, 0) + def test_disassemble_entry_without_wo(self): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index e5cb69ff816..a50061de1e3 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -8,6 +8,7 @@ from frappe.utils import add_to_date, cint, flt, get_datetime, get_table_name, g from frappe.utils.deprecations import deprecated from pypika import functions as fn +from erpnext.accounts.report.utils import validate_mandatory_date_range from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter SLE_COUNT_LIMIT = 100_000 @@ -29,8 +30,7 @@ def execute(filters=None): _("Please select either the Item or Warehouse or Warehouse Type filter to generate the report.") ) - if filters.from_date > filters.to_date: - frappe.throw(_("From Date must be before To Date")) + validate_mandatory_date_range(filters) float_precision = cint(frappe.db.get_default("float_precision")) or 3 diff --git a/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py b/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py index 000aca9f43e..afae69c6ce0 100644 --- a/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py +++ b/erpnext/stock/report/cogs_by_item_group/cogs_by_item_group.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.utils import date_diff from erpnext.accounts.report.general_ledger.general_ledger import get_gl_entries +from erpnext.accounts.report.utils import validate_mandatory_date_range Filters = frappe._dict Row = frappe._dict @@ -34,8 +35,7 @@ def update_filters_with_account(filters: Filters) -> None: def validate_filters(filters: Filters) -> None: - if filters.from_date > filters.to_date: - frappe.throw(_("From Date must be before To Date")) + validate_mandatory_date_range(filters) def get_columns() -> Columns: