diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index c6875b53ed8..86a1adcb994 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -243,8 +243,10 @@ def get_other_conditions(conditions, values, args): if group_condition: conditions += " and " + group_condition - date = args.get("transaction_date") or frappe.get_value( - args.get("doctype"), args.get("name"), "posting_date", ignore=True + date = ( + args.get("transaction_date") + or args.get("posting_date") + or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True) ) if date: conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01') diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 81c9ff09ccb..88428e57879 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -382,7 +382,7 @@ }, { "collapsible": 1, - "collapsible_depends_on": "bill_no", + "collapsible_depends_on": "posting_date", "fieldname": "supplier_invoice_details", "fieldtype": "Section Break", "label": "Supplier Invoice" @@ -1660,7 +1660,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-02-05 20:45:16.964500", + "modified": "2026-03-17 20:44:00.221219", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index b10f4fbb89a..6bc24633ca9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -2516,7 +2516,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): "doctype": target_doctype, "postprocess": update_details, "set_target_warehouse": "set_from_warehouse", - "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address"], + "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], }, doctype + " Item": item_field_map, }, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index f9b6ab4f07c..c515322348a 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -4835,6 +4835,33 @@ class TestSalesInvoice(FrappeTestCase): self.assertEqual(stock_ledger_entry.incoming_rate, 0.0) + def test_inter_company_transaction_cost_center(self): + si = create_sales_invoice( + company="Wind Power LLC", + customer="_Test Internal Customer", + debit_to="Debtors - WP", + warehouse="Stores - WP", + income_account="Sales - WP", + expense_account="Cost of Goods Sold - WP", + parent_cost_center="Main - WP", + cost_center="Main - WP", + currency="USD", + do_not_save=1, + ) + + si.selling_price_list = "_Test Price List Rest of the World" + si.submit() + + cost_center = frappe.db.get_value("Company", "_Test Company 1", "cost_center") + frappe.db.set_value("Company", "_Test Company 1", "cost_center", None) + + target_doc = make_inter_company_transaction("Sales Invoice", si.name) + + self.assertEqual(target_doc.cost_center, None) + self.assertEqual(target_doc.items[0].cost_center, None) + + frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center) + def make_item_for_si(item_code, properties=None): from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index 0c4fe4a4855..abbb6a58119 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -152,7 +152,9 @@ class ShippingRule(Document): frappe.throw(_("Shipping rule only applicable for Buying")) shipping_charge["doctype"] = "Purchase Taxes and Charges" - shipping_charge["category"] = "Valuation and Total" + shipping_charge["category"] = ( + "Valuation and Total" if doc.get_stock_items() or doc.get_asset_items() else "Total" + ) shipping_charge["add_deduct_tax"] = "Add" existing_shipping_charge = doc.get("taxes", filters=shipping_charge) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index db42d23a839..f4cb3083961 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -8,6 +8,7 @@ import frappe from frappe import _ from frappe.utils import flt, formatdate +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.controllers.trends import get_period_date_ranges, get_period_month_ranges @@ -15,6 +16,8 @@ def execute(filters=None): if not filters: filters = {} + validate_filters(filters) + columns = get_columns(filters) if filters.get("budget_against_filter"): dimensions = filters.get("budget_against_filter") @@ -35,6 +38,21 @@ def execute(filters=None): return columns, data, None, chart +def validate_filters(filters): + validate_budget_dimensions(filters) + + +def validate_budget_dimensions(filters): + dimensions = [d.get("document_type") for d in get_dimensions(with_cost_center_and_project=True)[0]] + if filters.get("budget_against") and filters.get("budget_against") not in dimensions: + frappe.throw( + title=_("Invalid Accounting Dimension"), + msg=_("{0} is not a valid Accounting Dimension.").format( + frappe.bold(filters.get("budget_against")) + ), + ) + + def get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation): for account, monthwise_data in dimension_items.items(): row = [dimension, account] diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 1ed08d79cc6..c4269beda85 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -31,6 +31,7 @@ def _execute(filters=None, additional_table_columns=None): item_list = get_items(filters, additional_table_columns) aii_account_map = get_aii_accounts() + default_taxes = {} if item_list: itemised_tax, tax_columns = get_tax_accounts( item_list, @@ -39,6 +40,9 @@ def _execute(filters=None, additional_table_columns=None): doctype="Purchase Invoice", tax_doctype="Purchase Taxes and Charges", ) + for tax in tax_columns: + default_taxes[f"{tax}_rate"] = 0 + default_taxes[f"{tax}_amount"] = 0 po_pr_map = get_purchase_receipts_against_purchase_order(item_list) @@ -85,6 +89,8 @@ def _execute(filters=None, additional_table_columns=None): } total_tax = 0 + row.update(default_taxes.copy()) + for tax in tax_columns: item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update( diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 78aa90ab947..aa431372f75 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -29,8 +29,12 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions= company_currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency") item_list = get_items(filters, additional_table_columns, additional_conditions) + default_taxes = {} if item_list: itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency) + for tax in tax_columns: + default_taxes[f"{tax}_rate"] = 0 + default_taxes[f"{tax}_amount"] = 0 mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list)) so_dn_map = get_delivery_notes_against_sales_order(item_list) @@ -88,6 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions= total_tax = 0 total_other_charges = 0 + row.update(default_taxes.copy()) + for tax in tax_columns: item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update( diff --git a/erpnext/accounts/report/item_wise_sales_register/test_item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/test_item_wise_sales_register.py index 4dfdf3058e4..7d074f338dd 100644 --- a/erpnext/accounts/report/item_wise_sales_register/test_item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/test_item_wise_sales_register.py @@ -17,9 +17,11 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): def tearDown(self): frappe.db.rollback() - def create_sales_invoice(self, do_not_submit=False): + def create_sales_invoice(self, item=None, taxes=None, do_not_submit=False): si = create_sales_invoice( - item=self.item, + item=item or self.item, + item_name=item or self.item, + description=item or self.item, company=self.company, customer=self.customer, debit_to=self.debit_to, @@ -30,6 +32,19 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): price_list_rate=100, do_not_save=1, ) + + for tax in taxes or []: + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": tax["account_head"], + "cost_center": self.cost_center, + "description": tax["description"], + "rate": tax["rate"], + }, + ) + si = si.save() if not do_not_submit: si = si.submit() @@ -63,3 +78,50 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): report_output = {k: v for k, v in report[1][0].items() if k in expected_result} self.assertDictEqual(report_output, expected_result) + + def test_grouped_report_handles_different_tax_descriptions(self): + self.create_item(item_name="_Test Item Tax Description A") + first_item = self.item + self.create_item(item_name="_Test Item Tax Description B") + second_item = self.item + + first_tax_description = "Tax Description A" + second_tax_description = "Tax Description B" + first_tax_amount_field = f"{frappe.scrub(first_tax_description)}_amount" + second_tax_amount_field = f"{frappe.scrub(second_tax_description)}_amount" + + self.create_sales_invoice( + item=first_item, + taxes=[ + { + "account_head": "_Test Account VAT - _TC", + "description": first_tax_description, + "rate": 5, + } + ], + ) + self.create_sales_invoice( + item=second_item, + taxes=[ + { + "account_head": "_Test Account Service Tax - _TC", + "description": second_tax_description, + "rate": 2, + } + ], + ) + + filters = frappe._dict( + { + "from_date": today(), + "to_date": today(), + "company": self.company, + "group_by": "Customer", + } + ) + _, data, _, _, _, _ = execute(filters) + + grand_total_row = next(row for row in data if row.get("bold") and row.get("item_code") == "Total") + + self.assertEqual(grand_total_row[first_tax_amount_field], 5.0) + self.assertEqual(grand_total_row[second_tax_amount_field], 2.0) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 5d1513df9b2..c4427da7135 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -912,6 +912,8 @@ def get_list_context(context=None): @frappe.whitelist() def update_status(status, name): + frappe.has_permission("Purchase Order", "submit", name, throw=True) + po = frappe.get_doc("Purchase Order", name) po.update_status(status) po.update_delivered_qty_in_sales_order() diff --git a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py index ef28eda62a5..4526bbf1703 100644 --- a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py +++ b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py @@ -41,6 +41,7 @@ def get_columns(filters): "fieldname": "transferred_qty", "width": 200, }, + {"label": _("Returned Quantity"), "fieldtype": "Float", "fieldname": "returned_qty", "width": 150}, {"label": _("Pending Quantity"), "fieldtype": "Float", "fieldname": "p_qty", "width": 150}, ] @@ -50,7 +51,7 @@ def get_data(filters): data = [] for row in order_rm_item_details: - transferred_qty = row.get("transferred_qty") or 0 + transferred_qty = (row.get("transferred_qty") or 0) - (row.get("returned_qty") or 0) if transferred_qty < row.get("reqd_qty", 0): pending_qty = frappe.utils.flt(row.get("reqd_qty", 0) - transferred_qty) row.p_qty = pending_qty if pending_qty > 0 else 0 @@ -86,6 +87,7 @@ def get_order_items_to_supply(filters): f"`tab{supplied_items_table}`.rm_item_code as rm_item_code", f"`tab{supplied_items_table}`.required_qty as reqd_qty", f"`tab{supplied_items_table}`.supplied_qty as transferred_qty", + f"`tab{supplied_items_table}`.returned_qty as returned_qty", ], filters=record_filters, ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index ec2b5caf9d2..ae2fae8051d 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2297,6 +2297,16 @@ class AccountsController(TransactionBase): return stock_items + def get_asset_items(self): + asset_items = [] + item_codes = list(set(item.item_code for item in self.get("items"))) + if item_codes: + asset_items = frappe.db.get_values( + "Item", {"name": ["in", item_codes], "is_fixed_asset": 1}, pluck="name", cache=True + ) + + return asset_items + def calculate_total_advance_from_ledger(self): adv = frappe.qb.DocType("Advance Payment Ledger Entry") return ( diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 290d8eb5d4b..c28a8ff44fc 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -234,8 +234,8 @@ class StatusUpdater(Document): self.global_amount_allowance = None for args in self.status_updater: - if "target_ref_field" not in args: - # if target_ref_field is not specified, the programmer does not want to validate qty / amount + if "target_ref_field" not in args or args.get("validate_qty") is False: + # if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation continue # get unique transactions to update diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 66109479d8c..c32edfaa7b2 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -989,6 +989,12 @@ class SubcontractingController(StockController): if self.doctype not in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]: return + if ( + frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") + == "BOM" + ): + return + for row in self.get(self.raw_material_table): key = (row.rm_item_code, row.main_item_code, row.get(self.subcontract_data.order_field)) if not self.__transferred_items or not self.__transferred_items.get(key): diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 476bde248cc..bc4e2b346d4 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -55,6 +55,14 @@ def validate_filters(filters): if filters.get("based_on") == filters.get("group_by"): frappe.throw(_("'Based On' and 'Group By' can not be same")) + if filters.get("period_based_on") and filters.period_based_on not in ["bill_date", "posting_date"]: + frappe.throw( + msg=_("{0} can be either {1} or {2}.").format( + frappe.bold("Period based On"), frappe.bold("Posting Date"), frappe.bold("Billing Date") + ), + title=_("Invalid Filter"), + ) + def get_data(filters, conditions): data = [] diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index c48c56df3d5..525b6aecee7 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -192,6 +192,7 @@ frappe.ui.form.on("BOM", { bom_no: frm.doc.name, item: item, qty: data.qty || 0.0, + company: frm.doc.company, project: frm.doc.project, variant_items: variant_items, use_multi_level_bom: use_multi_level_bom, diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index a6fad4ee1aa..0fa17d34d51 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1019,6 +1019,12 @@ class BOM(WebsiteGenerator): "Row {0}: Workstation or Workstation Type is mandatory for an operation {1}" ).format(d.idx, d.operation) ) + if not d.time_in_mins or d.time_in_mins <= 0: + frappe.throw( + _("Row {0}: Operation time should be greater than 0 for operation {1}").format( + d.idx, d.operation + ) + ) def get_tree_representation(self) -> BOMTree: """Get a complete tree representation preserving order of child items.""" @@ -1329,9 +1335,10 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account): bom = frappe.get_doc("BOM", work_order.bom_no) table = "exploded_items" if work_order.get("use_multi_level_bom") else "items" - items = {} + items = frappe._dict() for d in bom.get(table): - items.setdefault(d.item_code, d.amount) + items.setdefault(d.item_code, 0) + items[d.item_code] += flt(d.amount) non_stock_items = frappe.get_all( "Item", diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 83e722cce50..cc942d59c74 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -132,6 +132,15 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + @timeout + def test_bom_no_operation_time_validation(self): + bom = frappe.copy_doc(test_records[2]) + bom.docstatus = 0 + for op_row in bom.operations: + op_row.time_in_mins = 0 + + self.assertRaises(frappe.ValidationError, bom.save) + @timeout def test_bom_cost_with_batch_size(self): bom = frappe.copy_doc(test_records[2]) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 590708ac275..04974e2e2ba 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1372,7 +1372,11 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True): @frappe.whitelist() -def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_multi_level_bom=None): +def make_work_order( + bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None +): + from erpnext import get_default_company + if not frappe.has_permission("Work Order", "write"): frappe.throw(_("Not permitted"), frappe.PermissionError) @@ -1387,6 +1391,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m wo_doc = frappe.new_doc("Work Order") wo_doc.production_item = item + wo_doc.company = company or get_default_company() wo_doc.update(item_details) wo_doc.bom_no = bom_no wo_doc.use_multi_level_bom = cint(use_multi_level_bom) diff --git a/erpnext/portal/utils.py b/erpnext/portal/utils.py index 903d4a6196c..cfdf8e18191 100644 --- a/erpnext/portal/utils.py +++ b/erpnext/portal/utils.py @@ -47,21 +47,8 @@ def create_customer_or_supplier(): if party_exists(doctype, user): return - party = frappe.new_doc(doctype) fullname = frappe.utils.get_fullname(user) - - if not doctype == "Customer": - party.update( - { - "supplier_name": fullname, - "supplier_group": "All Supplier Groups", - "supplier_type": "Individual", - } - ) - - party.flags.ignore_mandatory = True - party.insert(ignore_permissions=True) - + party = create_party(doctype, fullname) alternate_doctype = "Customer" if doctype == "Supplier" else "Supplier" if party_exists(alternate_doctype, user): @@ -69,6 +56,22 @@ def create_customer_or_supplier(): fullname += "-" + doctype create_party_contact(doctype, fullname, user, party.name) + return party + + +def create_party(doctype, fullname): + party = frappe.new_doc(doctype) + # Can't set parent party as group + + party.update( + { + f"{doctype.lower()}_name": fullname, + f"{doctype.lower()}_type": "Individual", + } + ) + + party.flags.ignore_mandatory = True + party.insert(ignore_permissions=True) return party diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index dbd7f406432..85f9e246b04 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1681,6 +1681,8 @@ def make_work_orders(items, sales_order, company, project=None): @frappe.whitelist() def update_status(status, name): + frappe.has_permission("Sales Order", "submit", name, throw=True) + so = frappe.get_doc("Sales Order", name) so.update_status(status) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index f2766852b64..1b444971659 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -29,6 +29,15 @@ from erpnext.stock.serial_batch_bundle import ( ) from erpnext.utilities.transaction_base import TransactionBase + +class MissingWarehouseValidationError(frappe.ValidationError): + pass + + +class IncorrectWarehouseValidationError(frappe.ValidationError): + pass + + # TODO: Prioritize SO or WO group warehouse @@ -94,6 +103,7 @@ class PickList(TransactionBase): if self.get("locations"): self.validate_sales_order_percentage() + self.validate_warehouses() def validate_stock_qty(self): from erpnext.stock.doctype.batch.batch import get_batch_qty @@ -138,6 +148,31 @@ class PickList(TransactionBase): title=_("Insufficient Stock"), ) + def validate_warehouses(self): + for location in self.locations: + if not location.warehouse: + frappe.throw( + _("Row {0}: Warehouse is required").format(location.idx), + title=_("Missing Warehouse"), + exc=MissingWarehouseValidationError, + ) + + company = frappe.get_cached_value("Warehouse", location.warehouse, "company") + + if company != self.company: + frappe.throw( + _( + "Row {0}: Warehouse {1} is linked to company {2}. Please select a warehouse belonging to company {3}." + ).format( + location.idx, + frappe.bold(location.warehouse), + frappe.bold(company), + frappe.bold(self.company), + ), + title=_("Incorrect Warehouse"), + exc=IncorrectWarehouseValidationError, + ) + def check_serial_no_status(self): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -949,6 +984,7 @@ def get_available_item_locations( locations = get_available_item_locations_for_batched_item( item_code, from_warehouses, + company, consider_rejected_warehouses=consider_rejected_warehouses, ) else: @@ -1049,6 +1085,7 @@ def get_available_item_locations_for_serial_and_batched_item( locations = get_available_item_locations_for_batched_item( item_code, from_warehouses, + company, consider_rejected_warehouses=consider_rejected_warehouses, ) @@ -1129,6 +1166,7 @@ def get_available_item_locations_for_serialized_item( def get_available_item_locations_for_batched_item( item_code, from_warehouses, + company, consider_rejected_warehouses=False, ): locations = [] @@ -1138,6 +1176,7 @@ def get_available_item_locations_for_batched_item( "item_code": item_code, "warehouse": from_warehouses, "based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), + "company": company, } ) ) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 6e8c7abec7c..4d3c6b45a6a 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -211,6 +211,7 @@ class TestPickList(FrappeTestCase): "qty": 1000, "stock_qty": 1000, "conversion_factor": 1, + "warehouse": "_Test Warehouse - _TC", "sales_order": so.name, "sales_order_item": so.items[0].name, } @@ -268,6 +269,119 @@ class TestPickList(FrappeTestCase): pr1.cancel() pr2.cancel() + def test_pick_list_warehouse_for_batched_item(self): + """ + Test that pick list respects company based warehouse assignment for batched items. + + This test verifies that when creating a pick list for a batched item, + the system correctly identifies and assigns the appropriate warehouse + based on the company. + """ + from erpnext.stock.doctype.batch.test_batch import make_new_batch + + batch_company = frappe.get_doc( + {"doctype": "Company", "company_name": "Batch Company", "default_currency": "INR"} + ) + batch_company.insert() + + batch_warehouse = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "Batch Warehouse", + "company": batch_company.name, + } + ) + batch_warehouse.insert() + + batch_item = frappe.db.exists("Item", "Batch Warehouse Item") + if not batch_item: + batch_item = create_item("Batch Warehouse Item") + batch_item.has_batch_no = 1 + batch_item.create_new_batch = 1 + batch_item.save() + else: + batch_item = frappe.get_doc("Item", "Batch Warehouse Item") + + batch_no = make_new_batch(item_code=batch_item.name, batch_id="B-WH-ITEM-001") + + make_stock_entry( + item_code=batch_item.name, + qty=5, + company=batch_company.name, + to_warehouse=batch_warehouse.name, + batch_no=batch_no.name, + rate=100.0, + ) + make_stock_entry( + item_code=batch_item.name, + qty=5, + to_warehouse="_Test Warehouse - _TC", + batch_no=batch_no.name, + rate=100.0, + ) + + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": batch_company.name, + "purpose": "Material Transfer", + "locations": [ + { + "item_code": batch_item.name, + "qty": 10, + "stock_qty": 10, + "conversion_factor": 1, + } + ], + } + ) + + pick_list.set_item_locations() + self.assertEqual(len(pick_list.locations), 1) + self.assertEqual(pick_list.locations[0].qty, 5) + self.assertEqual(pick_list.locations[0].batch_no, batch_no.name) + self.assertEqual(pick_list.locations[0].warehouse, batch_warehouse.name) + + def test_pick_list_warehouse_validation(self): + """check if the warehouse validations are triggered""" + from erpnext.stock.doctype.pick_list.pick_list import ( + IncorrectWarehouseValidationError, + MissingWarehouseValidationError, + ) + + warehouse_item = create_item("Warehouse Item") + temp_company = frappe.get_doc( + {"doctype": "Company", "company_name": "Temp Company", "default_currency": "INR"} + ).insert() + temp_warehouse = frappe.get_doc( + {"doctype": "Warehouse", "warehouse_name": "Temp Warehouse", "company": temp_company.name} + ).insert() + + make_stock_entry(item_code=warehouse_item.name, qty=10, rate=100.0, to_warehouse=temp_warehouse.name) + + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": temp_company.name, + "purpose": "Material Transfer", + "pick_manually": 1, + "locations": [ + { + "item_code": warehouse_item.name, + "qty": 5, + "stock_qty": 5, + "conversion_factor": 1, + } + ], + } + ) + + self.assertRaises(MissingWarehouseValidationError, pick_list.insert) + pick_list.locations[0].warehouse = "_Test Warehouse - _TC" + self.assertRaises(IncorrectWarehouseValidationError, pick_list.insert) + pick_list.locations[0].warehouse = temp_warehouse.name + pick_list.insert() + def test_pick_list_for_batched_and_serialised_item(self): # check if oldest batch no and serial nos are picked item = frappe.db.exists("Item", {"item_name": "Batched and Serialised Item"}) diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index a33123e3e16..adac858acac 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -62,6 +62,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Warehouse", + "mandatory_depends_on": "eval: parent.pick_manually", "options": "Warehouse", "read_only": 1 }, @@ -283,7 +284,7 @@ ], "istable": 1, "links": [], - "modified": "2025-09-23 00:02:57.817040", + "modified": "2026-03-17 16:25:10.358013", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index b2e2e7dac84..e9291e10baf 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -185,6 +185,7 @@ class PurchaseReceipt(BuyingController): "target_ref_field": "stock_qty", "source_field": "stock_qty", "percent_join_field": "material_request", + "validate_qty": False, }, { "source_dt": "Purchase Receipt Item", @@ -327,7 +328,10 @@ class PurchaseReceipt(BuyingController): ) def po_required(self): - if frappe.db.get_value("Buying Settings", None, "po_required") == "Yes": + if ( + frappe.db.get_single_value("Buying Settings", "po_required") == "Yes" + and not self.is_internal_transfer() + ): for d in self.get("items"): if not d.purchase_order: frappe.throw(_("Purchase Order number required for Item {0}").format(d.item_code)) @@ -1414,6 +1418,8 @@ def make_purchase_return(source_name, target_doc=None): @frappe.whitelist() def update_purchase_receipt_status(docname, status): + frappe.has_permission("Purchase Receipt", "submit", docname, throw=True) + pr = frappe.get_doc("Purchase Receipt", docname) pr.update_status(status) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 2070b264f8f..d2e0397a7ce 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -15,6 +15,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.material_request.material_request import make_purchase_order from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( SerialNoDuplicateError, @@ -33,6 +34,40 @@ class TestPurchaseReceipt(FrappeTestCase): def setUp(self): frappe.db.set_single_value("Buying Settings", "allow_multiple_items", 1) + def test_purchase_receipt_skips_validation(self): + """ + Test that validation is skipped when over delivery receipt allowance is reduced after PO submission + and PR can be submitted with higher qty than MR. + """ + item = create_item("Test item for validation") + mr = frappe.new_doc("Material Request") + mr.material_request_type = "Purchase" + mr.company = "_Test Company" + mr.price_list = "_Test Price List" + mr.append( + "items", + { + "item_code": item.name, + "item_name": item.item_name, + "item_group": item.item_group, + "schedule_date": add_days(today(), 1), + "qty": 100, + "uom": item.stock_uom, + }, + ) + mr.insert() + mr.submit() + frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 200) + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.items[0].qty = 300 + po.save() + po.submit() + frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 20) + pr = make_purchase_receipt(qty=300, item_code=item.name, do_not_save=True) + pr.save() + pr.submit() + def test_purchase_receipt_qty(self): pr = make_purchase_receipt(qty=0, rejected_qty=0, do_not_save=True) with self.assertRaises(InvalidQtyError): 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 b5618bda08e..4de6ebc6a00 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 @@ -748,11 +748,16 @@ class SerialandBatchBundle(Document): precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate") for d in self.entries: + fifo_batch_wise_val = True + if valuation_method == "FIFO" and d.batch_no in batches: + fifo_batch_wise_val = False + if self.is_rejected and not set_valuation_rate_for_rejected_materials: rate = 0.0 elif ( (flt(d.incoming_rate, precision) == flt(rate, precision)) and not stock_queue + and fifo_batch_wise_val and d.qty and d.stock_value_difference ): @@ -2524,26 +2529,38 @@ def get_reserved_batches_for_pos(kwargs) -> dict: """Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices.""" pos_batches = frappe._dict() - pos_invoices = frappe.get_all( - "POS Invoice", - fields=[ - "`tabPOS Invoice Item`.batch_no", - "`tabPOS Invoice Item`.qty", - "`tabPOS Invoice`.is_return", - "`tabPOS Invoice Item`.warehouse", - "`tabPOS Invoice Item`.name as child_docname", - "`tabPOS Invoice`.name as parent_docname", - "`tabPOS Invoice Item`.use_serial_batch_fields", - "`tabPOS Invoice Item`.serial_and_batch_bundle", - ], - filters=[ - ["POS Invoice", "consolidated_invoice", "is", "not set"], - ["POS Invoice", "docstatus", "=", 1], - ["POS Invoice Item", "item_code", "=", kwargs.item_code], - ["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos], - ], + POS_Invoice = frappe.qb.DocType("POS Invoice") + POS_Invoice_Item = frappe.qb.DocType("POS Invoice Item") + + pos_invoices = ( + frappe.qb.from_(POS_Invoice) + .inner_join(POS_Invoice_Item) + .on(POS_Invoice.name == POS_Invoice_Item.parent) + .select( + POS_Invoice_Item.batch_no, + POS_Invoice_Item.qty, + POS_Invoice.is_return, + POS_Invoice_Item.warehouse, + POS_Invoice_Item.name.as_("child_docname"), + POS_Invoice.name.as_("parent_docname"), + POS_Invoice_Item.use_serial_batch_fields, + POS_Invoice_Item.serial_and_batch_bundle, + ) + .where( + (POS_Invoice.consolidated_invoice.isnull()) + & (POS_Invoice.docstatus == 1) + & (POS_Invoice_Item.item_code == kwargs.item_code) + ) ) + if kwargs.get("company"): + pos_invoices = pos_invoices.where(POS_Invoice.company == kwargs.get("company")) + + if kwargs.get("ignore_voucher_nos"): + pos_invoices = pos_invoices.where(POS_Invoice.name.notin(kwargs.get("ignore_voucher_nos"))) + + pos_invoices = pos_invoices.run(as_dict=True) + ids = [ pos_invoice.serial_and_batch_bundle for pos_invoice in pos_invoices @@ -2607,6 +2624,9 @@ def get_reserved_batches_for_sre(kwargs) -> dict: .groupby(sb_entry.batch_no, sre.warehouse) ) + if kwargs.get("company"): + query = query.where(sre.company == kwargs.get("company")) + if kwargs.batch_no: if isinstance(kwargs.batch_no, list): query = query.where(sb_entry.batch_no.isin(kwargs.batch_no)) @@ -2769,6 +2789,9 @@ def get_available_batches(kwargs): .groupby(batch_ledger.batch_no, batch_ledger.warehouse) ) + if kwargs.get("company"): + query = query.where(stock_ledger_entry.company == kwargs.get("company")) + if not kwargs.get("for_stock_levels"): query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())) @@ -2886,6 +2909,9 @@ def get_picked_batches(kwargs) -> dict[str, dict]: ) ) + if kwargs.get("company"): + query = query.where(table.company == kwargs.get("company")) + if kwargs.get("item_code"): query = query.where(table.item_code == kwargs.get("item_code")) @@ -3085,6 +3111,9 @@ def get_stock_ledgers_batches(kwargs): .groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse) ) + if kwargs.get("company"): + query = query.where(stock_ledger_entry.company == kwargs.get("company")) + for field in ["warehouse", "item_code", "batch_no"]: if not kwargs.get(field): continue diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index b1a1cdece37..5e9a749d672 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -514,7 +514,9 @@ frappe.ui.form.on("Stock Entry", { frm.fields_dict.items.grid.refresh(); frm.cscript.toggle_related_fields(frm.doc); }, - + cost_center(frm, cdt, cdn) { + erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "cost_center"); + }, validate_purpose_consumption: function (frm) { frappe .call({ diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 023dca5bdf2..f37a2785252 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -70,6 +70,8 @@ "address_display", "accounting_dimensions_section", "project", + "column_break_wgvc", + "cost_center", "other_info_tab", "printing_settings", "select_print_heading", @@ -699,6 +701,16 @@ "fieldtype": "Data", "is_virtual": 1, "label": "Last Scanned Warehouse" + }, + { + "fieldname": "column_break_wgvc", + "fieldtype": "Column Break" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "icon": "fa fa-file-text", @@ -706,7 +718,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-08-04 19:21:03.338958", + "modified": "2026-03-04 19:03:23.426082", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 24aad0ddf79..24e704e07b2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -103,6 +103,7 @@ class StockEntry(StockController): asset_repair: DF.Link | None bom_no: DF.Link | None company: DF.Link + cost_center: DF.Link | None credit_note: DF.Link | None delivery_note_no: DF.Link | None fg_completed_qty: DF.Float @@ -589,9 +590,6 @@ class StockEntry(StockController): flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) ) - if self.purpose == "Manufacture": - item.set("expense_account", item_details.get("expense_account")) - def validate_fg_completed_qty(self): item_wise_qty = {} if self.purpose == "Manufacture" and self.work_order: @@ -2136,7 +2134,7 @@ class StockEntry(StockController): self.to_warehouse if self.purpose == "Send to Subcontractor" else "" ) - if original_item != item.get("item_code"): + if isinstance(original_item, str) and original_item != item.get("item_code"): item["original_item"] = original_item self.add_to_stock_entry_detail(item_dict) diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 570dc3a3405..1f527e7071a 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -7,7 +7,7 @@ from math import ceil import frappe from frappe import _ -from frappe.utils import add_days, cint, flt, nowdate +from frappe.utils import add_days, cint, escape_html, flt, nowdate import erpnext @@ -219,15 +219,6 @@ def create_material_request(material_requests): mr_list = [] exceptions_list = [] - def _log_exception(mr): - if frappe.local.message_log: - exceptions_list.extend(frappe.local.message_log) - frappe.local.message_log = [] - else: - exceptions_list.append(frappe.get_traceback(with_context=True)) - - mr.log_error("Unable to create material request") - company_wise_mr = frappe._dict({}) for request_type in material_requests: for company in material_requests[request_type]: @@ -297,8 +288,9 @@ def create_material_request(material_requests): company_wise_mr.setdefault(company, []).append(mr) - except Exception: - _log_exception(mr) + except Exception as exception: + exceptions_list.append(exception) + mr.log_error("Unable to create material request") if company_wise_mr: if getattr(frappe.local, "reorder_email_notify", None) is None: @@ -383,10 +375,7 @@ def notify_errors(exceptions_list): for exception in exceptions_list: try: - exception = json.loads(exception) - error_message = """
{}

""".format( - _(exception.get("message")) - ) + error_message = f"
{escape_html(str(exception))}

" content += error_message except Exception: pass diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 46f655c8b11..e0c2bf2bcfc 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -243,10 +243,7 @@ class FIFOSlots: consumed/updated and maintained via FIFO. ** } """ - - from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( - get_serial_nos_from_bundle, - ) + from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle stock_ledger_entries = self.sle @@ -271,7 +268,7 @@ class FIFOSlots: if bundle_wise_serial_nos: serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] else: - serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) or [] + serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] serial_nos = self.uppercase_serial_nos(serial_nos) if d.actual_qty > 0: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index e6a42d28b15..d50d7fc5dba 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2058,7 +2058,6 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): where item_code = %(item_code)s and warehouse = %(warehouse)s - and voucher_no != %(voucher_no)s and is_cancelled = 0 and ( posting_datetime > %(posting_datetime)s