diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py index ef57a6325069..246aee3b0ec9 100644 --- a/erpnext/accounts/custom/address.py +++ b/erpnext/accounts/custom/address.py @@ -15,7 +15,7 @@ def validate(self): def link_address(self): """Link address based on owner""" - if self.is_your_company_address: + if self.get("is_your_company_address"): return return super().link_address() @@ -26,7 +26,9 @@ def update_compnay_address(self): self.is_your_company_address = 1 def validate_reference(self): - if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]: + if self.get("is_your_company_address") and not [ + row for row in self.links if row.link_doctype == "Company" + ]: frappe.throw( _( "Address needs to be linked to a Company. Please add a row for Company in the Links table." diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index b87e8e009512..7f078c67f5d8 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -619,15 +619,26 @@ def make_reverse_journal(self): if journals: from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry - for x in journals: - reversal = make_reverse_journal_entry(x) - reversal.posting_date = nowdate() - reversal.submit() - frappe.msgprint( - _("Revaluation journal for {0} has been created: {1}").format( - frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) - ) + if drafts := frappe.db.get_all( + "Journal Entry", + filters={"docstatus": 0, "reversal_of": ["in", journals]}, + pluck="name", + ): + part = "journals are" if len(drafts) > 1 else "journal is" + doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts]) + frappe.throw( + msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links), ) + else: + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.save() + frappe.msgprint( + _("A draft reverse journal for {0} has been created: {1}").format( + frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) + ) + ) def calculate_exchange_rate_using_last_gle(company, account, party_type, party): diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index 4329b6078ecf..c59fcbef33ce 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -361,6 +361,14 @@ def test_05_revaluation_journal_reversal(self): self.assertFalse(ret.get("reversals_posted")) err.make_reverse_journal() + # submit + draft = frappe.db.get_all( + "Journal Entry", + filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"}, + pluck="name", + ) + self.assertIsNotNone(draft) + frappe.get_doc("Journal Entry", draft[0]).submit() ret = err.check_journal_and_reversal() self.assertTrue(ret.get("journals_posted")) self.assertTrue(ret.get("reversals_posted")) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 232c33d4defc..47ba392802e9 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -41,7 +41,7 @@ frappe.ui.form.on("Journal Entry", { refresh: function (frm) { if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) { - frm.set_read_only(); + erpnext.journal_entry.lock_reversal_entry(frm); } erpnext.toggle_naming_series(); @@ -513,6 +513,14 @@ $.extend(erpnext.journal_entry, { }); }, + lock_reversal_entry: function (frm) { + frm.fields + .filter((field) => field.has_input) + .filter((field) => field.df.fieldname != "posting_date") + .forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1)); + frm.set_df_property("accounts", "read_only", 1); + }, + set_debit_credit_in_company_currency: function (frm, cdt, cdn) { var row = locals[cdt][cdn]; diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 6315560b89f5..ecedc8dc089a 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -124,7 +124,7 @@ def initialize_parallel_threads(docname: str): ) # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep else: frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -272,7 +272,7 @@ def schedule_next_date(docname: str): ) # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep frappe.enqueue( @@ -449,7 +449,7 @@ def summarize_and_post_ledger_entries(docname): # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed") @@ -599,7 +599,7 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie "Completed", ) # commit heavy computation before touching PPCV or PPCVD - if not frappe.in_test: + if not frappe.flags.in_test: frappe.db.commit() # nosemgrep # chain call diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 2edba2e5c5df..ca66235cff33 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -853,7 +853,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None): doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" - companies = frappe.get_all( + companies = frappe.get_list( doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"] ) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index 5b8a9195d268..4da827f1a81d 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index 3f603b628331..0b3bc0776984 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 02bb54abc79c..e0444a0af1ef 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 42b3991194b5..db74275238eb 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -55,8 +55,7 @@ def __init__(self, filters=None): self.filters.report_date = getdate(self.filters.report_date or nowdate()) self.age_as_on = ( getdate(nowdate()) - if "calculate_ageing_with" not in self.filters - or self.filters.calculate_ageing_with == "Today Date" + if "age_as_on" not in self.filters or self.filters.age_as_on == "Today" else self.filters.report_date ) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index 46585071174b..c15ec8b01249 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = { }, { fieldname: "report_date", - label: __("Posting Date"), + label: __("Report Date"), fieldtype: "Date", default: frappe.datetime.get_today(), }, @@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = { default: "Due Date", }, { - fieldname: "calculate_ageing_with", - label: __("Calculate Ageing With"), + fieldname: "age_as_on", + label: __("Age as on"), fieldtype: "Select", - options: "Report Date\nToday Date", + options: "Report Date\nToday", default: "Report Date", }, { diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index c4b894a34b82..d476fd4b3efd 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -73,6 +73,7 @@ def execute(filters=None): "parent_section": None, "indent": 0.0, "section": cash_flow_section["section_header"], + "currency": company_currency, } ) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 8ecfe51d2445..f557635aac1a 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ ) if total_base_amount else 0, + "currency": filters.currency, } ) ) @@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_ "buying_amount": total_buying_amount, "gross_profit": total_gross_profit, "gross_profit_percent": flt(gross_profit_percent, currency_precision), + "currency": filters.currency, } total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]] diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index cfea482d2174..7d92a197a829 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -171,14 +171,12 @@ }, { "allow_on_submit": 1, - "default": "{supplier_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, "label": "Title", "no_copy": 1, - "print_hide": 1, - "reqd": 1 + "print_hide": 1 }, { "fieldname": "naming_series", @@ -1309,7 +1307,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-07-31 17:19:40.816883", + "modified": "2026-07-28 12:20:11.284370", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 95b67d49429b..651c781f4f69 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -162,7 +162,7 @@ class PurchaseOrder(BuyingController): taxes_and_charges_deducted: DF.Currency tc_name: DF.Link | None terms: DF.TextEditor | None - title: DF.Data + title: DF.Data | None to_date: DF.Date | None total: DF.Currency total_net_weight: DF.Float diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py index a7b4a7207c6a..dcfc9ad70765 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py @@ -14,7 +14,6 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Order") data = get_data(filters, conditions) chart_data = get_chart_data(data, conditions, filters) - return conditions["columns"], data, None, chart_data @@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0] for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 28ff84c83fd9..76a809c1b8a5 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -6,6 +6,7 @@ from frappe import _ from frappe.utils import DateTimeLikeObject, getdate, today +import erpnext from erpnext.accounts.utils import get_fiscal_year @@ -42,6 +43,9 @@ def get_columns(filters, trans): "addl_tables": based_on_details["addl_tables"], "addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""), } + conditions["company_currency"] = ( + erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None + ) return conditions @@ -206,7 +210,7 @@ def get_data(filters, conditions): data.append(des) - total_row = calculate_total_row(data1, conditions["columns"]) + total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency")) data.append(total_row) else: data = frappe.db.sql( @@ -231,19 +235,24 @@ def get_data(filters, conditions): as_list=1, ) - total_row = calculate_total_row(data, conditions["columns"]) + total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency")) data.append(total_row) return data -def calculate_total_row(data, columns): +def calculate_total_row(data, columns, company_currency=None): def wrap_in_quotes(label): return f"'{label}'" total_values = {} + currency_col_idx = None for i, col in enumerate(columns): - if "Float" in col or "Currency/currency" in col: + # based-on and group-by columns are dicts, periodic and total columns are strings + if isinstance(col, dict): + if col.get("fieldtype") == "Link" and col.get("options") == "Currency": + currency_col_idx = i + elif "Float" in col or "Currency/currency" in col: total_values[i] = 0 for row in data: @@ -254,6 +263,9 @@ def wrap_in_quotes(label): for i in range(1, len(columns)): total_row.append(total_values.get(i, None)) + if currency_col_idx is not None: + total_row[currency_col_idx] = company_currency + return total_row diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index b68ab28ee82c..7d4d1bb10bfe 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -277,13 +277,17 @@ def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=N self.save() else: - frappe.throw(_("Cannot declare as lost, because Quotation has been made.")) + frappe.throw(_("Cannot declare as Lost because an active Quotation exists.")) def has_active_quotation(self): if not self.get("items", []): return frappe.get_all( "Quotation", - {"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1}, + { + "opportunity": self.name, + "status": ("not in", ["Lost", "Cancelled", "Expired"]), + "docstatus": 1, + }, "name", ) else: @@ -292,14 +296,20 @@ def has_active_quotation(self): select q.name from `tabQuotation` q, `tabQuotation Item` qi where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s - and q.status not in ('Lost', 'Closed')""", + and q.status not in ('Lost', 'Cancelled', 'Expired')""", self.name, ) def has_ordered_quotation(self): if not self.get("items", []): return frappe.get_all( - "Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name" + "Quotation", + { + "opportunity": self.name, + "status": ("in", ["Ordered", "Partially Ordered"]), + "docstatus": 1, + }, + "name", ) else: return frappe.db.sql( @@ -307,7 +317,7 @@ def has_ordered_quotation(self): select q.name from `tabQuotation` q, `tabQuotation Item` qi where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s - and q.status = 'Ordered'""", + and q.status in ('Ordered', 'Partially Ordered')""", self.name, ) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 1bbc23afeeaf..5976cb4fa945 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -787,7 +787,7 @@ def calculate_rm_cost(self, save=False): for d in self.get("items"): old_rate = d.rate - if not self.bom_creator and d.is_stock_item: + if d.is_stock_item: d.rate = self.get_rm_rate( { "company": self.company, diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 52f301bad117..0382d0e201a8 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -29,6 +29,7 @@ from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.utils import get_or_make_bin from erpnext.utilities.transaction_base import validate_uom_is_integer @@ -1228,9 +1229,16 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p def get_uom_conversion_factor(item_code, uom): - return frappe.db.get_value( + item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True) + conversion_factor = frappe.db.get_value( "UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor" ) + if not conversion_factor and item.variant_of: + conversion_factor = frappe.db.get_value( + "UOM Conversion Detail", {"parent": item.variant_of, "uom": uom}, "conversion_factor" + ) + + return conversion_factor or get_uom_conv_factor(uom, item.stock_uom) def get_subitems( diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index b1f1866a12c6..72fb5debb97d 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1606,6 +1606,118 @@ def test_transfer_and_purchase_mrp_for_purchase_uom(self): self.assertTrue(row.warehouse == mrp_warhouse) self.assertEqual(row.quantity, 12.0) + def test_purchase_uom_falls_back_to_uom_conversion_factor(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + if not frappe.db.exists("UOM Conversion Factor", {"from_uom": "Kg", "to_uom": "Gram"}): + frappe.get_doc( + doctype="UOM Conversion Factor", + category="Mass", + from_uom="Kg", + to_uom="Gram", + value=1000, + ).insert() + + rm = make_item("Test RM Item Global CF", {"is_stock_item": 1, "stock_uom": "Gram"}) + rm.purchase_uom = "Kg" + rm.save() + self.assertFalse([row for row in rm.uoms if row.uom == "Kg"]) + + bom_tree = {"Test FG Item Global CF": {rm.name: {}}} + parent_bom = create_nested_bom(bom_tree, prefix="") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=2000, + ignore_existing_ordered_qty=1, + skip_getting_mr_items=1, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + plan.for_warehouse = "_Test Warehouse - _TC" + + items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + row = frappe._dict(next(item for item in items if item["item_code"] == rm.name)) + self.assertEqual(row.uom, "Kg") + self.assertEqual(row.conversion_factor, 1000) + self.assertEqual(row.quantity, 2) + + def test_variant_inherits_purchase_uom_conversion_factor_of_template(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + template = make_item( + "TRMVCF", + { + "is_stock_item": 1, + "stock_uom": "Nos", + "has_variants": 1, + "attributes": [{"attribute": "Colour"}], + }, + ) + if not [row for row in template.uoms if row.uom == "Box"]: + template.purchase_uom = "Box" + template.append("uoms", {"uom": "Box", "conversion_factor": 12}) + template.save() + + if not frappe.db.exists("Item", "TRMVCF-RED"): + create_variant("TRMVCF", {"Colour": "Red"}).insert() + + variant = frappe.get_doc("Item", "TRMVCF-RED") + variant.uoms = [row for row in variant.uoms if row.uom != "Box"] + variant.purchase_uom = "Box" + variant.save() + + bom_tree = {"Test FG Item Variant CF": {variant.name: {}}} + parent_bom = create_nested_bom(bom_tree, prefix="") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=24, + ignore_existing_ordered_qty=1, + skip_getting_mr_items=1, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + plan.for_warehouse = "_Test Warehouse - _TC" + + items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + row = frappe._dict(next(item for item in items if item["item_code"] == variant.name)) + self.assertEqual(row.conversion_factor, 12) + self.assertEqual(row.quantity, 2) + + def test_missing_purchase_uom_conversion_factor_throws(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + rm = make_item("Test RM Item Missing CF", {"is_stock_item": 1, "stock_uom": "Nos"}) + rm.purchase_uom = "Box" + rm.save() + + bom_tree = {"Test FG Item Missing CF": {rm.name: {}}} + parent_bom = create_nested_bom(bom_tree, prefix="") + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=10, + ignore_existing_ordered_qty=1, + skip_getting_mr_items=1, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + plan.for_warehouse = "_Test Warehouse - _TC" + + with self.assertRaises(frappe.ValidationError) as error: + get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}] + ) + + self.assertIn("UOM Conversion factor", str(error.exception)) + def test_mr_qty_for_same_rm_with_different_sub_assemblies(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index fe67fe3feb63..374de33331ce 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -69,6 +69,11 @@ frappe.ui.form.on("BOM Operation", { const d = locals[cdt][cdn]; frm.events.calculate_operating_cost(frm, d); }, + + hour_rate: function (frm, cdt, cdn) { + const d = locals[cdt][cdn]; + frm.events.calculate_operating_cost(frm, d); + }, }); frappe.tour["Routing"] = [ diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index c28e0054c992..d1bfa8edc0dc 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1461,7 +1461,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True): @frappe.whitelist() def make_work_order( - bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None + bom_no: str, + item: str, + qty: float = 0, + company: str | None = None, + project: str | None = None, + variant_items: str | list | None = None, + use_multi_level_bom: bool | None = None, ): from erpnext import get_default_company @@ -1470,7 +1476,8 @@ def make_work_order( item_details = get_item_details(item, project) - if frappe.db.get_value("Item", item, "variant_of"): + # selected BOM already belongs to this variant — keep it + if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item: if variant_bom := frappe.db.get_value( "BOM", {"item": item, "is_default": 1, "docstatus": 1}, diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 1eb47ae577b2..406d08f2e112 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -72,7 +72,7 @@ def test_update_bom_operation_rate(self): test_routing_operations = [ {"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60}, - {"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 60}, + {"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 30}, ] routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations) bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR") @@ -94,6 +94,17 @@ def test_update_bom_operation_rate(self): self.assertEqual(bom_doc.operations[0].hour_rate, 250) self.assertEqual(bom_doc.operations[1].hour_rate, 250) + # hour_rate propagation must also refresh operating_cost (hour_rate * time_in_mins / 60) + # on the Routing's BOM Operation rows; the 30-min op exercises the arithmetic. + for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)): + hour_rate, operating_cost = frappe.db.get_value( + "BOM Operation", + {"parent": routing_doc.name, "parenttype": "Routing", "operation": operation}, + ["hour_rate", "operating_cost"], + ) + self.assertEqual(hour_rate, 250) + self.assertEqual(operating_cost, expected_operating_cost) + def make_workstation(*args, **kwargs): args = args if args else kwargs diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index c45fd7852f44..1e65ea7bedfc 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -152,9 +152,10 @@ def update_bom_operation(self): for bom_no in bom_list: frappe.db.sql( - """update `tabBOM Operation` set hour_rate = %s + """update `tabBOM Operation` + set hour_rate = %s, operating_cost = %s * time_in_mins / 60 where parent = %s and workstation = %s""", - (self.hour_rate, bom_no[0], self.name), + (self.hour_rate, self.hour_rate, bom_no[0], self.name), ) def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7adc55d57c41..02103c8071a4 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -443,3 +443,5 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm erpnext.patches.v16_0.backfill_pick_list_transferred_qty erpnext.patches.v16_0.access_control_for_project_users +erpnext.patches.v16_0.rename_ar_ap_ageing_filter +erpnext.patches.v15_0.fix_titles diff --git a/erpnext/patches/v15_0/fix_titles.py b/erpnext/patches/v15_0/fix_titles.py new file mode 100644 index 000000000000..32352413c3ae --- /dev/null +++ b/erpnext/patches/v15_0/fix_titles.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + """ + These doctypes point `title_field` at the party name field, so their `title` + default was never rendered and got stored as the literal template string. + """ + + for doctype, source_field in ( + ("Purchase Order", "supplier_name"), + ("Subcontracting Order", "supplier_name"), + ("Sales Order", "customer_name"), + ): + table = frappe.qb.DocType(doctype) + ( + frappe.qb.update(table) + .set(table.title, table[source_field]) + .where(table.title == f"{{{source_field}}}") + ).run() diff --git a/erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py b/erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py new file mode 100644 index 000000000000..8252c3b0aac7 --- /dev/null +++ b/erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py @@ -0,0 +1,45 @@ +import frappe + +REPORTS = ( + "Accounts Receivable", + "Accounts Payable", + "Accounts Receivable Summary", + "Accounts Payable Summary", +) + + +def execute(): + # filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today" + _migrate("Auto Email Report", "filters", "report") + _migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type") + _migrate("Number Card", "filters_json", "report_name", type_field="type") + + +def _migrate(doctype, filter_field, report_field, type_field=None): + conditions = {report_field: ("in", REPORTS)} + if type_field: + conditions[type_field] = "Report" + + for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]): + updated = _rewrite(row.get(filter_field)) + if updated is not None: + frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False) + + +def _rewrite(raw): + if not raw: + return None + + try: + filters = frappe.parse_json(raw) + except ValueError: + return None + + if not isinstance(filters, dict) or "calculate_ageing_with" not in filters: + return None + + filters["age_as_on"] = filters.pop("calculate_ageing_with") + if filters["age_as_on"] == "Today Date": + filters["age_as_on"] = "Today" + + return frappe.as_json(filters, indent=None) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index d1a61ad34a3f..c25ab500dc78 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -292,6 +292,7 @@ def on_submit(self): # update enquiry status self.update_opportunity("Quotation") self.update_lead() + self.carry_forward_communication() def on_cancel(self): if self.lost_reasons: @@ -303,6 +304,18 @@ def on_cancel(self): self.update_opportunity("Open") self.update_lead() + def carry_forward_communication(self): + from erpnext.crm.utils import copy_comments, link_communications + + if not ( + self.opportunity + and frappe.get_single_value("CRM Settings", "carry_forward_communication_and_comments") + ): + return + + copy_comments("Opportunity", self.opportunity, self) + link_communications("Opportunity", self.opportunity, self) + def print_other_charges(self, docname): print_lst = [] for d in self.get("taxes"): diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index e649b8e93836..876cc5bb7609 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -187,7 +187,6 @@ }, { "allow_on_submit": 1, - "default": "{customer_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, @@ -1680,7 +1679,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-03-06 15:33:49.059029", + "modified": "2026-07-28 12:20:44.130918", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py index 92f9d17a9c7c..e5b625693944 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/quotation_trends.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - from frappe import _ from erpnext.controllers.trends import get_columns, get_data @@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0] for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py index 0827110ae5d6..fb35df14a653 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py @@ -39,9 +39,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0] for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -58,4 +64,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 127517a1e3f3..d3d056f38624 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -30,7 +30,7 @@ class TermsandConditions(Document): def validate(self): if self.terms: - validate_template(self.terms) + validate_template(self.terms, restrict_globals=True) if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled): throw(_("At least one of the Applicable Modules should be selected")) @@ -40,7 +40,10 @@ def get_terms_and_conditions(template_name, doc): if isinstance(doc, str): doc = json.loads(doc) - terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name) + tnc = frappe.get_cached_doc("Terms and Conditions", template_name) + tnc.check_permission() - if terms_and_conditions.terms: - return frappe.render_template(terms_and_conditions.terms, doc) + if not tnc.terms: + return + + return frappe.render_template(tnc.terms, doc, restrict_globals=1) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index b57b683c2d8e..c911dd9eeaf1 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -64,6 +64,7 @@ def get_incoming_value_for_serial_nos(self, serial_nos): | (table.serial_no.like("%\n" + serial_no)) | (table.serial_no.like("%\n" + serial_no + "\n%")) ) + & (table.item_code == self.sle.item_code) & (table.company == self.sle.company) & (table.warehouse == self.sle.warehouse) & (table.serial_and_batch_bundle.isnull()) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index b3a85b0bdd6d..f7147f0fa59a 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -298,7 +298,7 @@ def get_batches_by_oldest(item_code, warehouse): """Returns the oldest batch and qty for the given item_code and warehouse""" batches = get_batch_qty(item_code=item_code, warehouse=warehouse) batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches] - batches_dates.sort(key=lambda tup: tup[1]) + batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1])) return batches_dates diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 2af1f6454944..a2c0f09d3877 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -162,7 +162,7 @@ def update_reserved_qty_for_sub_contracting( & (subcontract_order.docstatus == 1) ) if subcontract_doctype == "Purchase Order" - else (subcontract_order.docstatus == 1) + else ((subcontract_order.status != "Closed") & (subcontract_order.docstatus == 1)) ) ) @@ -199,6 +199,7 @@ def update_reserved_qty_for_sub_contracting( else ( (Coalesce(se.subcontracting_order, "") != "") & (subcontract_order.name == se.subcontracting_order) + & (subcontract_order.status != "Closed") ) ) ) diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json index 04212b31795d..b542c7529a75 100644 --- a/erpnext/stock/doctype/item_default/item_default.json +++ b/erpnext/stock/doctype/item_default/item_default.json @@ -38,6 +38,7 @@ { "fieldname": "default_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_list_view": 1, "label": "Default Warehouse", "options": "Warehouse", @@ -63,7 +64,8 @@ "fieldname": "buying_cost_center", "fieldtype": "Link", "label": "Default Buying Cost Center", - "options": "Cost Center" + "options": "Cost Center", + "ignore_user_permissions": 1 }, { "fieldname": "default_supplier", @@ -90,6 +92,7 @@ "fieldname": "selling_cost_center", "fieldtype": "Link", "label": "Default Selling Cost Center", + "ignore_user_permissions": 1, "options": "Cost Center" }, { @@ -99,12 +102,14 @@ { "fieldname": "income_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Income Account", "options": "Account" }, { "fieldname": "default_discount_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Discount Account", "options": "Account" }, @@ -123,6 +128,7 @@ "depends_on": "eval: parent.enable_deferred_expense", "fieldname": "deferred_expense_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Deferred Expense Account", "options": "Account" }, @@ -130,6 +136,7 @@ "depends_on": "eval: parent.enable_deferred_revenue", "fieldname": "deferred_revenue_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Deferred Revenue Account", "options": "Account" }, @@ -140,7 +147,7 @@ ], "istable": 1, "links": [], - "modified": "2025-03-17 13:46:09.719105", + "modified": "2026-07-28 15:39:44.848087", "modified_by": "Administrator", "module": "Stock", "name": "Item Default", diff --git a/erpnext/stock/doctype/item_reorder/item_reorder.json b/erpnext/stock/doctype/item_reorder/item_reorder.json index a03bd458d4f6..85b9d942fc15 100644 --- a/erpnext/stock/doctype/item_reorder/item_reorder.json +++ b/erpnext/stock/doctype/item_reorder/item_reorder.json @@ -1,161 +1,72 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "hash", - "beta": 0, - "creation": "2013-03-07 11:42:59", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 1, + "actions": [], + "autoname": "hash", + "creation": "2013-03-07 11:42:59", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "warehouse", + "warehouse_group", + "warehouse_reorder_level", + "warehouse_reorder_qty", + "material_request_type" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Check in (group)", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "columns": 3, + "fieldname": "warehouse_group", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Check Availability in Warehouse", + "options": "Warehouse" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Request for", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "columns": 2, + "fieldname": "warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Request for", + "options": "Warehouse", + "reqd": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse_reorder_level", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Re-order Level", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "warehouse_reorder_level", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Re-order Level" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "warehouse_reorder_qty", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Re-order Qty", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "warehouse_reorder_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Re-order Qty" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "material_request_type", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Material Request Type", - "length": 0, - "no_copy": 0, - "options": "Purchase\nTransfer\nMaterial Issue\nManufacture", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "material_request_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Material Request Type", + "options": "Purchase\nTransfer\nMaterial Issue\nManufacture", + "reqd": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 1, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2023-06-21 15:13:38.270046", - "modified_by": "Administrator", - "module": "Stock", - "name": "Item Reorder", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "sort_order": "ASC", - "track_seen": 0 -} + ], + "idx": 1, + "in_create": 1, + "istable": 1, + "links": [], + "modified": "2026-07-28 17:05:12.778047", + "modified_by": "Administrator", + "module": "Stock", + "name": "Item Reorder", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "ASC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index ceae67114f97..eaf28dd4b961 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -820,7 +820,10 @@ def raise_work_orders(material_request): for d in mr.items: if (d.stock_qty - d.ordered_qty) > 0: - if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1}): + if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1, "is_active": 1}) or ( + (variant_of := frappe.get_value("Item", d.item_code, "variant_of")) + and frappe.db.exists("BOM", {"item": variant_of, "is_default": 1, "is_active": 1}) + ): wo_order = frappe.new_doc("Work Order") wo_order.update( { diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 06e190c30f74..7a2dd32291aa 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1339,6 +1339,9 @@ def create_dn_wo_so(pick_list, delivery_note=None): delivery_note.company = pick_list.company + if not delivery_note.customer: + delivery_note.customer = pick_list.customer + item_table_mapper_without_so = { "doctype": "Delivery Note Item", "field_map": { diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 79a353ace30b..b344b772cbb7 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2751,6 +2751,50 @@ def test_process_loss_qty_derived_from_percentage_when_qty_blank(self): self.assertEqual(se.process_loss_qty, 50) + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_cancel_seeds_replay_from_before_posting_datetime_bucket(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item = make_item().name + warehouse = create_warehouse("Cancel Replay Warehouse", company="_Test Company") + + def stock_entry(qty, posting_date, **kwargs): + return make_stock_entry( + item_code=item, + qty=qty, + company="_Test Company", + posting_date=posting_date, + posting_time="10:00:00", + **kwargs, + ) + + stock_entry(25, add_days(today(), -3), to_warehouse=warehouse, rate=10) + + bucket_date = add_days(today(), -2) + stock_entry(20, bucket_date, from_warehouse=warehouse) + receipt = stock_entry(75, bucket_date, to_warehouse=warehouse, rate=10) + duplicate_issue = stock_entry(20, bucket_date, from_warehouse=warehouse) + + frappe.flags.dont_execute_stock_reposts = True + self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts") + + duplicate_issue.reload() + duplicate_issue.cancel() + + self.assertEqual( + flt( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": receipt.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + ), + 80, + ) + + overdraw = stock_entry(100, add_days(today(), -1), from_warehouse=warehouse, do_not_submit=True) + self.assertRaises(NegativeStockError, overdraw.submit) + def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 6aea0ef53374..d917d5914bf1 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1357,6 +1357,81 @@ def qty_after(voucher): # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). self.assertEqual(qty_after(receipt2), 10) + def test_cancel_shifts_same_timestamp_delivery_notes(self): + item = make_item("Test Shifts Same Timestamp Multiple DNs").name + warehouse = "_Test Warehouse - _TC" + posting_date = today() + posting_time = "10:00:00" + + make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + rate=10, + posting_date=posting_date, + posting_time="09:00:00", + ) + + dns = [] + for i in range(5): + dns.append( + create_delivery_note( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10 * i, + posting_date=posting_date, + posting_time=posting_time, + ) + ) + time.sleep(1) + + dn = dns[2] + dn.cancel() + + expected_qty_after_transaction_of_dns3 = 40 + qty_after_transaction_of_dns3 = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": dns[3].name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3) + + def test_cancel_updates_bin_stock_value_when_no_sle_shares_timestamp(self): + item = make_item("Test Cancel Bin Stock Value Lone Timestamp").name + warehouse = "_Test Warehouse - _TC" + posting_date = today() + + make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + rate=10, + posting_date=posting_date, + posting_time="09:00:00", + ) + + dn = create_delivery_note( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10, + posting_date=posting_date, + posting_time="10:00:00", + ) + + dn.cancel() + + # Nothing else sits on the delivery note's posting datetime, so the cancellation leaves no + # live SLE to reprocess. The bin must still fall back to the receipt's stock value. + bin_qty, bin_stock_value = frappe.db.get_value( + "Bin", {"item_code": item, "warehouse": warehouse}, ["actual_qty", "stock_value"] + ) + + self.assertEqual(bin_qty, 100) + self.assertEqual(bin_stock_value, 1000) + def test_get_next_stock_reco_respects_creation_order(self): # A stock reco sharing the exact posting timestamp of the current entry must only count as the # "next" reco when it was created after that entry. A reco created before it actually precedes diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 45d7a459fb5a..b9600fddc9bb 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -8,7 +8,7 @@ from frappe.utils.nestedset import NestedSet from pypika.terms import ExistsCriterion -from erpnext.stock import get_warehouse_account +from erpnext.stock import get_warehouse_account, get_warehouse_account_map class Warehouse(NestedSet): @@ -195,11 +195,19 @@ def get_child_warehouses(warehouse): def get_warehouses_based_on_account(account, company=None): warehouses = [] + warehouse_account_map = None for d in frappe.get_all( "Warehouse", fields=["name", "is_group"], filters={"account": account, "disabled": 0} ): if d.is_group: - warehouses.extend(get_child_warehouses(d.name)) + # Keep only children whose effective account matches; a child can override the group's account + if warehouse_account_map is None: + warehouse_account_map = get_warehouse_account_map(company) + warehouses.extend( + w + for w in get_child_warehouses(d.name) + if (warehouse_account_map.get(w) or {}).get("account") == account + ) else: warehouses.append(d.name) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 5b2854b7b8d6..aecc20d8aa21 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -63,6 +63,7 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru for_validate = process_string_args(for_validate) overwrite_warehouse = process_string_args(overwrite_warehouse) item = frappe.get_cached_doc("Item", args.item_code) + item.check_permission() validate_item_details(args, item) if isinstance(doc, str): diff --git a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py index a456bad72d7e..1365a02ba256 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py @@ -14,12 +14,12 @@ def execute(filters=None): conditions = get_columns(filters, "Delivery Note") data = get_data(filters, conditions) - chart_data = get_chart_data(data, filters) + chart_data = get_chart_data(data, conditions, filters) return conditions["columns"], data, None, chart_data -def get_chart_data(data, filters): +def get_chart_data(data, conditions, filters): def wrap_in_quotes(label): return f"'{label}'" @@ -52,4 +52,6 @@ def wrap_in_quotes(label): }, "type": "bar", "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py index 9d313b477a35..4210d1a36045 100644 --- a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py +++ b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py @@ -14,12 +14,12 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Receipt") data = get_data(filters, conditions) - chart_data = get_chart_data(data, filters) + chart_data = get_chart_data(data, conditions, filters) return conditions["columns"], data, None, chart_data -def get_chart_data(data, filters): +def get_chart_data(data, conditions, filters): def wrap_in_quotes(label): return f"'{label}'" @@ -53,4 +53,6 @@ def wrap_in_quotes(label): "type": "bar", "colors": ["#5e64ff"], "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e745a10ea7e6..8ab475334d70 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -325,6 +325,7 @@ def generate(self) -> dict: del stock_ledger_entries self._recompute_moving_average_slots() + self._rebalance_batch_slots() if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) @@ -346,6 +347,29 @@ def _recompute_moving_average_slots(self) -> None: if is_qty_slot(slot): slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _rebalance_batch_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_batch_no"): + self._rebalance_batch_slot_values(item_dict["fifo_queue"]) + + def _rebalance_batch_slot_values(self, fifo_queue: list) -> None: + """A batch is one valuation pool, so per-slot value differences are stale + detail: spread the pool value over its slots in proportion to qty.""" + groups = {} + for slot in fifo_queue: + if is_batch_slot(slot): + key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None + groups.setdefault(key, []).append(slot) + + for slots in groups.values(): + total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) + if total_qty <= 0: + continue + + rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty + for slot in slots: + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index fb488c47eff6..9feca367e9e1 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -7,6 +7,8 @@ from frappe.tests.utils import FrappeTestCase from erpnext.stock.report.stock_ageing.stock_ageing import ( + BATCH_SLOT_QTY_INDEX, + BATCH_SLOT_VALUE_INDEX, FIFOSlots, format_report_data, get_average_age, @@ -569,10 +571,11 @@ def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stoc ], ) - def test_partial_batch_reco_keeps_existing_slot_values(self): + def test_partial_batch_reco_pools_slot_values(self): """Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12] The reco entry qty (delta 2) does not cover the whole batch, so - stock_value_difference / qty is not the batch rate: skip the rescale.""" + stock_value_difference / qty is not the batch rate: skip the rescale. + The batch total (1400) is untouched, then pooled across both slots.""" from erpnext.stock.doctype.item.test_item import make_item item_code = make_item( @@ -612,11 +615,163 @@ def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stoc slots = FIFOSlots(self.filters, sle).generate() queue = slots[item_code]["fifo_queue"] + self.assertEqual( + [slot[:4] for slot in queue], + [ + [batch_no, 1, 10.0, "2021-12-01"], + [batch_no, 1, 2.0, "2021-12-01"], + ], + ) + self.assertAlmostEqual(queue[0][4], 1166.67, places=2) + self.assertAlmostEqual(queue[1][4], 233.33, places=2) + + def test_batch_receipts_at_differing_rates_pool_slot_values(self): + """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10] and no issue. + Nothing goes negative, but the batch is one valuation pool, so both + age slots carry the pooled rate instead of their receipt value.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Split", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-SPLIT-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 10, 10, 0), + make_sle("2021-12-02", "002", 10, 20, 100), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual( + queue, + [ + [batch_no, 1, 10.0, "2021-12-01", 50.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], + ], + ) + + def test_batch_pooling_preserves_total_on_repeating_rate(self): + """Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0] + The pooled rate does not terminate, so assert the redistributed + slot values still add back to the batch total.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Residual", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-RESIDUAL-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 3, 3, 100), + make_sle("2021-12-02", "002", 6, 9, 0), + make_sle("2021-12-03", "003", 2, 11, 0), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual([slot[BATCH_SLOT_QTY_INDEX] for slot in queue], [3.0, 6.0, 2.0]) + self.assertEqual(sum(slot[BATCH_SLOT_VALUE_INDEX] for slot in queue), 100.0) + + def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self): + """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5] + Consuming the zero-valued head slot at the pooled rate drives it + negative; slot values are then rebalanced to the batch pool rate.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Rebalance", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-REBALANCE-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 10, 10, 0), + make_sle("2021-12-02", "002", 10, 20, 100), + make_sle("2021-12-03", "003", -4, 16, -20), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + self.assertEqual( queue, [ - [batch_no, 1, 10.0, "2021-12-01", 1000.0], - [batch_no, 1, 2.0, "2021-12-01", 400.0], + [batch_no, 1, 6.0, "2021-12-01", 30.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], ], ) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index fa3b977a1216..89f01b9862ac 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -130,7 +130,7 @@ frappe.query_reports["Stock Balance"] = { fieldname: "include_zero_stock_items", label: __("Include Zero Stock Items"), fieldtype: "Check", - default: 0, + default: 1, }, { fieldname: "show_dimension_wise_stock", diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ba578f698144..a2cce420e3aa 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -799,9 +799,18 @@ def has_stock_reco_with_serial_batch(self, sle): def process_sle_against_current_timestamp(self): sl_entries = get_sle_against_current_voucher(self.args) + if self.args.get("cancelled") and sl_entries: + self.seed_previous_sle_for_cancellation(sl_entries[0]) for sle in sl_entries: self.process_sle(sle) + def seed_previous_sle_for_cancellation(self, anchor_sle): + args = frappe._dict(anchor_sle) + args["sle_id"] = args.name + prev_sle = get_previous_sle_of_current_voucher(args) + if prev_sle: + self.prev_sle_dict[(anchor_sle.item_code, anchor_sle.warehouse)] = prev_sle + def get_future_entries_to_fix(self): # includes current entry! args = self.data[self.args.warehouse].previous_sle or frappe._dict( @@ -1762,7 +1771,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc voucher_no = args.get("voucher_no") voucher_condition = f"and voucher_no != '{voucher_no}'" - elif args.get("creation") and args.get("sle_id"): + elif args.get("creation") and args.get("sle_id") and not args.get("cancelled"): creation = args.get("creation") operator = "<=" voucher_condition = f"and creation < '{creation}'" diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json index 206e3135dfb6..cbcf1ddc03a2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.json @@ -66,7 +66,6 @@ "fields": [ { "allow_on_submit": 1, - "default": "{supplier_name}", "fieldname": "title", "fieldtype": "Data", "hidden": 1, @@ -465,7 +464,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2024-12-06 15:21:49.924146", + "modified": "2026-07-28 12:21:09.663812", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order", diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 6094229c32d8..1a54343a1b3e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -336,6 +336,85 @@ def test_update_reserved_qty_for_subcontracting(self): bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract ) + def test_close_subcontracting_order_releases_reserved_qty(self): + # RM in stock at the reserve warehouse for transfer + make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100) + make_stock_entry( + target="_Test Warehouse - _TC", item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100 + ) + + bin_before_sco = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, + fieldname="reserved_qty_for_sub_contract", + as_dict=1, + ) + + # Create SCO with a reserve warehouse on the supplied items + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": "_Test FG Item", + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + + # Transfer only 90% of the raw materials to the supplier warehouse + ste = frappe.get_doc(make_rm_stock_entry(sco.name)) + for item in ste.items: + item.qty *= 0.9 + ste.save() + ste.submit() + sco.load_from_db() + self.assertEqual(sco.status, "Partial Material Transferred") + + # Receive only a partial qty so the order stays open (per_received < 100) + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty -= 1 + scr.save() + scr.submit() + sco.load_from_db() + self.assertEqual(sco.status, "Partially Received") + + # Keep another SCO open so transfers from the closed SCO must not reduce its reservation + open_sco = get_subcontracting_order(service_items=service_items) + self.assertEqual(open_sco.status, "Open") + + bin_before_close = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, + fieldname=["reserved_qty_for_sub_contract", "projected_qty"], + as_dict=1, + ) + + # One unit remains reserved for the partially transferred SCO, plus ten for the open SCO + self.assertEqual( + bin_before_close.reserved_qty_for_sub_contract, + bin_before_sco.reserved_qty_for_sub_contract + 11, + ) + + # Close the partially-received order + sco.update_status("Closed") + self.assertEqual(sco.status, "Closed") + + bin_after_close = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, + fieldname=["reserved_qty_for_sub_contract", "projected_qty"], + as_dict=1, + ) + + # Closing releases the remaining unit without applying its transfer against the open SCO + self.assertEqual( + bin_after_close.reserved_qty_for_sub_contract, + bin_before_sco.reserved_qty_for_sub_contract + 10, + ) + self.assertEqual(bin_after_close.projected_qty, bin_before_close.projected_qty + 1) + def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self): """A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the diff --git a/erpnext/tests/test_init.py b/erpnext/tests/test_init.py index 2b4ea9fa8dc8..1509fb16c2dc 100644 --- a/erpnext/tests/test_init.py +++ b/erpnext/tests/test_init.py @@ -49,3 +49,18 @@ def test_patches(self): from frappe.tests.test_patches import check_patch_files check_patch_files("erpnext") + + def test_no_unrendered_title_templates(self): + modules = frappe.get_all("Module Def", filters={"app_name": "erpnext"}, pluck="name") + for doctype in frappe.get_all("DocType", filters={"module": ("in", modules)}, pluck="name"): + meta = frappe.get_meta(doctype) + field = meta.get_field("title") + if not field or not field.default or "{" not in field.default: + continue + + self.assertEqual( + meta.title_field, + "title", + f"{doctype}: title default {field.default!r} is stored verbatim because " + "Document.set_title_field() only renders it when title_field is 'title'", + )