diff --git a/erpnext/accounts/doctype/bank_account/bank_account.py b/erpnext/accounts/doctype/bank_account/bank_account.py index 1aa11df0c9a..aced4258526 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.py +++ b/erpnext/accounts/doctype/bank_account/bank_account.py @@ -131,6 +131,7 @@ def get_default_company_bank_account(company, party_type, party): @frappe.whitelist() def get_bank_account_details(bank_account): + frappe.has_permission("Bank Account", doc=bank_account, ptype="read", throw=True) return frappe.get_cached_value( "Bank Account", bank_account, ["account", "bank", "bank_account_no"], as_dict=1 ) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index f5f8f8ab107..4f133a2fb7d 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -5,7 +5,7 @@ import frappe from frappe import _, scrub from frappe.model.document import Document -from frappe.utils import flt, nowdate +from frappe.utils import escape_html, flt, nowdate from frappe.utils.background_jobs import enqueue, is_job_enqueued from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( @@ -84,6 +84,11 @@ class OpeningInvoiceCreationTool(Document): ) prepare_invoice_summary(doctype, invoices) + invoices_summary_companies = list(invoices_summary.keys()) + + for company in invoices_summary_companies: + invoices_summary[escape_html(company)] = invoices_summary.pop(company) + return invoices_summary, max_count def validate_company(self): diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index a8e073c353c..ea513e0cfd7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -612,12 +612,13 @@ class PurchaseInvoice(BuyingController): frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account) def po_required(self): - if frappe.db.get_value("Buying Settings", None, "po_required") == "Yes": - if frappe.get_value( + if ( + frappe.db.get_single_value("Buying Settings", "po_required") == "Yes" + and not self.is_internal_transfer() + and not frappe.db.get_value( "Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_order" - ): - return - + ) + ): for d in self.get("items"): if not d.purchase_order: msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code)) diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py index 9a3c05e90f2..011b508de3f 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py @@ -48,6 +48,9 @@ class Deferred_Item: Generate report data for output """ ret_data = frappe._dict({"name": self.item_name}) + ret_data.service_start_date = self.service_start_date + ret_data.service_end_date = self.service_end_date + ret_data.amount = self.base_net_amount for period in self.period_total: ret_data[period.key] = period.total ret_data.indent = 1 @@ -205,6 +208,9 @@ class Deferred_Invoice: for item in self.uniq_items: self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item])) + # roll-up amount from all deferred items + self.amount_total = sum(item.base_net_amount for item in self.items) + def calculate_invoice_revenue_expense_for_period(self): """ calculate deferred revenue/expense for all items in invoice @@ -232,7 +238,7 @@ class Deferred_Invoice: generate report data for invoice, includes invoice total """ ret_data = [] - inv_total = frappe._dict({"name": self.name}) + inv_total = frappe._dict({"name": self.name, "amount": self.amount_total}) for x in self.period_total: inv_total[x.key] = x.total inv_total.indent = 0 @@ -386,6 +392,24 @@ class Deferred_Revenue_and_Expense_Report: def get_columns(self): columns = [] columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1}) + columns.append( + { + "label": _("Service Start Date"), + "fieldname": "service_start_date", + "fieldtype": "Date", + "read_only": 1, + } + ) + columns.append( + { + "label": _("Service End Date"), + "fieldname": "service_end_date", + "fieldtype": "Date", + "read_only": 1, + } + ) + columns.append({"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "read_only": 1}) + for period in self.period_list: columns.append( { @@ -415,6 +439,8 @@ class Deferred_Revenue_and_Expense_Report: elif self.filters.type == "Expense": total_row = frappe._dict({"name": "Total Deferred Expense"}) + total_row["amount"] = sum(inv.amount_total for inv in self.deferred_invoices) + for idx, period in enumerate(self.period_list, 0): total_row[period.key] = self.period_total[idx].total ret.append(total_row) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c4427da7135..d80f116e042 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -826,18 +826,18 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions target.set_payment_schedule() target.credit_to = get_party_account("Supplier", source.supplier, source.company) + def get_billed_qty(po_item_name): + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty).as_("qty")) + .where((table.docstatus == 1) & (table.po_detail == po_item_name)) + ) + return query.run(pluck="qty")[0] or 0 + def update_item(obj, target, source_parent): - def get_billed_qty(po_item_name): - from frappe.query_builder.functions import Sum - - table = frappe.qb.DocType("Purchase Invoice Item") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty).as_("qty")) - .where((table.docstatus == 1) & (table.po_detail == po_item_name)) - ) - return query.run(pluck="qty")[0] or 0 - billed_qty = flt(get_billed_qty(obj.name)) target.qty = flt(obj.qty) - billed_qty @@ -877,7 +877,11 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions "wip_composite_asset": "wip_composite_asset", }, "postprocess": update_item, - "condition": lambda doc: (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)) + "condition": lambda doc: ( + doc.base_amount == 0 + or abs(doc.billed_amt) < abs(doc.amount) + or doc.qty > flt(get_billed_qty(doc.name)) + ) and select_item(doc), }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 26ace63872f..ae7898f9a07 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1346,6 +1346,35 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(pi_2.status, "Paid") self.assertEqual(po.status, "Completed") + @change_settings("Buying Settings", {"maintain_same_rate": 0}) + def test_purchase_order_over_billing_missing_item(self): + item1 = make_item( + "_Test Item for Overbilling", + ).name + + item2 = make_item( + "_Test Item for Overbilling 2", + ).name + + po = create_purchase_order(qty=10, rate=1000, item_code=item1, do_not_save=1) + po.append("items", {"item_code": item2, "qty": 5, "rate": 20, "warehouse": "_Test Warehouse - _TC"}) + po.taxes = [] + po.insert() + po.submit() + + pi1 = make_pi_from_po(po.name) + pi1.items[0].qty = 8 + pi1.items[0].rate = 1250 + pi1.remove(pi1.items[1]) + pi1.insert() + pi1.submit() + + self.assertEqual(pi1.grand_total, 10000.0) + self.assertTrue(len(pi1.items) == 1) + + pi2 = make_pi_from_po(po.name) + self.assertEqual(len(pi2.items), 2) + def create_po_for_sc_testing(): from erpnext.controllers.tests.test_subcontracting_controller import ( diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 06a8f25b186..dea76428d90 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -327,7 +327,7 @@ class BuyingController(SubcontractingController): last_item_idx = d.idx total_valuation_amount = sum( - flt(d.base_tax_amount_after_discount_amount) + flt(d.base_tax_amount_after_discount_amount) * (-1 if d.get("add_deduct_tax") == "Deduct" else 1) for d in self.get("taxes") if d.category in ["Valuation", "Valuation and Total"] ) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 75fbd5d60a5..9e072ee4017 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -992,3 +992,26 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters): limit_page_length=page_len, as_list=1, ) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_warehouse_address(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict): + table = frappe.qb.DocType(doctype) + child_table = frappe.qb.DocType("Dynamic Link") + + query = ( + frappe.qb.from_(table) + .inner_join(child_table) + .on((table.name == child_table.parent) & (child_table.parenttype == doctype)) + .select(table.name) + .where( + (child_table.link_name == filters.get("warehouse")) + & (table.disabled == 0) + & (child_table.link_doctype == "Warehouse") + & (table.name.like(f"%{txt}%")) + ) + .offset(start) + .limit(page_len) + ) + return query.run(as_list=1) diff --git a/erpnext/crm/doctype/contract_template/contract_template.json b/erpnext/crm/doctype/contract_template/contract_template.json index 7cc5ec13cf7..fa288c63c7d 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.json +++ b/erpnext/crm/doctype/contract_template/contract_template.json @@ -56,7 +56,7 @@ } ], "links": [], - "modified": "2020-12-07 10:44:22.587047", + "modified": "2026-03-25 19:27:19.162421", "modified_by": "Administrator", "module": "CRM", "name": "Contract Template", @@ -75,43 +75,34 @@ "write": 1 }, { - "create": 1, - "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "Sales Manager", - "share": 1, - "write": 1 + "share": 1 }, { - "create": 1, - "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "Purchase Manager", - "share": 1, - "write": 1 + "share": 1 }, { - "create": 1, - "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "HR Manager", - "share": 1, - "write": 1 + "share": 1 } ], "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py index 9e24a26caa8..4454ede5310 100644 --- a/erpnext/crm/doctype/email_campaign/email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -204,8 +204,22 @@ def send_mail(entry, email_campaign): # called from hooks on doc_event Email Unsubscribe def unsubscribe_recipient(unsubscribe, method): - if unsubscribe.reference_doctype == "Email Campaign": - frappe.db.set_value("Email Campaign", unsubscribe.reference_name, "status", "Unsubscribed") + if unsubscribe.reference_doctype != "Email Campaign": + return + + email_campaign = frappe.get_doc("Email Campaign", unsubscribe.reference_name) + + if email_campaign.email_campaign_for == "Email Group": + if unsubscribe.email: + frappe.db.set_value( + "Email Group Member", + {"email_group": email_campaign.recipient, "email": unsubscribe.email}, + "unsubscribed", + 1, + ) + else: + # For Lead or Contact + frappe.db.set_value("Email Campaign", email_campaign.name, "status", "Unsubscribed") # called through hooks to update email campaign status daily diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 0fa17d34d51..82bfa4cb5c1 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -767,12 +767,14 @@ class BOM(WebsiteGenerator): hour_rate / flt(self.conversion_rate) if self.conversion_rate and hour_rate else hour_rate ) - if row.hour_rate and row.time_in_mins: + if row.hour_rate: row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate) - row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0 - row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate) - row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0) - row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0) + + if row.time_in_mins: + row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0 + row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate) + row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0) + row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0) if update_hour_rate: row.db_update() diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 69d0cc8fbd8..d911456f602 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -31,6 +31,34 @@ frappe.ui.form.on("Job Card", { }; }); + frm.set_query("operation", "time_logs", () => { + let operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation); + return { + filters: { + name: ["in", operations], + }, + }; + }); + + frm.set_query("work_order", function () { + return { + filters: { + status: ["not in", ["Cancelled", "Closed", "Stopped"]], + }, + }; + }); + + frm.events.set_company_filters(frm, "target_warehouse"); + frm.events.set_company_filters(frm, "source_warehouse"); + frm.events.set_company_filters(frm, "wip_warehouse"); + frm.set_query("source_warehouse", "items", () => { + return { + filters: { + company: frm.doc.company, + }, + }; + }); + frm.set_indicator_formatter("sub_operation", function (doc) { if (doc.status == "Pending") { return "red"; diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index d2ca6ff73fd..1d63f91caaf 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1664,8 +1664,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d ) sales_order = data.get("sales_order") + qty_precision = frappe.get_precision("Material Request Plan Item", "quantity") for item_code, details in item_details.items(): + details.qty = flt(details.qty, qty_precision) so_item_details.setdefault(sales_order, frappe._dict()) if item_code in so_item_details.get(sales_order, {}): so_item_details[sales_order][item_code]["qty"] = so_item_details[sales_order][item_code].get( diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 04974e2e2ba..bc3def1186f 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -383,7 +383,7 @@ class WorkOrder(Document): if self.docstatus == 0: status = "Draft" elif self.docstatus == 1: - if status != "Stopped": + if status not in ["Closed", "Stopped"]: status = "Not Started" if flt(self.material_transferred_for_manufacturing) > 0: status = "In Process" diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 4d4d485c71a..46eefff1671 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -1018,11 +1018,11 @@ class TestQuotation(FrappeTestCase): def test_make_quotation_qar_to_inr(self): quotation = make_quotation( currency="QAR", - transaction_date="2026-06-04", + transaction_date="2026-01-01", ) cache = frappe.cache() - key = "currency_exchange_rate_{}:{}:{}".format("2026-06-04", "QAR", "INR") + key = "currency_exchange_rate_{}:{}:{}".format("2026-01-01", "QAR", "INR") value = cache.get(key) expected_rate = flt(value) / 3.64 diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 17f65ce270c..018a4f86fa8 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -281,8 +281,13 @@ erpnext.stock.move_item = function (item, source, target, actual_qty, rate, stoc } dialog.set_primary_action(__("Create Stock Entry"), function () { - if (source && (dialog.get_value("qty") == 0 || dialog.get_value("qty") > actual_qty)) { - frappe.msgprint(__("Quantity must be greater than zero, and less or equal to {0}", [actual_qty])); + if (flt(dialog.get_value("qty")) <= 0) { + frappe.msgprint(__("Quantity must be greater than zero")); + return; + } + + if (source && dialog.get_value("qty") > actual_qty) { + frappe.msgprint(__("Quantity must be less than or equal to {0}", [actual_qty])); return; } diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py index 3d7c21639e0..e5635bb4093 100644 --- a/erpnext/stock/dashboard/item_dashboard.py +++ b/erpnext/stock/dashboard/item_dashboard.py @@ -1,6 +1,6 @@ import frappe from frappe.model.db_query import DatabaseQuery -from frappe.utils import cint, flt +from frappe.utils import cint, escape_html, flt from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( get_sre_reserved_qty_for_items_and_warehouses as get_reserved_stock_details, @@ -70,8 +70,10 @@ def get_data( for item in items: item.update( { - "item_name": frappe.get_cached_value("Item", item.item_code, "item_name"), - "stock_uom": frappe.get_cached_value("Item", item.item_code, "stock_uom"), + "item_code": escape_html(item.item_code), + "item_name": escape_html(frappe.get_cached_value("Item", item.item_code, "item_name")), + "stock_uom": escape_html(frappe.get_cached_value("Item", item.item_code, "stock_uom")), + "warehouse": escape_html(item.warehouse), "disable_quick_entry": frappe.get_cached_value("Item", item.item_code, "has_batch_no") or frappe.get_cached_value("Item", item.item_code, "has_serial_no"), "projected_qty": flt(item.projected_qty, precision), diff --git a/erpnext/stock/dashboard/item_dashboard_list.html b/erpnext/stock/dashboard/item_dashboard_list.html index ae90ff80686..34d51814b2f 100644 --- a/erpnext/stock/dashboard/item_dashboard_list.html +++ b/erpnext/stock/dashboard/item_dashboard_list.html @@ -50,15 +50,15 @@ data-warehouse="{{ d.warehouse }}" data-actual_qty="{{ d.actual_qty }}" data-stock-uom="{{ d.stock_uom }}" - data-item="{{ escape(d.item_code) }}">{{ __("Move") }} + data-item="{{ d.item_code }}">{{ __("Move") }} {% endif %} {% endif %} diff --git a/erpnext/stock/dashboard/warehouse_capacity_dashboard.py b/erpnext/stock/dashboard/warehouse_capacity_dashboard.py index 24e0ef11ffa..83e5b1fe642 100644 --- a/erpnext/stock/dashboard/warehouse_capacity_dashboard.py +++ b/erpnext/stock/dashboard/warehouse_capacity_dashboard.py @@ -1,6 +1,6 @@ import frappe from frappe.model.db_query import DatabaseQuery -from frappe.utils import flt, nowdate +from frappe.utils import escape_html, flt, nowdate from erpnext.stock.utils import get_stock_balance @@ -75,6 +75,9 @@ def get_warehouse_capacity_data(filters, start): balance_qty = get_stock_balance(entry.item_code, entry.warehouse, nowdate()) or 0 entry.update( { + "warehouse": escape_html(entry.warehouse), + "item_code": escape_html(entry.item_code), + "company": escape_html(entry.company), "actual_qty": balance_qty, "percent_occupied": flt((flt(balance_qty) / flt(entry.stock_capacity)) * 100, 0), } diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 9a478ace4d3..60a26b91665 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn from frappe.utils import cint, flt import erpnext +from erpnext import is_perpetual_inventory_enabled from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -160,6 +161,9 @@ class LandedCostVoucher(Document): ) def validate_expense_accounts(self): + if not is_perpetual_inventory_enabled(self.company): + return + for t in self.taxes: company = frappe.get_cached_value("Account", t.expense_account, "company") diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 737df053832..fd65b7f60e7 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -175,6 +175,8 @@ class TestLandedCostVoucher(FrappeTestCase): self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0) def test_lcv_validates_company(self): + from erpnext import is_perpetual_inventory_enabled + from erpnext.accounts.doctype.account.test_account import create_account from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( IncorrectCompanyValidationError, ) @@ -182,6 +184,20 @@ class TestLandedCostVoucher(FrappeTestCase): company_a = "_Test Company" company_b = "_Test Company with perpetual inventory" + srbnb = create_account( + account_name="Stock Received But Not Billed", + account_type="Stock Received But Not Billed", + parent_account="Stock Liabilities - _TC", + company=company_a, + account_currency="INR", + ) + + epi = is_perpetual_inventory_enabled(company_a) + company_doc = frappe.get_doc("Company", company_a) + company_doc.enable_perpetual_inventory = 1 + company_doc.stock_received_but_not_billed = srbnb + company_doc.save() + pr = make_purchase_receipt( company=company_a, warehouse="Stores - _TC", @@ -207,6 +223,9 @@ class TestLandedCostVoucher(FrappeTestCase): distribute_landed_cost_on_items(lcv) lcv.submit() + frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi) + frappe.local.enable_perpetual_inventory = {} + def test_landed_cost_voucher_for_zero_purchase_rate(self): "Test impact of LCV on future stock balances." from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 1b444971659..d717bbceeb3 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -505,8 +505,26 @@ class PickList(TransactionBase): self.item_location_map = frappe._dict() from_warehouses = [self.parent_warehouse] if self.parent_warehouse else [] - if self.parent_warehouse: - from_warehouses.extend(get_descendants_of("Warehouse", self.parent_warehouse)) + + if self.work_order: + root_warehouse = frappe.db.get_value( + "Warehouse", {"company": self.company, "parent_warehouse": ["IS", "NOT SET"], "is_group": 1} + ) + + from_warehouses = [root_warehouse] + + if from_warehouses: + from_warehouses.extend(get_descendants_of("Warehouse", from_warehouses[0])) + + item_warehouse_dict = frappe._dict() + if self.work_order: + item_warehouse_list = frappe.get_all( + "Work Order Item", + filters={"parent": self.work_order}, + fields=["item_code", "source_warehouse"], + ) + if item_warehouse_list: + item_warehouse_dict = {item.item_code: item.source_warehouse for item in item_warehouse_list} # Create replica before resetting, to handle empty table on update after submit. locations_replica = self.get("locations") @@ -524,6 +542,13 @@ class PickList(TransactionBase): len_idx = len(self.get("locations")) or 0 for item_doc in items: item_code = item_doc.item_code + priority_warehouses = [] + + if self.work_order and item_warehouse_dict.get(item_code): + source_warehouse = item_warehouse_dict.get(item_code) + priority_warehouses = [source_warehouse] + priority_warehouses.extend(get_descendants_of("Warehouse", source_warehouse)) + from_warehouses = list(dict.fromkeys(priority_warehouses + from_warehouses)) self.item_location_map.setdefault( item_code, @@ -534,6 +559,7 @@ class PickList(TransactionBase): self.company, picked_item_details=picked_items_details.get(item_code), consider_rejected_warehouses=self.consider_rejected_warehouses, + priority_warehouses=priority_warehouses, ), ) @@ -959,6 +985,7 @@ def get_available_item_locations( ignore_validation=False, picked_item_details=None, consider_rejected_warehouses=False, + priority_warehouses=None, ): locations = [] @@ -999,7 +1026,7 @@ def get_available_item_locations( locations = filter_locations_by_picked_materials(locations, picked_item_details) if locations: - locations = get_locations_based_on_required_qty(locations, required_qty) + locations = get_locations_based_on_required_qty(locations, required_qty, priority_warehouses) if not ignore_validation: validate_picked_materials(item_code, required_qty, locations, picked_item_details) @@ -1007,9 +1034,14 @@ def get_available_item_locations( return locations -def get_locations_based_on_required_qty(locations, required_qty): +def get_locations_based_on_required_qty(locations, required_qty, priority_warehouses): filtered_locations = [] + if priority_warehouses: + priority_locations = [loc for loc in locations if loc.warehouse in priority_warehouses] + fallback_locations = [loc for loc in locations if loc.warehouse not in priority_warehouses] + locations = priority_locations + fallback_locations + for location in locations: if location.qty >= required_qty: location.qty = required_qty diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 4d3c6b45a6a..83d1827794c 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1041,6 +1041,53 @@ class TestPickList(FrappeTestCase): pl = create_pick_list(so.name) self.assertFalse(pl.locations) + def test_pick_list_warehouse_for_work_order(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list, make_work_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + # Create Warehouses for Work Order + source_warehouse = create_warehouse("_Test WO Warehouse") + wip_warehouse = create_warehouse("_Test WIP Warehouse", company="_Test Company") + fg_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company") + + # Create Finished Good Item + fg_item = make_item("Test Work Order Finished Good Item", properties={"is_stock_item": 1}).name + + # Create Raw Material Item + rm_item = make_item("Test Work Order Raw Material Item", properties={"is_stock_item": 1}).name + + # Create BOM + bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item]) + + # Create Inward entry for Raw Material + make_stock_entry(item=rm_item, to_warehouse=wip_warehouse, qty=10) + make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=10) + + # Create Work Order + wo = make_work_order(item=fg_item, qty=5, bom_no=bom.name, company="_Test Company") + wo.required_items[0].source_warehouse = source_warehouse + wo.fg_warehouse = fg_warehouse + wo.skip_transfer = True + wo.submit() + + # Create Pick List + pl = create_pick_list(wo.name, for_qty=wo.qty) + + # System prioritises the Source Warehouse + self.assertEqual(pl.locations[0].warehouse, source_warehouse) + self.assertEqual(pl.locations[0].item_code, rm_item) + self.assertEqual(pl.locations[0].qty, 5) + + # Create Outward Entry from Source Warehouse + make_stock_entry(item=rm_item, from_warehouse=source_warehouse, qty=10) + pl.set_item_locations() + + # System should pick other available warehouses + self.assertEqual(pl.locations[0].warehouse, wip_warehouse) + self.assertEqual(pl.locations[0].item_code, rm_item) + self.assertEqual(pl.locations[0].qty, 5) + def test_pick_list_validation_for_serial_no(self): warehouse = "_Test Warehouse - _TC" item = make_item( diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index d2e0397a7ce..8508031578d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1217,6 +1217,65 @@ class TestPurchaseReceipt(FrappeTestCase): pr.cancel() + def test_item_valuation_with_deduct_valuation_and_total_tax(self): + pr = make_purchase_receipt( + company="_Test Company with perpetual inventory", + warehouse="Stores - TCP1", + supplier_warehouse="Work In Progress - TCP1", + qty=5, + rate=100, + do_not_save=1, + ) + + pr.append( + "taxes", + { + "charge_type": "Actual", + "add_deduct_tax": "Deduct", + "account_head": "_Test Account Shipping Charges - TCP1", + "category": "Valuation and Total", + "cost_center": "Main - TCP1", + "description": "Valuation Discount", + "tax_amount": 20, + }, + ) + + pr.insert() + + self.assertAlmostEqual(pr.items[0].item_tax_amount, -20.0, places=2) + self.assertAlmostEqual(pr.items[0].valuation_rate, 96.0, places=2) + + pr.delete() + + pr = make_purchase_receipt( + company="_Test Company with perpetual inventory", + warehouse="Stores - TCP1", + supplier_warehouse="Work In Progress - TCP1", + qty=5, + rate=100, + do_not_save=1, + ) + + pr.append( + "taxes", + { + "charge_type": "On Net Total", + "add_deduct_tax": "Deduct", + "account_head": "_Test Account Shipping Charges - TCP1", + "category": "Valuation and Total", + "cost_center": "Main - TCP1", + "description": "Valuation Discount", + "rate": 10, + }, + ) + + pr.insert() + + self.assertAlmostEqual(pr.items[0].item_tax_amount, -50.0, places=2) + self.assertAlmostEqual(pr.items[0].valuation_rate, 90.0, places=2) + + pr.delete() + def test_po_to_pi_and_po_to_pr_worflow_full(self): """Test following behaviour: - Create PO diff --git a/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json b/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json index 0dc4fd113fc..757712b2a8d 100644 --- a/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json +++ b/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json @@ -14,19 +14,19 @@ "fields": [ { "fieldname": "length", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Length (cm)" }, { "fieldname": "width", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Width (cm)" }, { "fieldname": "height", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Height (cm)" }, @@ -49,7 +49,7 @@ ], "istable": 1, "links": [], - "modified": "2024-03-06 16:48:57.355757", + "modified": "2026-03-29 00:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Shipment Parcel", @@ -60,4 +60,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json index 4735d9f8866..91fcebf6235 100644 --- a/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json +++ b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json @@ -15,21 +15,21 @@ "fields": [ { "fieldname": "length", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Length (cm)", "reqd": 1 }, { "fieldname": "width", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Width (cm)", "reqd": 1 }, { "fieldname": "height", - "fieldtype": "Int", + "fieldtype": "Float", "in_list_view": 1, "label": "Height (cm)", "reqd": 1 @@ -52,7 +52,7 @@ } ], "links": [], - "modified": "2020-09-28 12:51:00.320421", + "modified": "2026-03-29 00:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Shipment Parcel Template", @@ -75,4 +75,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 5e9a749d672..fd2860a6f58 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -38,18 +38,18 @@ frappe.ui.form.on("Stock Entry", { frm.set_query("source_warehouse_address", function () { return { + query: "erpnext.controllers.queries.get_warehouse_address", filters: { - link_doctype: "Warehouse", - link_name: frm.doc.from_warehouse, + warehouse: frm.doc.from_warehouse, }, }; }); frm.set_query("target_warehouse_address", function () { return { + query: "erpnext.controllers.queries.get_warehouse_address", filters: { - link_doctype: "Warehouse", - link_name: frm.doc.to_warehouse, + warehouse: frm.doc.to_warehouse, }, }; }); diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e0c2bf2bcfc..1171dd73eab 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -7,6 +7,7 @@ from operator import itemgetter import frappe from frappe import _ +from frappe.query_builder.functions import Count from frappe.utils import cint, date_diff, flt, get_datetime from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -238,9 +239,9 @@ class FIFOSlots: Returns dict of the foll.g structure: Key = Item A / (Item A, Warehouse A) Key: { - 'details' -> Dict: ** item details **, - 'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock, - consumed/updated and maintained via FIFO. ** + 'details' -> Dict: ** item details **, + 'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock, + consumed/updated and maintained via FIFO. ** } """ from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle @@ -251,16 +252,33 @@ class FIFOSlots: if stock_ledger_entries is None: bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos() + # prepare single sle voucher detail lookup + self.prepare_stock_reco_voucher_wise_count() + with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: stock_ledger_entries = self.__get_stock_ledger_entries() for d in stock_ledger_entries: key, fifo_queue, transferred_item_key = self.__init_key_stores(d) + prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) - if d.voucher_type == "Stock Reconciliation": + if d.voucher_type == "Stock Reconciliation" and ( + not d.batch_no or d.serial_no or d.serial_and_batch_bundle + ): + if d.voucher_detail_no in self.stock_reco_voucher_wise_count: + # for legacy recon with single sle has qty_after_transaction and stock_value_difference without outward entry + # for exisitng handle emptying the existing queue and details. + d.stock_value_difference = flt(d.qty_after_transaction * d.valuation_rate) + d.actual_qty = d.qty_after_transaction + self.item_details[key]["qty_after_transaction"] = 0 + self.item_details[key]["total_qty"] = 0 + fifo_queue.clear() + else: + d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) + + elif d.voucher_type == "Stock Reconciliation": # get difference in qty shift as actual qty - prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) serial_nos = get_serial_nos(d.serial_no) if d.serial_no else [] @@ -278,6 +296,14 @@ class FIFOSlots: self.__update_balances(d, key) + # handle serial nos misconsumption + if d.has_serial_no: + qty_after = cint(self.item_details[key]["qty_after_transaction"]) + if qty_after <= 0: + fifo_queue.clear() + elif len(fifo_queue) > qty_after: + fifo_queue[:] = fifo_queue[:qty_after] + # Note that stock_ledger_entries is an iterator, you can not reuse it like a list del stock_ledger_entries @@ -404,7 +430,6 @@ class FIFOSlots: def __update_balances(self, row: dict, key: tuple | str): self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction - if "total_qty" not in self.item_details[key]: self.item_details[key]["total_qty"] = row.actual_qty else: @@ -460,6 +485,7 @@ class FIFOSlots: sle.posting_date, sle.voucher_type, sle.voucher_no, + sle.voucher_detail_no, sle.serial_no, sle.batch_no, sle.qty_after_transaction, @@ -555,3 +581,36 @@ class FIFOSlots: warehouse_results = [x[0] for x in warehouse_results] return sle_query.where(sle.warehouse.isin(warehouse_results)) + + def prepare_stock_reco_voucher_wise_count(self): + self.stock_reco_voucher_wise_count = frappe._dict() + + doctype = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(item) + .on(doctype.item_code == item.name) + .select(doctype.voucher_detail_no, Count(doctype.name).as_("count")) + .where( + (doctype.voucher_type == "Stock Reconciliation") + & (doctype.docstatus < 2) + & (doctype.is_cancelled == 0) + ) + .groupby(doctype.voucher_detail_no) + ) + + data = query.run(as_dict=True) + if not data: + return + + for row in data: + if row.count != 1: + continue + + sr_item = frappe.db.get_value( + "Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True + ) + if sr_item.qty and sr_item.current_qty: + self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html index 0805a32ae33..5563a58b730 100644 --- a/erpnext/templates/pages/order.html +++ b/erpnext/templates/pages/order.html @@ -140,7 +140,7 @@