diff --git a/.github/workflows/server-tests-mariadb-faux.yml b/.github/workflows/server-tests-mariadb-faux.yml index 8df315c13ae..555ce260406 100644 --- a/.github/workflows/server-tests-mariadb-faux.yml +++ b/.github/workflows/server-tests-mariadb-faux.yml @@ -7,6 +7,7 @@ on: paths: - "**.js" - "**.css" + - "**.svg" - "**.md" - "**.html" - 'crowdin.yml' diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/id_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/id_chart_of_accounts.json index 855feea6920..f569938dfc5 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/id_chart_of_accounts.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/id_chart_of_accounts.json @@ -33,6 +33,17 @@ }, "account_number": "1151.000" }, + "Pajak Dibayar di Muka": { + "PPN Masukan": { + "account_number": "1152.001", + "account_type": "Tax" + }, + "PPh 23 Dibayar di Muka": { + "account_number": "1152.002", + "account_type": "Tax" + }, + "account_number": "1152.000" + }, "account_number": "1150.000" }, "Kas": { @@ -97,17 +108,6 @@ }, "account_number": "1130.000" }, - "Pajak Dibayar di Muka": { - "PPN Masukan": { - "account_number": "1151.001", - "account_type": "Tax" - }, - "PPh 23 Dibayar di Muka": { - "account_number": "1152.001", - "account_type": "Tax" - }, - "account_number": "1150.000" - }, "account_number": "1100.000" }, diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 2465948c5ef..29e6c1fb638 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -9,6 +9,7 @@ "engine": "InnoDB", "field_order": [ "entry_type_and_date", + "company", "is_system_generated", "title", "voucher_type", @@ -17,7 +18,6 @@ "reversal_of", "column_break1", "from_template", - "company", "posting_date", "finance_book", "apply_tds", @@ -638,7 +638,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2025-11-13 17:54:14.542903", + "modified": "2026-02-03 14:40:39.944524", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 4ea077f5d8c..6428896acae 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -74,8 +74,8 @@ class JournalEntry(AccountsController): mode_of_payment: DF.Link | None multi_currency: DF.Check naming_series: DF.Literal["ACC-JV-.YYYY.-"] - party_not_required: DF.Check override_tax_withholding_entries: DF.Check + party_not_required: DF.Check pay_to_recd_from: DF.Data | None payment_order: DF.Link | None periodic_entry_difference_account: DF.Link | None @@ -1691,6 +1691,10 @@ def get_exchange_rate( credit=None, exchange_rate=None, ): + # Ensure exchange_rate is always numeric to avoid calculation errors + if isinstance(exchange_rate, str): + exchange_rate = flt(exchange_rate) or 1 + account_details = frappe.get_cached_value( "Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1 ) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index c3743c6e1f0..15ec96a5e9c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1450,16 +1450,15 @@ frappe.ui.form.on("Payment Entry", { callback: function (r) { if (!r.exc && r.message) { // set taxes table - if (r.message) { - for (let tax of r.message) { - if (tax.charge_type === "On Net Total") { - tax.charge_type = "On Paid Amount"; - } - frm.add_child("taxes", tax); + let taxes = r.message; + taxes.forEach((tax) => { + if (tax.charge_type === "On Net Total") { + tax.charge_type = "On Paid Amount"; } - frm.events.apply_taxes(frm); - frm.events.set_unallocated_amount(frm); - } + }); + frm.set_value("taxes", taxes); + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); } }, }); diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 6dae8dd22d5..9750e4c7f83 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -897,6 +897,53 @@ class TestPOSInvoice(IntegrationTestCase): if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC": self.assertEqual(batch.qty, 5) + def test_pos_batch_reservation_with_return_qty(self): + """ + Test POS Invoice reserved qty for batch without bundle with return invoices. + """ + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_auto_batch_nos, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_batch_item_with_batch, + ) + + create_batch_item_with_batch("_Batch Item Reserve Return", "TestBatch-RR 01") + se = make_stock_entry( + target="_Test Warehouse - _TC", + item_code="_Batch Item Reserve Return", + qty=30, + basic_rate=100, + ) + + se.reload() + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + # POS Invoice for the batch without bundle + pos_inv = create_pos_invoice(item="_Batch Item Reserve Return", rate=300, qty=15, do_not_save=1) + pos_inv.append( + "payments", + {"mode_of_payment": "Cash", "amount": 4500}, + ) + pos_inv.items[0].batch_no = batch_no + pos_inv.save() + pos_inv.submit() + + # POS Invoice return + pos_return = make_sales_return(pos_inv.name) + + pos_return.insert() + pos_return.submit() + + batches = get_auto_batch_nos( + frappe._dict({"item_code": "_Batch Item Reserve Return", "warehouse": "_Test Warehouse - _TC"}) + ) + + for batch in batches: + if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC": + self.assertEqual(batch.qty, 30) + def test_pos_batch_item_qty_validation(self): from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( BatchNegativeStockError, diff --git a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py index f43ee3c4ce2..23ef5678e0d 100644 --- a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py @@ -98,8 +98,7 @@ def get_customers_list(pos_profile=None): return ( frappe.db.sql( - f""" select name, customer_name, customer_group, - territory, customer_pos_id from tabCustomer where disabled = 0 + f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0 and {cond}""", tuple(customer_groups), as_dict=1, diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py index 092081308c9..4f6741f17cd 100644 --- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py @@ -415,8 +415,9 @@ def reconcile(doc: None | str = None) -> None: for x in allocations: pr.append("allocation", x) + skip_ref_details_update_for_pe = check_multi_currency(pr) # reconcile - pr.reconcile_allocations(skip_ref_details_update_for_pe=True) + pr.reconcile_allocations(skip_ref_details_update_for_pe=skip_ref_details_update_for_pe) # If Payment Entry, update details only for newly linked references # This is for performance @@ -504,6 +505,37 @@ def reconcile(doc: None | str = None) -> None: frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed") +def check_multi_currency(pr_doc): + GL = frappe.qb.DocType("GL Entry") + Account = frappe.qb.DocType("Account") + + def get_account_currency(voucher_type, voucher_no): + currency = ( + frappe.qb.from_(GL) + .join(Account) + .on(GL.account == Account.name) + .select(Account.account_currency) + .where( + (GL.voucher_type == voucher_type) + & (GL.voucher_no == voucher_no) + & (Account.account_type.isin(["Payable", "Receivable"])) + ) + .limit(1) + ).run(as_dict=True) + + return currency[0].account_currency if currency else None + + for allocation in pr_doc.allocation: + reference_currency = get_account_currency(allocation.reference_type, allocation.reference_name) + + invoice_currency = get_account_currency(allocation.invoice_type, allocation.invoice_number) + + if reference_currency != invoice_currency: + return True + + return False + + @frappe.whitelist() def is_any_doc_running(for_filter: str | dict | None = None) -> str | None: running_doc = None diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 980eee2c48c..02371e778cf 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -8,12 +8,12 @@ "email_append_to": 1, "engine": "InnoDB", "field_order": [ + "company", "title", "naming_series", "supplier", "supplier_name", "tax_id", - "company", "column_break_6", "posting_date", "posting_time", @@ -1668,7 +1668,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-01-29 21:21:53.051193", + "modified": "2026-02-03 14:23:47.937128", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 888dbd59438..801820fbdb5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -36,7 +36,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status -from erpnext.controllers.accounts_controller import validate_account_head +from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( update_billed_amount_based_on_po, @@ -2005,9 +2005,17 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): args = json.loads(args) def post_parent_process(source_parent, target_parent): - for row in target_parent.get("items"): - if row.get("qty") == 0: - target_parent.remove(row) + remove_items_with_zero_qty(target_parent) + set_missing_values(source_parent, target_parent) + + def remove_items_with_zero_qty(target_parent): + target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0] + + def set_missing_values(source_parent, target_parent): + target_parent.run_method("set_missing_values") + if args and args.get("merge_taxes"): + merge_taxes(source_parent, target_parent) + target_parent.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row @@ -2059,7 +2067,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): "postprocess": update_item, "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), }, - "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"}, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": not (args and args.get("merge_taxes")), + "ignore": args.get("merge_taxes") if args else 0, + }, }, target_doc, post_parent_process, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 0ba6feef6da..6682270b4c9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -44,6 +44,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( "Unreconcile Payment Entries", "Serial and Batch Bundle", "Bank Transaction", + "Packing Slip", ]; if (!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 6e82c3896c3..697804499f6 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -8,12 +8,12 @@ "engine": "InnoDB", "field_order": [ "customer_section", + "company", + "company_tax_id", "naming_series", "customer", "customer_name", "tax_id", - "company", - "company_tax_id", "column_break1", "posting_date", "posting_time", diff --git a/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html b/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html index 8a6968ee373..ad8c908dbcf 100644 --- a/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html +++ b/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html @@ -17,7 +17,7 @@
- +
Date: {{ frappe.utils.format_date(doc.creation) }}
Date: {{ frappe.utils.format_date(doc.posting_date) }}
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index d2fe570fa3b..a6dc46cabd9 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -176,7 +176,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ column_names = get_column_names() # to display item as Item Code: Item Name - columns[0] = "Sales Invoice:Link/Item:300" + columns[0]["fieldname"] = "sales_invoice" + columns[0]["options"] = "Item" + columns[0]["width"] = 300 # removing Item Code and Item Name columns supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name") customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name") diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index c7585d9efd8..9cbdbee6316 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -163,11 +163,11 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co def get_chart_data(filters, columns, income, expense, net_profit_loss, currency): - labels = [d.get("label") for d in columns[2:]] + labels = [d.get("label") for d in columns[4:]] income_data, expense_data, net_profit = [], [], [] - for p in columns[2:]: + for p in columns[4:]: if income: income_data.append(income[-2].get(p.get("fieldname"))) if expense: diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 05d03d47676..25a31ea698d 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -9,10 +9,9 @@ "engine": "InnoDB", "field_order": [ "supplier_section", + "company", "title", "naming_series", - "supplier", - "supplier_name", "order_confirmation_no", "order_confirmation_date", "get_items_from_open_material_requests", @@ -21,8 +20,9 @@ "transaction_date", "schedule_date", "column_break1", - "company", + "supplier", "is_subcontracted", + "supplier_name", "has_unit_price_items", "supplier_warehouse", "amended_from", @@ -1310,7 +1310,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-01-29 21:22:54.323838", + "modified": "2026-02-03 14:44:55.192192", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 69593927e69..f78ea1b48c8 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -846,12 +846,34 @@ def get_filters( if reference_voucher_detail_no: filters["voucher_detail_no"] = reference_voucher_detail_no - if voucher_type in ["Purchase Receipt", "Purchase Invoice"] and item_row and item_row.get("warehouse"): - filters["warehouse"] = item_row.get("warehouse") + warehouses = [] + if voucher_type in ["Purchase Receipt", "Purchase Invoice"] and item_row: + if reference_voucher_detail_no: + warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no) + + if item_row.get("warehouse") and item_row.get("warehouse") in warehouses: + filters["warehouse"] = item_row.get("warehouse") return filters +def get_warehouses_for_return(voucher_type, name): + warehouses = [] + warehouse_details = frappe.get_all( + voucher_type + " Item", + filters={"name": name, "docstatus": 1}, + fields=["warehouse", "rejected_warehouse"], + ) + + for d in warehouse_details: + if d.warehouse: + warehouses.append(d.warehouse) + if d.rejected_warehouse: + warehouses.append(d.rejected_warehouse) + + return warehouses + + def get_returned_serial_nos(child_doc, parent_doc, serial_no_field=None, ignore_voucher_detail_no=None): from erpnext.stock.doctype.serial_no.serial_no import ( get_serial_nos as get_serial_nos_from_serial_no, diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index db54db5cf1f..0f883eeb50f 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -296,7 +296,7 @@ class SellingController(StockController): _( """Row #{0}: Selling rate for item {1} is lower than its {2}. Selling {3} should be atleast {4}.

Alternatively, - you can disable selling price validation in {5} to bypass + you can disable '{5}' in {6} to bypass this validation.""" ).format( idx, @@ -304,7 +304,8 @@ class SellingController(StockController): bold(ref_rate_field), bold("net rate"), bold(rate), - get_link_to_form("Selling Settings", "Selling Settings"), + bold(frappe.get_meta("Selling Settings").get_label("validate_selling_price")), + get_link_to_form("Selling Settings"), ), title=_("Invalid Selling Price"), ) @@ -313,7 +314,6 @@ class SellingController(StockController): return is_internal_customer = self.get("is_internal_customer") - valuation_rate_map = {} for item in self.items: if not item.item_code or item.is_free_item: @@ -323,7 +323,9 @@ class SellingController(StockController): "Item", item.item_code, ("last_purchase_rate", "is_stock_item") ) - last_purchase_rate_in_sales_uom = last_purchase_rate * (item.conversion_factor or 1) + last_purchase_rate_in_sales_uom = flt( + last_purchase_rate * (item.conversion_factor or 1), item.precision("base_net_rate") + ) if flt(item.base_net_rate) < flt(last_purchase_rate_in_sales_uom): throw_message(item.idx, item.item_name, last_purchase_rate_in_sales_uom, "last purchase rate") @@ -331,50 +333,16 @@ class SellingController(StockController): if is_internal_customer or not is_stock_item: continue - valuation_rate_map[(item.item_code, item.warehouse)] = None - - if not valuation_rate_map: - return - - or_conditions = ( - f"""(item_code = {frappe.db.escape(valuation_rate[0])} - and warehouse = {frappe.db.escape(valuation_rate[1])})""" - for valuation_rate in valuation_rate_map - ) - - valuation_rates = frappe.db.sql( - f""" - select - item_code, warehouse, valuation_rate - from - `tabBin` - where - ({" or ".join(or_conditions)}) - and valuation_rate > 0 - """, - as_dict=True, - ) - - for rate in valuation_rates: - valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate - - for item in self.items: - if not item.item_code or item.is_free_item: - continue - - last_valuation_rate = valuation_rate_map.get((item.item_code, item.warehouse)) - - if not last_valuation_rate: - continue - - last_valuation_rate_in_sales_uom = last_valuation_rate * (item.conversion_factor or 1) - - if flt(item.base_net_rate) < flt(last_valuation_rate_in_sales_uom): + if item.get("incoming_rate") and item.base_net_rate < ( + valuation_rate := flt( + item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate") + ) + ): throw_message( item.idx, item.item_name, - last_valuation_rate_in_sales_uom, - "valuation rate (Moving Average)", + valuation_rate, + "valuation rate", ) def get_item_list(self): @@ -533,6 +501,8 @@ class SellingController(StockController): if self.doctype not in ("Delivery Note", "Sales Invoice"): return + from erpnext.stock.serial_batch_bundle import get_batch_nos + allow_at_arms_length_price = frappe.get_cached_value( "Stock Settings", None, "allow_internal_transfer_at_arms_length_price" ) @@ -540,6 +510,7 @@ class SellingController(StockController): "Selling Settings", "set_zero_rate_for_expired_batch" ) + old_doc = self.get_doc_before_save() items = self.get("items") + (self.get("packed_items") or []) for d in items: if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"): @@ -569,6 +540,29 @@ class SellingController(StockController): # Get incoming rate based on original item cost based on valuation method qty = flt(d.get("stock_qty") or d.get("actual_qty") or d.get("qty")) + if old_doc: + old_item = next( + ( + item + for item in (old_doc.get("items") + (old_doc.get("packed_items") or [])) + if item.name == d.name + ), + None, + ) + if old_item: + old_qty = flt( + old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty") + ) + if ( + old_item.item_code != d.item_code + or old_item.warehouse != d.warehouse + or old_qty != qty + or old_item.batch_no != d.batch_no + or get_batch_nos(old_item.serial_and_batch_bundle) + != get_batch_nos(d.serial_and_batch_bundle) + ): + d.incoming_rate = 0 + if ( not d.incoming_rate or self.is_internal_transfer() diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 70eb459112f..b16c95722a6 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -91,7 +91,8 @@ status_map = { ], "Delivery Note": [ ["Draft", None], - ["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"], + ["To Bill", "eval:self.per_billed == 0 and self.docstatus == 1"], + ["Partially Billed", "eval:self.per_billed < 100 and self.per_billed > 0 and self.docstatus == 1"], ["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"], ["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"], ["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"], diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 473ab7c86b3..11ad4f4d2ef 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -313,10 +313,10 @@ class SubcontractingController(StockController): ): for row in frappe.get_all( f"{self.subcontract_data.order_doctype} Item", - fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "name"], + fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "bom"], filters={"docstatus": 1, "parent": ("in", self.subcontract_orders)}, ): - self.qty_to_be_received[(row.item_code, row.parent)] += row.qty + self.qty_to_be_received[(row.item_code, row.parent, row.bom)] += row.qty def __get_transferred_items(self): se = frappe.qb.DocType("Stock Entry") @@ -922,13 +922,17 @@ class SubcontractingController(StockController): self.__set_serial_nos(item_row, rm_obj) def __get_qty_based_on_material_transfer(self, item_row, transfer_item): - key = (item_row.item_code, item_row.get(self.subcontract_data.order_field)) + key = ( + item_row.item_code, + item_row.get(self.subcontract_data.order_field), + item_row.get("bom"), + ) if self.qty_to_be_received == item_row.qty: return transfer_item.qty - if self.qty_to_be_received: - qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key, 0)) + if self.qty_to_be_received.get(key): + qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key)) transfer_item.item_details.required_qty = transfer_item.qty if transfer_item.serial_no or frappe.get_cached_value( @@ -977,7 +981,11 @@ class SubcontractingController(StockController): if self.qty_to_be_received: self.qty_to_be_received[ - (row.item_code, row.get(self.subcontract_data.order_field)) + ( + row.item_code, + row.get(self.subcontract_data.order_field), + row.get("bom"), + ) ] -= row.qty def __set_rate_for_serial_and_batch_bundle(self): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3fdd00237e7..e3235379127 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -461,3 +461,4 @@ erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing") erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2 erpnext.patches.v16_0.set_ordered_qty_in_quotation_item +erpnext.patches.v16_0.update_company_custom_field_in_bin \ No newline at end of file diff --git a/erpnext/patches/v16_0/update_company_custom_field_in_bin.py b/erpnext/patches/v16_0/update_company_custom_field_in_bin.py new file mode 100644 index 00000000000..e0b36d91fd7 --- /dev/null +++ b/erpnext/patches/v16_0/update_company_custom_field_in_bin.py @@ -0,0 +1,14 @@ +import frappe + + +def execute(): + frappe.reload_doc("stock", "doctype", "bin") + + frappe.db.sql( + """ + UPDATE `tabBin` b + INNER JOIN `tabWarehouse` w ON b.warehouse = w.name + SET b.company = w.company + WHERE b.company IS NULL OR b.company = '' + """ + ) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 20a421195eb..d9b025a7f92 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -308,6 +308,8 @@ class Project(Document): self.gross_margin = flt(self.total_billed_amount) - expense_amount if self.total_billed_amount: self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100 + else: + self.per_gross_margin = 0 def update_purchase_costing(self): total_purchase_cost = calculate_total_purchase_cost(self.name) diff --git a/erpnext/public/icons/desktop_icons/solid/account_setup.svg b/erpnext/public/icons/desktop_icons/solid/accounts_setup.svg similarity index 100% rename from erpnext/public/icons/desktop_icons/solid/account_setup.svg rename to erpnext/public/icons/desktop_icons/solid/accounts_setup.svg diff --git a/erpnext/public/icons/desktop_icons/subtle/account_setup.svg b/erpnext/public/icons/desktop_icons/subtle/accounts_setup.svg similarity index 100% rename from erpnext/public/icons/desktop_icons/subtle/account_setup.svg rename to erpnext/public/icons/desktop_icons/subtle/accounts_setup.svg diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 4ae844c6116..d4a4577d8a0 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -625,6 +625,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe callback: function (r) { if (!r.exc) { me.frm.refresh_fields(); + me.show_batch_dialog_if_required(item); } }, }); @@ -635,26 +636,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe process_item_selection(doc, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); + let update_stock = 0; var me = this; - var update_stock = 0, - show_batch_dialog = 0; item.weight_per_unit = 0; item.weight_uom = ""; item.uom = null; // make UOM blank to update the existing UOM when item changes item.conversion_factor = 0; - - if (["Sales Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) { - update_stock = cint(me.frm.doc.update_stock); - show_batch_dialog = update_stock; - } else if (this.frm.doc.doctype === "Purchase Receipt" || this.frm.doc.doctype === "Delivery Note") { - show_batch_dialog = 1; - } - - if (show_batch_dialog && item.use_serial_batch_fields === 1) { - show_batch_dialog = 0; - } - item.barcode = null; if (item.item_code || item.serial_no) { @@ -765,74 +753,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } }, () => me.toggle_conversion_factor(item), - () => { - if (show_batch_dialog && !frappe.flags.trigger_from_barcode_scanner) - return frappe.db - .get_value("Item", item.item_code, [ - "has_batch_no", - "has_serial_no", - ]) - .then((r) => { - if ( - r.message && - (r.message.has_batch_no || r.message.has_serial_no) - ) { - frappe.flags.hide_serial_batch_dialog = false; - } else { - show_batch_dialog = false; - } - }); - }, - () => { - // check if batch serial selector is disabled or not - if (show_batch_dialog && !frappe.flags.hide_serial_batch_dialog) - return frappe.db - .get_single_value( - "Stock Settings", - "disable_serial_no_and_batch_selector" - ) - .then((value) => { - if (value) { - frappe.flags.hide_serial_batch_dialog = true; - } - }); - }, - () => { - if ( - show_batch_dialog && - !frappe.flags.hide_serial_batch_dialog && - !frappe.flags.dialog_set - ) { - var d = locals[cdt][cdn]; - $.each(r.message, function (k, v) { - if (!d[k]) d[k] = v; - }); - - if (d.has_batch_no && d.has_serial_no) { - d.batch_no = undefined; - } - - frappe.flags.dialog_set = true; - erpnext.show_serial_batch_selector( - me.frm, - d, - (item) => { - me.frm.script_manager.trigger("qty", item.doctype, item.name); - if (!me.frm.doc.set_warehouse) - me.frm.script_manager.trigger( - "warehouse", - item.doctype, - item.name - ); - me.apply_price_list(item, true); - }, - undefined, - !frappe.flags.hide_serial_batch_dialog - ); - } else { - frappe.flags.dialog_set = false; - } - }, + () => me.show_batch_dialog_if_required(item), () => me.conversion_factor(doc, cdt, cdn, true), () => me.remove_pricing_rule(item), () => { @@ -853,6 +774,78 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } + show_batch_dialog_if_required(item) { + let show_batch_dialog = 0; + let update_stock = 0; + let me = this; + + if (!item.item_code) { + return; + } + + if (["Sales Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) { + update_stock = cint(me.frm.doc.update_stock); + show_batch_dialog = update_stock; + } else if (this.frm.doc.doctype === "Purchase Receipt" || this.frm.doc.doctype === "Delivery Note") { + show_batch_dialog = 1; + } + + if (show_batch_dialog && item.use_serial_batch_fields === 1) { + show_batch_dialog = 0; + } + + frappe.run_serially([ + () => { + if (show_batch_dialog && !frappe.flags.trigger_from_barcode_scanner) + return frappe.db + .get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]) + .then((r) => { + if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { + item.has_serial_no = r.message.has_serial_no; + item.has_batch_no = r.message.has_batch_no; + frappe.flags.hide_serial_batch_dialog = false; + } else { + show_batch_dialog = false; + } + }); + }, + () => { + // check if batch serial selector is disabled or not + if (show_batch_dialog && !frappe.flags.hide_serial_batch_dialog) + return frappe.db + .get_single_value("Stock Settings", "disable_serial_no_and_batch_selector") + .then((value) => { + if (value) { + frappe.flags.hide_serial_batch_dialog = true; + } + }); + }, + () => { + if (show_batch_dialog && !frappe.flags.hide_serial_batch_dialog && !frappe.flags.dialog_set) { + if (item.has_batch_no && item.has_serial_no) { + item.batch_no = undefined; + } + + frappe.flags.dialog_set = true; + erpnext.show_serial_batch_selector( + me.frm, + item, + (item) => { + me.frm.script_manager.trigger("qty", item.doctype, item.name); + if (!me.frm.doc.set_warehouse) + me.frm.script_manager.trigger("warehouse", item.doctype, item.name); + me.apply_price_list(item, true); + }, + undefined, + !frappe.flags.hide_serial_batch_dialog + ); + } else { + frappe.flags.dialog_set = false; + } + }, + ]); + } + price_list_rate(doc, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]); diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index dff5f1e3ead..355dadbc534 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -994,7 +994,7 @@ erpnext.utils.map_current_doc = function (opts) { if (opts.source_doctype) { let data_fields = []; - if (["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)) { + if (["Purchase Receipt", "Delivery Note", "Purchase Invoice"].includes(opts.source_doctype)) { let target_meta = frappe.get_meta(cur_frm.doc.doctype); if (target_meta.fields.find((f) => f.fieldname === "taxes")) { data_fields.push({ diff --git a/erpnext/public/js/utils/contact_address_quick_entry.js b/erpnext/public/js/utils/contact_address_quick_entry.js index 1a1d67b475a..f6ce5d0d28b 100644 --- a/erpnext/public/js/utils/contact_address_quick_entry.js +++ b/erpnext/public/js/utils/contact_address_quick_entry.js @@ -110,12 +110,6 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm options: "Country", mandatory_depends_on: "eval:doc.city || doc.address_line1", }, - { - label: __("Customer POS Id"), - fieldname: "customer_pos_id", - fieldtype: "Data", - hidden: 1, - }, ]; return variant_fields; diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index 2e8d38bebde..93e9b60f5a8 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -142,7 +142,14 @@ def download_zip(files, output_filename): def get_invoice_summary(items, taxes, item_wise_tax_details): summary_data = frappe._dict() - taxes_wise_tax_details = {d.tax_row: d for d in item_wise_tax_details} + taxes_wise_tax_details = {} + + for d in item_wise_tax_details: + if d.tax_row not in taxes_wise_tax_details: + taxes_wise_tax_details[d.tax_row] = [] + + taxes_wise_tax_details[d.tax_row].append(d) + for tax in taxes: # Include only VAT charges. if tax.charge_type == "Actual": diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 500fe723fd0..e306814027d 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -113,12 +113,37 @@ class Customer(TransactionBase): def get_customer_name(self): self.customer_name = self.customer_name.strip() if frappe.db.get_value("Customer", self.customer_name) and not frappe.flags.in_import: - count = frappe.db.sql( - """select ifnull(MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), 0) from tabCustomer - where name like %s""", - f"%{self.customer_name} - %", - as_list=1, - )[0][0] + name_prefix = f"{self.customer_name} - %" + + if frappe.db.db_type == "postgres": + # Postgres: extract trailing digits (e.g. "Customer - 3") and cast to int. + # NOTE: PostgreSQL is strict about types; MySQL's UNSIGNED cast does not exist. + count = frappe.db.sql( + """ + SELECT COALESCE( + MAX(CAST(SUBSTRING(name FROM '\\d+$') AS INTEGER)), + 0 + ) + FROM tabCustomer + WHERE name LIKE %(name_prefix)s + """, + {"name_prefix": name_prefix}, + as_list=1, + )[0][0] + else: + # MariaDB/MySQL: keep existing behavior. + count = frappe.db.sql( + """ + SELECT COALESCE( + MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), + 0 + ) + FROM tabCustomer + WHERE name LIKE %(name_prefix)s + """, + {"name_prefix": name_prefix}, + as_list=1, + )[0][0] count = cint(count) + 1 new_customer_name = f"{self.customer_name} - {cstr(count)}" @@ -506,6 +531,9 @@ def _set_missing_values(source, target): if contact: target.contact_person = contact[0].parent + target.contact_display, target.contact_email, target.contact_mobile = frappe.get_value( + "Contact", contact[0].parent, ["full_name", "email_id", "mobile_no"] + ) @frappe.whitelist() diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index de17b04c450..f8ef775e9e4 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -11,18 +11,18 @@ "field_order": [ "customer_section", "column_break0", + "company", "naming_series", - "customer", - "customer_name", - "tax_id", "order_type", "column_break_7", "transaction_date", "delivery_date", "column_break1", + "customer", + "customer_name", + "tax_id", "po_no", "po_date", - "company", "skip_delivery_note", "has_unit_price_items", "is_subcontracted", @@ -1713,7 +1713,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-01-29 21:23:48.362401", + "modified": "2026-02-03 14:45:50.314361", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/stock/doctype/bin/bin.json b/erpnext/stock/doctype/bin/bin.json index 7de1aa9d189..b0668896597 100644 --- a/erpnext/stock/doctype/bin/bin.json +++ b/erpnext/stock/doctype/bin/bin.json @@ -22,6 +22,7 @@ "reserved_stock", "section_break_pmrs", "stock_uom", + "company", "column_break_0slj", "valuation_rate", "stock_value" @@ -132,6 +133,14 @@ "options": "UOM", "read_only": 1 }, + { + "fetch_from": "warehouse.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1 + }, { "fieldname": "valuation_rate", "fieldtype": "Float", @@ -186,7 +195,7 @@ "idx": 1, "in_create": 1, "links": [], - "modified": "2024-03-27 13:06:39.414036", + "modified": "2026-02-01 08:11:46.824913", "modified_by": "Administrator", "module": "Stock", "name": "Bin", @@ -231,8 +240,9 @@ } ], "quick_entry": 1, + "row_format": "Dynamic", "search_fields": "item_code,warehouse", "sort_field": "creation", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 62c4528f432..ae1c44d6419 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -19,6 +19,7 @@ class Bin(Document): from frappe.types import DF actual_qty: DF.Float + company: DF.Link | None indented_qty: DF.Float item_code: DF.Link ordered_qty: DF.Float diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 52392e96866..9abe8c8c409 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1070,7 +1070,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "\nDraft\nTo Bill\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed", + "options": "\nDraft\nTo Bill\nPartially Billed\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed", "print_hide": 1, "print_width": "150px", "read_only": 1, @@ -1434,7 +1434,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2026-01-29 21:24:11.781261", + "modified": "2026-02-03 12:27:19.055918", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 3b52f91b492..07c1623a182 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -127,7 +127,15 @@ class DeliveryNote(SellingController): shipping_address_name: DF.Link | None shipping_rule: DF.Link | None status: DF.Literal[ - "", "Draft", "To Bill", "Completed", "Return", "Return Issued", "Cancelled", "Closed" + "", + "Draft", + "To Bill", + "Partially Billed", + "Completed", + "Return", + "Return Issued", + "Cancelled", + "Closed", ] tax_category: DF.Link | None tax_id: DF.Data | None diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index fccc401931e..56698ccf76b 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -18,8 +18,10 @@ frappe.listview_settings["Delivery Note"] = { return [__("Closed"), "green", "status,=,Closed"]; } else if (doc.status === "Return Issued") { return [__("Return Issued"), "grey", "status,=,Return Issued"]; - } else if (flt(doc.per_billed, 2) < 100) { - return [__("To Bill"), "orange", "per_billed,<,100|docstatus,=,1"]; + } else if (flt(doc.per_billed) == 0) { + return [__("To Bill"), "orange", "per_billed,=,0|docstatus,=,1"]; + } else if (flt(doc.per_billed, 2) > 0 && flt(doc.per_billed, 2) < 100) { + return [__("Partially Billed"), "yellow", "per_billed,<,100|docstatus,=,1"]; } else if (flt(doc.per_billed, 2) === 100) { return [__("Completed"), "green", "per_billed,=,100|docstatus,=,1"]; } diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index adb75eea023..02713b6f917 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1101,7 +1101,8 @@ class TestDeliveryNote(IntegrationTestCase): self.assertEqual(dn2.get("items")[0].billed_amt, 400) self.assertEqual(dn2.per_billed, 80) - self.assertEqual(dn2.status, "To Bill") + # Since 20% of DN2 is yet to be billed, it should be classified as partially billed. + self.assertEqual(dn2.status, "Partially Billed") def test_dn_billing_status_case4(self): # SO -> SI -> DN @@ -2863,6 +2864,23 @@ class TestDeliveryNote(IntegrationTestCase): for entry in sabb.entries: self.assertEqual(entry.incoming_rate, 200) + @IntegrationTestCase.change_settings("Selling Settings", {"validate_selling_price": 1}) + def test_validate_selling_price(self): + item_code = make_item("VSP Item", properties={"is_stock_item": 1}).name + make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=10) + make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=1) + + dn = create_delivery_note( + item_code=item_code, + qty=1, + rate=9, + do_not_save=True, + ) + self.assertRaises(frappe.ValidationError, dn.save) + dn.items[0].incoming_rate = 0 + dn.items[0].stock_qty = 2 + dn.save() + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 5b7534d3247..7f23aeb16c4 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5111,6 +5111,128 @@ class TestPurchaseReceipt(IntegrationTestCase): self.assertEqual(stk_ledger.incoming_rate, 120) self.assertEqual(stk_ledger.stock_value_difference, 600) + def test_negative_stock_error_for_purchase_return_when_stock_exists_in_future_date(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + from erpnext.stock.stock_ledger import NegativeStockError + + item_code = make_item( + "Test Negative Stock for Purchase Return with Future Stock Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TNSPFPRI.#####", + }, + ).name + + make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -4), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + pr1 = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -3), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch1 = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle) + + pr2 = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -2), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch2 = get_batch_from_bundle(pr2.items[0].serial_and_batch_bundle) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=add_days(today(), -1), + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch1, + use_serial_batch_fields=1, + ) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=add_days(today(), -1), + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch2, + use_serial_batch_fields=1, + ) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=today(), + source="_Test Warehouse 1 - _TC", + target="_Test Warehouse - _TC", + batch_no=batch1, + use_serial_batch_fields=1, + ) + + make_purchase_entry = make_return_doc("Purchase Receipt", pr1.name) + make_purchase_entry.set_posting_time = 1 + make_purchase_entry.posting_date = pr1.posting_date + self.assertRaises(NegativeStockError, make_purchase_entry.submit) + + def test_purchase_return_from_different_warehouse(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = make_item( + "Test Purchase Return From Different Warehouse Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TPRFDWU.#####", + }, + ).name + + pr1 = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -4), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch1 = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=add_days(today(), -1), + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch1, + use_serial_batch_fields=1, + ) + + make_purchase_entry = make_return_doc("Purchase Receipt", pr1.name) + make_purchase_entry.items[0].warehouse = "_Test Warehouse 1 - _TC" + make_purchase_entry.submit() + make_purchase_entry.reload() + + sabb = frappe.get_doc("Serial and Batch Bundle", make_purchase_entry.items[0].serial_and_batch_bundle) + for row in sabb.entries: + self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC") + self.assertEqual(row.incoming_rate, 100) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 2a9bcd4edd6..bd1e6902da0 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -17,6 +17,7 @@ from frappe.utils import ( cint, cstr, flt, + get_datetime, get_link_to_form, getdate, now, @@ -438,6 +439,8 @@ class SerialandBatchBundle(Document): ) def get_valuation_rate_for_return_entry(self, return_against): + from erpnext.controllers.sales_and_purchase_return import get_warehouses_for_return + if not self.voucher_detail_no: return {} @@ -467,9 +470,11 @@ class SerialandBatchBundle(Document): ["Serial and Batch Bundle", "voucher_detail_no", "=", return_against_voucher_detail_no], ] + # Added to handle rejected warehouse case if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]: - # Added to handle rejected warehouse case - filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse]) + warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no) + if self.warehouse in warehouses: + filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse]) bundle_data = frappe.get_all( "Serial and Batch Bundle", @@ -1451,31 +1456,44 @@ class SerialandBatchBundle(Document): for d in self.entries: available_qty = batch_wise_available_qty.get(d.batch_no, 0) if flt(available_qty, precision) < 0: - frappe.throw( - _( - """ - The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry.""" - ).format( - bold(d.batch_no), - bold(self.item_code), - bold(self.warehouse), - bold(abs(flt(available_qty, precision))), - ), - title=_("Negative Stock Error"), - ) + self.throw_negative_batch(d.batch_no, available_qty, precision) + + def throw_negative_batch(self, batch_no, available_qty, precision): + from erpnext.stock.stock_ledger import NegativeStockError + + frappe.throw( + _( + """ + The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry.""" + ).format( + bold(batch_no), + bold(self.item_code), + bold(self.warehouse), + bold(abs(flt(available_qty, precision))), + ), + title=_("Negative Stock Error"), + exc=NegativeStockError, + ) def get_batchwise_available_qty(self): - available_qty = self.get_available_qty_from_sabb() - available_qty_from_ledger = self.get_available_qty_from_stock_ledger() + batchwise_entries = self.get_available_qty_from_sabb() + batchwise_entries.extend(self.get_available_qty_from_stock_ledger()) - if not available_qty_from_ledger: - return available_qty + available_qty = frappe._dict({}) + batchwise_entries = sorted( + batchwise_entries, + key=lambda x: (get_datetime(x.get("posting_datetime")), get_datetime(x.get("creation"))), + ) - for batch_no, qty in available_qty_from_ledger.items(): - if batch_no in available_qty: - available_qty[batch_no] += qty + precision = frappe.get_precision("Serial and Batch Entry", "qty") + for row in batchwise_entries: + if row.batch_no in available_qty: + available_qty[row.batch_no] += flt(row.qty) else: - available_qty[batch_no] = qty + available_qty[row.batch_no] = flt(row.qty) + + if flt(available_qty[row.batch_no], precision) < 0: + self.throw_negative_batch(row.batch_no, available_qty[row.batch_no], precision) return available_qty @@ -1488,7 +1506,9 @@ class SerialandBatchBundle(Document): frappe.qb.from_(sle) .select( sle.batch_no, - Sum(sle.actual_qty).as_("available_qty"), + sle.actual_qty.as_("qty"), + sle.posting_datetime, + sle.creation, ) .where( (sle.item_code == self.item_code) @@ -1500,12 +1520,9 @@ class SerialandBatchBundle(Document): & (sle.batch_no.isnotnull()) ) .for_update() - .groupby(sle.batch_no) ) - res = query.run(as_list=True) - - return frappe._dict(res) if res else frappe._dict() + return query.run(as_dict=True) def get_available_qty_from_sabb(self): batches = [d.batch_no for d in self.entries if d.batch_no] @@ -1516,7 +1533,9 @@ class SerialandBatchBundle(Document): frappe.qb.from_(child) .select( child.batch_no, - Sum(child.qty).as_("available_qty"), + child.qty, + child.posting_datetime, + child.creation, ) .where( (child.item_code == self.item_code) @@ -1527,13 +1546,10 @@ class SerialandBatchBundle(Document): & (child.type_of_transaction.isin(["Inward", "Outward"])) ) .for_update() - .groupby(child.batch_no) ) query = query.where(child.voucher_type != "Pick List") - res = query.run(as_list=True) - - return frappe._dict(res) if res else frappe._dict() + return query.run(as_dict=True) def validate_voucher_no_docstatus(self): if self.voucher_type == "POS Invoice": @@ -2596,11 +2612,11 @@ def get_reserved_batches_for_pos(kwargs) -> dict: key = (row.batch_no, row.warehouse) if key in pos_batches: - pos_batches[key]["qty"] -= row.qty * -1 if row.is_return else row.qty + pos_batches[key]["qty"] += row.qty * -1 else: pos_batches[key] = frappe._dict( { - "qty": (row.qty * -1 if not row.is_return else row.qty), + "qty": row.qty * -1, "warehouse": row.warehouse, } ) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 22ca84f6e38..e5b6a647724 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1271,15 +1271,11 @@ def get_items(warehouse, posting_date, posting_time, company, item_code=None, ig for d in items: if (d.item_code, d.warehouse) in itemwise_batch_data: - valuation_rate = get_stock_balance( - d.item_code, d.warehouse, posting_date, posting_time, with_valuation_rate=True - )[1] - for row in itemwise_batch_data.get((d.item_code, d.warehouse)): if ignore_empty_stock and not row.qty: continue - args = get_item_data(row, row.qty, valuation_rate) + args = get_item_data(row, row.qty, row.valuation_rate) res.append(args) else: stock_bal = get_stock_balance( @@ -1413,6 +1409,7 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None): "item_code": row[0], "warehouse": row[3], "qty": row[8], + "valuation_rate": row[9], "item_name": row[1], "batch_no": row[4], } diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 026a064c7dc..559e1b31a36 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -282,7 +282,11 @@ class StockBalanceReport: for field in self.inventory_dimensions: qty_dict[field] = entry.get(field) - if entry.voucher_type == "Stock Reconciliation" and (not entry.batch_no or entry.serial_no): + if ( + entry.voucher_type == "Stock Reconciliation" + and frappe.get_cached_value(entry.voucher_type, entry.voucher_no, "purpose") != "Opening Stock" + and (not entry.batch_no or entry.serial_no) + ): qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty) else: qty_diff = flt(entry.actual_qty) diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index e650fb607ba..b2401da4f8f 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -3,6 +3,7 @@ import frappe +from frappe.query_builder.functions import Coalesce, Sum from frappe.utils import cstr, flt, now, nowdate, nowtime from erpnext.controllers.stock_controller import create_repost_item_valuation_entry @@ -182,18 +183,67 @@ def get_indented_qty(item_code, warehouse): def get_ordered_qty(item_code, warehouse): - ordered_qty = frappe.db.sql( - """ - select sum((po_item.qty - po_item.received_qty)*po_item.conversion_factor) - from `tabPurchase Order Item` po_item, `tabPurchase Order` po - where po_item.item_code=%s and po_item.warehouse=%s - and po_item.qty > po_item.received_qty and po_item.parent=po.name - and po.status not in ('Closed', 'Delivered') and po.docstatus=1 - and po_item.delivered_by_supplier = 0""", - (item_code, warehouse), + """Return total pending ordered quantity for an item in a warehouse. + Includes outstanding quantities from Purchase Orders and Subcontracting Orders""" + + purchase_order_qty = get_purchase_order_qty(item_code, warehouse) + subcontracting_order_qty = get_subcontracting_order_qty(item_code, warehouse) + + return flt(purchase_order_qty) + flt(subcontracting_order_qty) + + +def get_purchase_order_qty(item_code, warehouse): + PurchaseOrder = frappe.qb.DocType("Purchase Order") + PurchaseOrderItem = frappe.qb.DocType("Purchase Order Item") + + purchase_order_qty = ( + frappe.qb.from_(PurchaseOrderItem) + .join(PurchaseOrder) + .on(PurchaseOrderItem.parent == PurchaseOrder.name) + .select( + Sum( + (PurchaseOrderItem.qty - PurchaseOrderItem.received_qty) * PurchaseOrderItem.conversion_factor + ) + ) + .where( + (PurchaseOrderItem.item_code == item_code) + & (PurchaseOrderItem.warehouse == warehouse) + & (PurchaseOrderItem.qty > PurchaseOrderItem.received_qty) + & (PurchaseOrder.status.notin(["Closed", "Delivered"])) + & (PurchaseOrder.docstatus == 1) + & (Coalesce(PurchaseOrderItem.delivered_by_supplier, 0) == 0) + ) + .run() ) - return flt(ordered_qty[0][0]) if ordered_qty else 0 + return purchase_order_qty[0][0] if purchase_order_qty else 0 + + +def get_subcontracting_order_qty(item_code, warehouse): + SubcontractingOrder = frappe.qb.DocType("Subcontracting Order") + SubcontractingOrderItem = frappe.qb.DocType("Subcontracting Order Item") + + subcontracting_order_qty = ( + frappe.qb.from_(SubcontractingOrderItem) + .join(SubcontractingOrder) + .on(SubcontractingOrderItem.parent == SubcontractingOrder.name) + .select( + Sum( + (SubcontractingOrderItem.qty - SubcontractingOrderItem.received_qty) + * SubcontractingOrderItem.conversion_factor + ) + ) + .where( + (SubcontractingOrderItem.item_code == item_code) + & (SubcontractingOrderItem.warehouse == warehouse) + & (SubcontractingOrderItem.qty > SubcontractingOrderItem.received_qty) + & (SubcontractingOrder.status.notin(["Closed", "Completed"])) + & (SubcontractingOrder.docstatus == 1) + ) + .run() + ) + + return subcontracting_order_qty[0][0] if subcontracting_order_qty else 0 def get_planned_qty(item_code, warehouse): diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index ee9cf7a8ee5..8eb369d120f 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -12,7 +12,7 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor StockReservation, has_reserved_stock, ) -from erpnext.stock.stock_balance import update_bin_qty +from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty from erpnext.stock.utils import get_bin @@ -234,30 +234,7 @@ class SubcontractingOrder(SubcontractingController): ): item_wh_list.append([item.item_code, item.warehouse]) for item_code, warehouse in item_wh_list: - update_bin_qty(item_code, warehouse, {"ordered_qty": self.get_ordered_qty(item_code, warehouse)}) - - @staticmethod - def get_ordered_qty(item_code, warehouse): - table = frappe.qb.DocType("Subcontracting Order") - child = frappe.qb.DocType("Subcontracting Order Item") - - query = ( - frappe.qb.from_(table) - .inner_join(child) - .on(table.name == child.parent) - .select((child.qty - child.received_qty) * child.conversion_factor) - .where( - (table.docstatus == 1) - & (child.item_code == item_code) - & (child.warehouse == warehouse) - & (child.qty > child.received_qty) - & (table.status != "Completed") - ) - ) - - query = query.run() - - return flt(query[0][0]) if query else 0 + update_bin_qty(item_code, warehouse, {"ordered_qty": get_ordered_qty(item_code, warehouse)}) def update_reserved_qty_for_subcontracting(self, sco_item_rows=None): for item in self.supplied_items: diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 2a69083bc0f..29ecdd24b82 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -616,6 +616,117 @@ class TestSubcontractingReceipt(IntegrationTestCase): for item in scr.supplied_items: self.assertFalse(item.available_qty_for_consumption) + def test_supplied_items_consumed_qty_for_similar_finished_goods(self): + """ + Test that supplied raw material consumption is calculated correctly + when multiple subcontracted service items use the same finished good + but different BOMs. + """ + + from erpnext.controllers.subcontracting_controller import ( + make_rm_stock_entry as make_subcontract_transfer_entry, + ) + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + # Configuration: Backflush based on subcontract material transfer + set_backflush_based_on("Material Transferred for Subcontract") + + # Create Raw Materials + raw_material_1 = make_item("_RM Item 1", properties={"is_stock_item": 1}).name + + raw_material_2 = make_item("_RM Item 2", properties={"is_stock_item": 1}).name + + # Create Subcontracted Finished Good + finished_good = make_item("_Finished Good Item", properties={"is_stock_item": 1}) + finished_good.is_sub_contracted_item = 1 + finished_good.save() + + # Receive Raw Materials into Warehouse + for raw_material in (raw_material_1, raw_material_2): + make_stock_entry( + item_code=raw_material, + qty=10, + target="_Test Warehouse - _TC", + basic_rate=100, + ) + + # Create BOMs for the same Finished Good with different RMs + bom_rm_1 = make_bom( + item=finished_good.name, + quantity=1, + raw_materials=[raw_material_1], + ).name + + _bom_rm_2 = make_bom( + item=finished_good.name, + quantity=1, + raw_materials=[raw_material_2], + ).name + + # Define Subcontracted Service Items + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 1, + "rate": 100, + "fg_item": finished_good.name, + "fg_item_qty": 10, + }, + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 1, + "rate": 150, + "fg_item": finished_good.name, + "fg_item_qty": 10, + }, + ] + + # Create Subcontracting Order + subcontracting_order = get_subcontracting_order( + service_items=service_items, + do_not_save=True, + ) + + # Assign BOM only to the first service item + subcontracting_order.items[0].bom = bom_rm_1 + subcontracting_order.save() + subcontracting_order.submit() + + # Prepare Raw Material Transfer Items + raw_material_transfer_items = [] + for supplied_item in subcontracting_order.supplied_items: + raw_material_transfer_items.append( + { + "item_code": supplied_item.main_item_code, + "rm_item_code": supplied_item.rm_item_code, + "qty": supplied_item.required_qty, + "warehouse": "_Test Warehouse - _TC", + "stock_uom": "Nos", + } + ) + + # Transfer Raw Materials to Subcontractor Warehouse + stock_entry = frappe.get_doc( + make_subcontract_transfer_entry( + subcontracting_order.name, + raw_material_transfer_items, + ) + ) + stock_entry.to_warehouse = "_Test Warehouse 1 - _TC" + stock_entry.save() + stock_entry.submit() + + # Create Subcontracting Receipt + subcontracting_receipt = make_subcontracting_receipt(subcontracting_order.name) + subcontracting_receipt.save() + + # Check consumed_qty for each supplied item + self.assertEqual(len(subcontracting_receipt.supplied_items), 2) + self.assertEqual(subcontracting_receipt.supplied_items[0].consumed_qty, 10) + self.assertEqual(subcontracting_receipt.supplied_items[1].consumed_qty, 10) + def test_supplied_items_cost_after_reposting(self): # Set Backflush Based On as "BOM" set_backflush_based_on("BOM") diff --git a/erpnext/workspace_sidebar/invoicing.json b/erpnext/workspace_sidebar/invoicing.json index 104c8547d5f..99a6b367953 100644 --- a/erpnext/workspace_sidebar/invoicing.json +++ b/erpnext/workspace_sidebar/invoicing.json @@ -219,7 +219,7 @@ "collapsible": 1, "indent": 0, "keep_closed": 0, - "label": "Payment Reconciliaition", + "label": "Payment Reconciliation", "link_to": "Payment Reconciliation", "link_type": "DocType", "show_arrow": 0, diff --git a/erpnext/workspace_sidebar/taxes.json b/erpnext/workspace_sidebar/taxes.json index 5cf65ff3c67..64b343ca215 100644 --- a/erpnext/workspace_sidebar/taxes.json +++ b/erpnext/workspace_sidebar/taxes.json @@ -13,7 +13,7 @@ "indent": 0, "keep_closed": 0, "label": "Sales Tax Template", - "link_to": "Item Tax Template", + "link_to": "Sales Taxes and Charges Template", "link_type": "DocType", "navigate_to_tab": "", "show_arrow": 0, @@ -148,7 +148,7 @@ "type": "Link" } ], - "modified": "2026-01-10 00:06:13.005238", + "modified": "2026-02-01 00:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Taxes",