diff --git a/erpnext/accounts/doctype/bank_account/bank_account.js b/erpnext/accounts/doctype/bank_account/bank_account.js index 202f750fb50..5173e0539f4 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.js +++ b/erpnext/accounts/doctype/bank_account/bank_account.js @@ -42,8 +42,4 @@ frappe.ui.form.on("Bank Account", { }); } }, - - is_company_account: function (frm) { - frm.set_df_property("account", "reqd", frm.doc.is_company_account); - }, }); diff --git a/erpnext/accounts/doctype/bank_account/bank_account.json b/erpnext/accounts/doctype/bank_account/bank_account.json index 9ecd9c53503..b44ccb56835 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.json +++ b/erpnext/accounts/doctype/bank_account/bank_account.json @@ -52,6 +52,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Company Account", + "mandatory_depends_on": "is_company_account", "options": "Account" }, { @@ -98,6 +99,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Company", + "mandatory_depends_on": "is_company_account", "options": "Company" }, { @@ -252,7 +254,7 @@ "link_fieldname": "default_bank_account" } ], - "modified": "2025-08-29 12:32:01.081687", + "modified": "2026-01-20 00:46:16.633364", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Account", diff --git a/erpnext/accounts/doctype/bank_account/bank_account.py b/erpnext/accounts/doctype/bank_account/bank_account.py index d8dc1191bf7..c0dc6467f8f 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.py +++ b/erpnext/accounts/doctype/bank_account/bank_account.py @@ -51,25 +51,29 @@ class BankAccount(Document): delete_contact_and_address("Bank Account", self.name) def validate(self): - self.validate_company() - self.validate_account() + self.validate_is_company_account() self.update_default_bank_account() - def validate_account(self): - if self.account: - if accounts := frappe.db.get_all( - "Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1 - ): - frappe.throw( - _("'{0}' account is already used by {1}. Use another account.").format( - frappe.bold(self.account), - frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])), - ) - ) + def validate_is_company_account(self): + if self.is_company_account: + if not self.company: + frappe.throw(_("Company is mandatory for company account")) - def validate_company(self): - if self.is_company_account and not self.company: - frappe.throw(_("Company is mandatory for company account")) + if not self.account: + frappe.throw(_("Company Account is mandatory")) + + self.validate_account() + + def validate_account(self): + if accounts := frappe.db.get_all( + "Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1 + ): + frappe.throw( + _("'{0}' account is already used by {1}. Use another account.").format( + frappe.bold(self.account), + frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])), + ) + ) def update_default_bank_account(self): if self.is_default and not self.disabled: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 3020e4e6659..b65fff308b1 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1104,7 +1104,7 @@ frappe.ui.form.on("Payment Entry", { allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) { await frm.call("allocate_amount_to_references", { - paid_amount: paid_amount, + paid_amount: flt(paid_amount), paid_amount_change: paid_amount_change, allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false, }); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index c50eaa69a9f..0ba6feef6da 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -115,18 +115,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( } if (cint(doc.update_stock) != 1) { - // show Make Delivery Note button only if Sales Invoice is not created from Delivery Note - var from_delivery_note = false; - from_delivery_note = this.frm.doc.items.some(function (item) { - return item.delivery_note ? true : false; - }); - - if (!from_delivery_note && !is_delivered_by_supplier) { - this.frm.add_custom_button( - __("Delivery"), - this.frm.cscript["Make Delivery Note"], - __("Create") + if (!is_delivered_by_supplier) { + const should_create_delivery_note = doc.items.some( + (item) => + item.qty - item.delivered_qty > 0 && + !item.scio_detail && + !item.dn_detail && + !item.delivered_by_supplier ); + if (should_create_delivery_note) { + this.frm.add_custom_button( + __("Delivery Note"), + this.frm.cscript["Make Delivery Note"], + __("Create") + ); + } } } diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index e987a5ad099..a87ad06564e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -2422,7 +2422,10 @@ def make_delivery_note(source_name, target_doc=None): "cost_center": "cost_center", }, "postprocess": update_item, - "condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail, + "condition": lambda doc: doc.delivered_by_supplier != 1 + and not doc.scio_detail + and not doc.dn_detail + and doc.qty - doc.delivered_qty > 0, }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, "Sales Team": { diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index dc65883eb15..1c5c483bac4 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -116,14 +116,6 @@ frappe.ui.form.on("Asset", { __("Manage") ); - frm.add_custom_button( - __("Repair Asset"), - function () { - frm.trigger("create_asset_repair"); - }, - __("Manage") - ); - frm.add_custom_button( __("Split Asset"), function () { @@ -155,6 +147,14 @@ frappe.ui.form.on("Asset", { }, __("Manage") ); + + frm.add_custom_button( + __("Repair Asset"), + function () { + frm.trigger("create_asset_repair"); + }, + __("Manage") + ); } if (!frm.doc.calculate_depreciation) { diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 916d9865662..aa09f0ca956 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -552,7 +552,7 @@ class StockController(AccountsController): if is_rejected: serial_nos = row.get("rejected_serial_no") type_of_transaction = "Inward" if not self.is_return else "Outward" - qty = row.get("rejected_qty") + qty = row.get("rejected_qty") * row.get("conversion_factor", 1.0) warehouse = row.get("rejected_warehouse") if ( diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 33c1edbcb72..c825159df71 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -166,29 +166,46 @@ class SubcontractingController(StockController): _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) ) - if self.doctype != "Subcontracting Receipt" and item.qty > flt( - get_pending_subcontracted_quantity( - self.doctype, - self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order, - ).get( - item.purchase_order_item - if self.doctype == "Subcontracting Order" - else item.sales_order_item - ) - / item.subcontracting_conversion_factor, - frappe.get_precision( + if self.doctype != "Subcontracting Receipt": + order_item_doctype = ( "Purchase Order Item" if self.doctype == "Subcontracting Order" - else "Sales Order Item", - "qty", - ), - ): - frappe.throw( - _( - "Row {0}: Item {1}'s quantity cannot be higher than the available quantity." - ).format(item.idx, item.item_name) + else "Sales Order Item" ) + order_name = ( + self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order + ) + order_item_field = frappe.scrub(order_item_doctype) + + if not item.get(order_item_field): + frappe.throw( + _("Row {0}: Item {1} must be linked to a {2}.").format( + item.idx, item.item_name, order_item_doctype + ) + ) + + pending_qty = flt( + flt( + get_pending_subcontracted_quantity( + order_item_doctype, + order_name, + ).get(item.get(order_item_field)) + ) + / item.subcontracting_conversion_factor, + frappe.get_precision( + order_item_doctype, + "qty", + ), + ) + + if item.qty > pending_qty: + frappe.throw( + _( + "Row {0}: Item {1}'s quantity cannot be higher than the available quantity." + ).format(item.idx, item.item_name) + ) + if self.doctype != "Subcontracting Inward Order": item.amount = item.qty * item.rate @@ -1333,9 +1350,7 @@ def get_item_details(items): def get_pending_subcontracted_quantity(doctype, name): - table = frappe.qb.DocType( - "Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item" - ) + table = frappe.qb.DocType(doctype) query = ( frappe.qb.from_(table) .select(table.name, table.stock_qty, table.subcontracted_qty) diff --git a/erpnext/controllers/subcontracting_inward_controller.py b/erpnext/controllers/subcontracting_inward_controller.py index 056bfcdec9d..1a3ff66b825 100644 --- a/erpnext/controllers/subcontracting_inward_controller.py +++ b/erpnext/controllers/subcontracting_inward_controller.py @@ -720,6 +720,7 @@ class SubcontractingInwardController: item.db_set("scio_detail", scio_rm.name) if data: + precision = self.precision("customer_provided_item_cost", "items") result = frappe.get_all( "Subcontracting Inward Order Received Item", filters={ @@ -734,10 +735,17 @@ class SubcontractingInwardController: table = frappe.qb.DocType("Subcontracting Inward Order Received Item") case_expr_qty, case_expr_rate = Case(), Case() for d in result: - d.received_qty += ( - data[d.name].transfer_qty if self._action == "submit" else -data[d.name].transfer_qty + current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1) + current_rate = flt(data[d.name].rate) + + # Calculate weighted average rate + old_total = d.rate * d.received_qty + current_total = current_rate * current_qty + + d.received_qty = d.received_qty + current_qty + d.rate = ( + flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0 ) - d.rate += data[d.name].rate if self._action == "submit" else -data[d.name].rate if not d.required_qty and not d.received_qty: deleted_docs.append(d.name) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 3b0f338cf84..ca7efec8e58 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -569,6 +569,7 @@ accounting_dimension_doctypes = [ "Payment Request", "Asset Movement Item", "Asset Depreciation Schedule", + "Advance Taxes and Charges", ] get_matching_queries = ( diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 0840192ccfd..571e43a3d30 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3725,6 +3725,53 @@ class TestWorkOrder(IntegrationTestCase): wo = make_wo_order_test_record(item="Top Level Parent") self.assertEqual([item.item_code for item in wo.required_items], expected) + def test_reserved_qty_for_pp_with_extra_material_transfer(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + rm_item_code = make_item( + "_Test Reserved Qty PP Item", + { + "is_stock_item": 1, + }, + ).name + + fg_item_code = make_item( + "_Test Reserved Qty PP FG Item", + { + "is_stock_item": 1, + }, + ).name + + make_stock_entry_test_record( + item_code=rm_item_code, target="_Test Warehouse - _TC", qty=10, basic_rate=100 + ) + + make_bom( + item=fg_item_code, + raw_materials=[rm_item_code], + ) + + wo_order = make_wo_order_test_record( + item=fg_item_code, + qty=1, + source_warehouse="_Test Warehouse - _TC", + skip_transfer=0, + target_warehouse="_Test Warehouse - _TC", + ) + + bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC") + self.assertEqual(bin1_at_completion.reserved_qty_for_production, 1) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1)) + s.items[0].qty += 2 # extra material transfer + s.submit() + + bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC") + + self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0) + def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 0d99a923a00..e816c4690df 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -829,7 +829,7 @@ erpnext.work_order = { } } if (counter > 0) { - var consumption_btn = frm.add_custom_button( + frm.add_custom_button( __("Material Consumption"), function () { const backflush_raw_materials_based_on = diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index faa6b08398d..80cfc0c2a6e 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -770,6 +770,7 @@ class WorkOrder(Document): self.db_set("status", "Cancelled") self.on_close_or_cancel() + self.delete_job_card() def on_close_or_cancel(self): if self.production_plan and frappe.db.exists( @@ -779,7 +780,6 @@ class WorkOrder(Document): else: self.update_work_order_qty_in_so() - self.delete_job_card() self.update_completed_qty_in_material_request() self.update_planned_qty() self.update_ordered_qty() @@ -2654,6 +2654,9 @@ def get_reserved_qty_for_production( qty_field = wo_item.required_qty else: qty_field = Case() + qty_field = qty_field.when( + ((wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty)), 0.0 + ) qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c82e257dc6c..6978d0a634e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -458,3 +458,4 @@ erpnext.patches.v16_0.update_corrected_cancelled_status erpnext.patches.v16_0.fix_barcode_typo erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing") +erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges diff --git a/erpnext/patches/v15_0/create_accounting_dimensions_in_advance_taxes_and_charges.py b/erpnext/patches/v15_0/create_accounting_dimensions_in_advance_taxes_and_charges.py new file mode 100644 index 00000000000..201b16b1e00 --- /dev/null +++ b/erpnext/patches/v15_0/create_accounting_dimensions_in_advance_taxes_and_charges.py @@ -0,0 +1,7 @@ +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + create_accounting_dimensions_for_doctype, +) + + +def execute(): + create_accounting_dimensions_for_doctype(doctype="Advance Taxes and Charges") diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index c7023dd9423..08a1c0433ad 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -603,7 +603,7 @@ def send_project_update_email_to_users(project): "sent": 0, "date": today(), "time": nowtime(), - "naming_series": "UPDATE-.project.-.YY.MM.DD.-", + "naming_series": "UPDATE-.project.-.YY.MM.DD.-.####", } ).insert() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 54474305643..4ae844c6116 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -3131,10 +3131,16 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe set_warehouse() { this.autofill_warehouse(this.frm.doc.items, "warehouse", this.frm.doc.set_warehouse); + this.autofill_warehouse(this.frm.doc.packed_items, "warehouse", this.frm.doc.set_warehouse); } set_target_warehouse() { this.autofill_warehouse(this.frm.doc.items, "target_warehouse", this.frm.doc.set_target_warehouse); + this.autofill_warehouse( + this.frm.doc.packed_items, + "target_warehouse", + this.frm.doc.set_target_warehouse + ); } set_from_warehouse() { diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 72798f32329..babd09a5591 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -183,6 +183,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Customer Group", + "link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]", "oldfieldname": "customer_group", "oldfieldtype": "Link", "options": "Customer Group", @@ -625,7 +626,7 @@ "link_fieldname": "party" } ], - "modified": "2026-01-16 15:56:05.967663", + "modified": "2026-01-21 17:23:42.151114", "modified_by": "Administrator", "module": "Selling", "name": "Customer", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 6cb3b4fbb3d..1b88bf79ac4 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -61,6 +61,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Default Customer Group", + "link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]", "options": "Customer Group" }, { @@ -297,7 +298,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-01-02 18:17:05.734945", + "modified": "2026-01-21 17:28:37.027837", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index 343bc057f0c..7eb78c5499d 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -6,14 +6,14 @@ } }, "Algeria": { - "Algeria VAT 17%": { - "account_name": "VAT 17%", - "tax_rate": 17.00, + "Algeria TVA 19%": { + "account_name": "TVA 19%", + "tax_rate": 19.00, "default": 1 }, - "Algeria VAT 7%": { - "account_name": "VAT 7%", - "tax_rate": 7.00 + "Algeria TVA 9%": { + "account_name": "TVA 9%", + "tax_rate": 9.00 } }, diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 0fb3f048983..f383562e4e9 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -97,7 +97,6 @@ class DeprecatedBatchNoValuation: for ledger in entries: self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value) self.available_qty[ledger.batch_no] += flt(ledger.batch_qty) - self.total_qty[ledger.batch_no] += flt(ledger.batch_qty) @deprecated( "erpnext.stock.serial_batch_bundle.BatchNoValuation.get_sle_for_batches", @@ -271,7 +270,6 @@ class DeprecatedBatchNoValuation: batch_data = query.run(as_dict=True) for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) - self.total_qty[d.batch_no] += flt(d.batch_qty) for d in batch_data: if self.available_qty.get(d.batch_no): @@ -383,7 +381,6 @@ class DeprecatedBatchNoValuation: batch_data = query.run(as_dict=True) for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) - self.total_qty[d.batch_no] += flt(d.batch_qty) if not self.last_sle: return diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index b34bc7ded7d..8c9aff89c73 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -282,7 +282,6 @@ { "fieldname": "set_warehouse", "fieldtype": "Link", - "ignore_user_permissions": 1, "in_list_view": 1, "label": "Set Target Warehouse", "options": "Warehouse" @@ -378,7 +377,7 @@ "idx": 70, "is_submittable": 1, "links": [], - "modified": "2026-01-10 15:34:59.000603", + "modified": "2026-01-21 12:48:40.792323", "modified_by": "Administrator", "module": "Stock", "name": "Material Request", diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index b26bab3d1c4..b426b333c02 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4997,6 +4997,45 @@ class TestPurchaseReceipt(IntegrationTestCase): self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]]) + def test_negative_stock_error_for_purchase_return(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 Negative Stock for Purchase Return Item", + {"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TNSFPRI.#####"}, + ).name + + pr = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -3), + qty=10, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + + make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -4), + qty=10, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + make_stock_entry( + item_code=item_code, + qty=10, + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch_no, + use_serial_batch_fields=1, + ) + + return_pr = make_return_doc("Purchase Receipt", pr.name) + self.assertRaises(frappe.ValidationError, return_pr.submit) + 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 fce52748283..464a56e68b4 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 @@ -576,14 +576,12 @@ class SerialandBatchBundle(Document): d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no))) precision = d.precision("qty") - for field in ["available_qty", "total_qty"]: - value = getattr(sn_obj, field) - available_qty = flt(value.get(d.batch_no), precision) - if self.docstatus == 1: - available_qty += flt(d.qty, precision) + available_qty = flt(sn_obj.available_qty.get(d.batch_no), precision) + if self.docstatus == 1: + available_qty += flt(d.qty, precision) - if not allow_negative_stock: - self.validate_negative_batch(d.batch_no, available_qty, field) + if not allow_negative_stock: + self.validate_negative_batch(d.batch_no, available_qty) d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate) @@ -596,8 +594,8 @@ class SerialandBatchBundle(Document): } ) - def validate_negative_batch(self, batch_no, available_qty, field=None): - if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty, field=field): + def validate_negative_batch(self, batch_no, available_qty): + if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty): msg = f"""Batch No {bold(batch_no)} of an Item {bold(self.item_code)} has negative stock of quantity {bold(available_qty)} in the @@ -605,7 +603,7 @@ class SerialandBatchBundle(Document): frappe.throw(_(msg), BatchNegativeStockError) - def is_stock_reco_for_valuation_adjustment(self, available_qty, field=None): + def is_stock_reco_for_valuation_adjustment(self, available_qty): if ( self.voucher_type == "Stock Reconciliation" and self.type_of_transaction == "Outward" @@ -613,7 +611,6 @@ class SerialandBatchBundle(Document): and ( abs(frappe.db.get_value("Stock Reconciliation Item", self.voucher_detail_no, "qty")) == abs(available_qty) - or field == "total_qty" ) ): return True @@ -1344,6 +1341,7 @@ class SerialandBatchBundle(Document): def on_submit(self): self.validate_docstatus() self.validate_serial_nos_inventory() + self.validate_batch_quantity() def validate_docstatus(self): for row in self.entries: @@ -1437,6 +1435,106 @@ class SerialandBatchBundle(Document): def on_cancel(self): self.validate_voucher_no_docstatus() + self.validate_batch_quantity() + + def validate_batch_quantity(self): + if not self.has_batch_no: + return + + if self.type_of_transaction != "Outward" or ( + self.voucher_type == "Stock Reconciliation" and self.type_of_transaction == "Outward" + ): + return + + batch_wise_available_qty = self.get_batchwise_available_qty() + precision = frappe.get_precision("Serial and Batch Entry", "qty") + + 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"), + ) + + 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() + + if not available_qty_from_ledger: + return available_qty + + for batch_no, qty in available_qty_from_ledger.items(): + if batch_no in available_qty: + available_qty[batch_no] += qty + else: + available_qty[batch_no] = qty + + return available_qty + + def get_available_qty_from_stock_ledger(self): + batches = [d.batch_no for d in self.entries if d.batch_no] + + sle = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(sle) + .select( + sle.batch_no, + Sum(sle.actual_qty).as_("available_qty"), + ) + .where( + (sle.item_code == self.item_code) + & (sle.warehouse == self.warehouse) + & (sle.is_cancelled == 0) + & (sle.batch_no.isin(batches)) + & (sle.docstatus == 1) + & (sle.serial_and_batch_bundle.isnull()) + & (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() + + def get_available_qty_from_sabb(self): + batches = [d.batch_no for d in self.entries if d.batch_no] + + child = frappe.qb.DocType("Serial and Batch Entry") + + query = ( + frappe.qb.from_(child) + .select( + child.batch_no, + Sum(child.qty).as_("available_qty"), + ) + .where( + (child.item_code == self.item_code) + & (child.warehouse == self.warehouse) + & (child.is_cancelled == 0) + & (child.batch_no.isin(batches)) + & (child.docstatus == 1) + & (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() def validate_voucher_no_docstatus(self): if self.voucher_type == "POS Invoice": diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 31a4081fc9d..39db384abd2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -959,7 +959,9 @@ class StockEntry(StockController, SubcontractingInwardController): if matched_item := self.get_matched_items(item_code): if flt(details.get("qty"), precision) != flt(matched_item.qty, precision): frappe.throw( - _("For the item {0}, the quantity should be {1} according to the BOM {2}.").format( + _( + "For the item {0}, the consumed quantity should be {1} according to the BOM {2}." + ).format( frappe.bold(item_code), flt(details.get("qty")), get_link_to_form("BOM", self.bom_no), @@ -1024,12 +1026,37 @@ class StockEntry(StockController, SubcontractingInwardController): ) def get_matched_items(self, item_code): - for row in self.items: + items = [item for item in self.items if item.s_warehouse] + for row in items or self.get_consumed_items(): if row.item_code == item_code or row.original_item == item_code: return row return {} + def get_consumed_items(self): + """Get all raw materials consumed through consumption entries""" + parent = frappe.qb.DocType("Stock Entry") + child = frappe.qb.DocType("Stock Entry Detail") + + query = ( + frappe.qb.from_(parent) + .join(child) + .on(parent.name == child.parent) + .select( + child.item_code, + Sum(child.qty).as_("qty"), + child.original_item, + ) + .where( + (parent.docstatus == 1) + & (parent.purpose == "Material Consumption for Manufacture") + & (parent.work_order == self.work_order) + ) + .groupby(child.item_code, child.original_item) + ) + + return query.run(as_dict=True) + @frappe.whitelist() def get_stock_and_rate(self): """ diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 3f9ab8da394..8f9fbfc434e 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2384,6 +2384,33 @@ class TestStockEntry(IntegrationTestCase): frappe.db.set_single_value("Manufacturing Settings", "material_consumption", original_value) + @IntegrationTestCase.change_settings( + "Manufacturing Settings", + { + "material_consumption": 1, + "backflush_raw_materials_based_on": "BOM", + "validate_components_quantities_per_bom": 1, + }, + ) + def test_validation_as_per_bom_with_continuous_raw_material_consumption(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as _make_stock_entry + from erpnext.manufacturing.doctype.work_order.work_order import make_work_order + + fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name + rm_item1 = make_item("_Battery", properties={"is_stock_item": 1}).name + warehouse = "Stores - WP" + bom_no = make_bom(item=fg_item, raw_materials=[rm_item1]).name + make_stock_entry(item_code=rm_item1, target=warehouse, qty=5, rate=10, purpose="Material Receipt") + + work_order = make_work_order(bom_no, fg_item, 5) + work_order.skip_transfer = 1 + work_order.fg_warehouse = warehouse + work_order.submit() + + frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit() + frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit() + def make_serialized_item(self, **args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 59683e87e3d..59acb04e8ea 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -75,6 +75,7 @@ class StockReconciliation(StockController): self.validate_duplicate_serial_and_batch_bundle("items") self.remove_items_with_no_change() self.validate_data() + self.change_row_indexes() self.validate_expense_account() self.validate_customer_provided_item() self.set_zero_value_for_customer_provided_items() @@ -556,8 +557,7 @@ class StockReconciliation(StockController): elif len(items) != len(self.items): self.items = items - for i, item in enumerate(self.items): - item.idx = i + 1 + self.change_idx = True frappe.msgprint(_("Removed items with no change in quantity or value.")) def calculate_difference_amount(self, item, item_dict): @@ -574,14 +574,14 @@ class StockReconciliation(StockController): def validate_data(self): def _get_msg(row_num, msg): - return _("Row # {0}:").format(row_num + 1) + " " + msg + return _("Row #{0}:").format(row_num) + " " + msg self.validation_messages = [] item_warehouse_combinations = [] default_currency = frappe.db.get_default("currency") - for row_num, row in enumerate(self.items): + for row in self.items: # find duplicates key = [row.item_code, row.warehouse] for field in ["serial_no", "batch_no"]: @@ -594,7 +594,7 @@ class StockReconciliation(StockController): if key in item_warehouse_combinations: self.validation_messages.append( - _get_msg(row_num, _("Same item and warehouse combination already entered.")) + _get_msg(row.idx, _("Same item and warehouse combination already entered.")) ) else: item_warehouse_combinations.append(key) @@ -604,7 +604,7 @@ class StockReconciliation(StockController): if row.serial_no and not row.qty: self.validation_messages.append( _get_msg( - row_num, + row.idx, f"Quantity should not be zero for the {bold(row.item_code)} since serial nos are specified", ) ) @@ -612,17 +612,17 @@ class StockReconciliation(StockController): # if both not specified if row.qty in ["", None] and row.valuation_rate in ["", None]: self.validation_messages.append( - _get_msg(row_num, _("Please specify either Quantity or Valuation Rate or both")) + _get_msg(row.idx, _("Please specify either Quantity or Valuation Rate or both")) ) # do not allow negative quantity if flt(row.qty) < 0: - self.validation_messages.append(_get_msg(row_num, _("Negative Quantity is not allowed"))) + self.validation_messages.append(_get_msg(row.idx, _("Negative Quantity is not allowed"))) # do not allow negative valuation if flt(row.valuation_rate) < 0: self.validation_messages.append( - _get_msg(row_num, _("Negative Valuation Rate is not allowed")) + _get_msg(row.idx, _("Negative Valuation Rate is not allowed")) ) if row.qty and row.valuation_rate in ["", None]: @@ -654,6 +654,11 @@ class StockReconciliation(StockController): raise frappe.ValidationError(self.validation_messages) + def change_row_indexes(self): + if getattr(self, "change_idx", False): + for i, item in enumerate(self.items): + item.idx = i + 1 + def validate_item(self, item_code, row): from erpnext.stock.doctype.item.item import ( validate_cancelled_item, @@ -661,6 +666,16 @@ class StockReconciliation(StockController): validate_is_stock_item, ) + def validate_serial_batch_items(): + has_batch_no, has_serial_no = frappe.get_value( + "Item", item_code, ["has_batch_no", "has_serial_no"] + ) + if row.use_serial_batch_fields and self.purpose == "Stock Reconciliation": + if has_batch_no and not row.batch_no: + raise frappe.ValidationError(_("Please enter Batch No")) + if has_serial_no and not row.serial_no: + raise frappe.ValidationError(_("Please enter Serial No")) + # using try except to catch all validation msgs and display together try: @@ -669,12 +684,13 @@ class StockReconciliation(StockController): # end of life and stock item validate_end_of_life(item_code, item.end_of_life, item.disabled) validate_is_stock_item(item_code, item.is_stock_item) + validate_serial_batch_items() # docstatus should be < 2 validate_cancelled_item(item_code, item.docstatus) except Exception as e: - self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e)) + self.validation_messages.append(_("Row #") + ("%d: " % (row.idx)) + cstr(e)) def validate_reserved_stock(self) -> None: """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation.""" diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 2500b521017..40f372f2bc7 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1450,6 +1450,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): qty=10, rate=100, use_serial_batch_fields=1, + purpose="Opening Stock", ) sr.reload() @@ -1592,6 +1593,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): qty=10, rate=80, use_serial_batch_fields=1, + purpose="Opening Stock", ) batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle) @@ -1676,6 +1678,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): qty=10, rate=100, use_serial_batch_fields=1, + purpose="Opening Stock", ) sr.reload() diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index a1f431c883e..f2ac54898a7 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -439,8 +439,10 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It if not ctx.uom: if ctx.doctype in sales_doctypes: ctx.uom = item.sales_uom if item.sales_uom else item.stock_uom - elif (ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]) or ( - ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase" + elif ( + (ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]) + or (ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase") + or (ctx.doctype == "Supplier Quotation") ): ctx.uom = item.purchase_uom if item.purchase_uom else item.stock_uom else: diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 50603eb609d..199672871dc 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -807,62 +807,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation): for ledger in entries: self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate) self.available_qty[ledger.batch_no] += flt(ledger.qty) - self.total_qty[ledger.batch_no] += flt(ledger.qty) - - entries = self.get_batch_stock_after_date() - for row in entries: - self.total_qty[row.batch_no] += flt(row.total_qty) self.calculate_avg_rate_from_deprecarated_ledgers() self.calculate_avg_rate_for_non_batchwise_valuation() self.set_stock_value_difference() - def get_batch_stock_after_date(self) -> list[dict]: - # Get total qty of each batch no from Serial and Batch Bundle without checking time condition - if not self.batchwise_valuation_batches: - return [] - - child = frappe.qb.DocType("Serial and Batch Entry") - - timestamp_condition = "" - if self.sle.posting_datetime: - timestamp_condition = child.posting_datetime > self.sle.posting_datetime - - if self.sle.creation: - timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & ( - child.creation > self.sle.creation - ) - - query = ( - frappe.qb.from_(child) - .select( - child.batch_no, - Sum(child.qty).as_("total_qty"), - ) - .where( - (child.item_code == self.sle.item_code) - & (child.warehouse == self.sle.warehouse) - & (child.batch_no.isin(self.batchwise_valuation_batches)) - & (child.docstatus == 1) - & (child.type_of_transaction.isin(["Inward", "Outward"])) - ) - .for_update() - .groupby(child.batch_no) - ) - - # Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference - if self.sle.voucher_detail_no: - query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no) - elif self.sle.voucher_no: - query = query.where(child.voucher_no != self.sle.voucher_no) - - query = query.where(child.voucher_type != "Pick List") - - if timestamp_condition: - query = query.where(timestamp_condition) - - return query.run(as_dict=True) - def get_batch_stock_before_date(self) -> list[dict]: # Get batch wise stock value difference from Serial and Batch Bundle considering time condition if not self.batchwise_valuation_batches: diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py index 0e227ac4fa5..1d57660d6ef 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -51,6 +51,49 @@ class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase): for item in rm_in.get("items"): self.assertEqual(item.customer_provided_item_cost, 15) + def test_customer_provided_item_cost_with_multiple_receipts(self): + """ + Validate that rate is calculated correctly (Weighted Average) when multiple receipts + occur for the same SCIO Received Item. + """ + so, scio = create_so_scio() + rm_item = "Basic RM" + + # Receipt 1: 5 Qty @ Unit Cost 10 + rm_in_1 = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in_1.items = [item for item in rm_in_1.items if item.item_code == rm_item] + rm_in_1.items[0].qty = 5 + rm_in_1.items[0].basic_rate = 10 + rm_in_1.items[0].transfer_qty = 5 + rm_in_1.submit() + + scio.reload() + received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item) + self.assertEqual(received_item.rate, 10) + + # Receipt 2: 5 Qty @ Unit Cost 20 + rm_in_2 = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()) + rm_in_2.items = [item for item in rm_in_2.items if item.item_code == rm_item] + rm_in_2.items[0].qty = 5 + rm_in_2.items[0].basic_rate = 20 + rm_in_2.items[0].transfer_qty = 5 + rm_in_2.save() + rm_in_2.submit() + + # Check 2: Rate should be Weighted Average + # (5 * 10 + 5 * 20) / 10 = 150 / 10 = 15 + scio.reload() + received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item) + self.assertEqual(received_item.rate, 15) + + # Cancel Receipt 2: Rate should revert to original + # (15 * 10 - 20 * 5) / 5 = 50 / 5 = 10 + rm_in_2.cancel() + scio.reload() + received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item) + self.assertEqual(received_item.received_qty, 5) + self.assertEqual(received_item.rate, 10) + def test_add_extra_customer_provided_item(self): so, scio = create_so_scio()