diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json index 35a3196c140..76e64ff4fa1 100644 --- a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json +++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json @@ -48,6 +48,7 @@ "fieldname": "amount", "fieldtype": "Currency", "label": "Amount", + "options": "currency", "read_only": 1 }, { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index ca31f1de4ec..efaeff12279 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -603,6 +603,7 @@ }, { "default": "0", + "depends_on": "eval:doc.items.every((item) => !item.pr_detail)", "fieldname": "update_stock", "fieldtype": "Check", "label": "Update Stock", @@ -1659,7 +1660,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2025-08-04 19:19:11.380664", + "modified": "2026-02-05 20:45:16.964500", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8be04ab67a1..c53d72f9eba 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -38,7 +38,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status -from erpnext.controllers.accounts_controller import validate_account_head +from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( @@ -2083,6 +2083,19 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + def post_parent_process(source_parent, target_parent): + remove_items_with_zero_qty(target_parent) + set_missing_values(source_parent, target_parent) + + def remove_items_with_zero_qty(target_parent): + target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0] + + def set_missing_values(source_parent, target_parent): + target_parent.run_method("set_missing_values") + if args and args.get("merge_taxes"): + merge_taxes(source_parent, target_parent) + target_parent.run_method("calculate_taxes_and_totals") + def update_item(obj, target, source_parent): target.qty = flt(obj.qty) - flt(obj.received_qty) target.received_qty = flt(obj.qty) - flt(obj.received_qty) @@ -2122,7 +2135,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): "postprocess": update_item, "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), }, - "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"}, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": not (args and args.get("merge_taxes")), + "ignore": args.get("merge_taxes") if args else 0, + }, }, target_doc, ) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 8cc9598d915..8f04f173c1f 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -701,6 +701,7 @@ }, { "default": "0", + "depends_on": "eval:doc.items.every((item) => !item.dn_detail)", "fieldname": "update_stock", "fieldtype": "Check", "hide_days": 1, @@ -2199,7 +2200,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2025-09-09 14:48:59.472826", + "modified": "2026-02-05 20:43:44.732805", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index d2fe570fa3b..55ab95ac662 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -5,15 +5,16 @@ from collections import OrderedDict import frappe from frappe import _, qb, scrub -from frappe.query_builder import Order +from frappe.query_builder import Case, Order +from frappe.query_builder.functions import Coalesce from frappe.utils import cint, flt, formatdate +from pypika.terms import ExistsCriterion from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, get_dimension_with_children, ) from erpnext.accounts.report.financial_statements import get_cost_centers_with_children -from erpnext.controllers.queries import get_match_cond from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition from erpnext.stock.utils import get_incoming_rate @@ -176,7 +177,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ column_names = get_column_names() # to display item as Item Code: Item Name - columns[0] = "Sales Invoice:Link/Item:300" + columns[0]["fieldname"] = "sales_invoice" + columns[0]["options"] = "Item" + columns[0]["width"] = 300 # removing Item Code and Item Name columns supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name") customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name") @@ -203,7 +206,11 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ data.append(row) - total_gross_profit = total_base_amount - total_buying_amount + total_gross_profit = flt( + total_base_amount + abs(total_buying_amount) + if total_buying_amount < 0 + else total_base_amount - total_buying_amount, + ) data.append( frappe._dict( { @@ -215,7 +222,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ "buying_amount": total_buying_amount, "gross_profit": total_gross_profit, "gross_profit_%": flt( - (total_gross_profit / total_base_amount) * 100.0, + (total_gross_profit / abs(total_base_amount)) * 100.0, cint(frappe.db.get_default("currency_precision")) or 3, ) if total_base_amount @@ -248,9 +255,13 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_ data.append(row) - total_gross_profit = total_base_amount - total_buying_amount + total_gross_profit = flt( + total_base_amount + abs(total_buying_amount) + if total_buying_amount < 0 + else total_base_amount - total_buying_amount, + ) currency_precision = cint(frappe.db.get_default("currency_precision")) or 3 - gross_profit_percent = (total_gross_profit / total_base_amount * 100.0) if total_base_amount else 0 + gross_profit_percent = (total_gross_profit / abs(total_base_amount) * 100.0) if total_base_amount else 0 total_row = { group_columns[0]: "Total", @@ -581,10 +592,15 @@ class GrossProfitGenerator: base_amount += row.base_amount # calculate gross profit - row.gross_profit = flt(row.base_amount - row.buying_amount, self.currency_precision) + row.gross_profit = flt( + row.base_amount + abs(row.buying_amount) + if row.buying_amount < 0 + else row.base_amount - row.buying_amount, + self.currency_precision, + ) if row.base_amount: row.gross_profit_percent = flt( - (row.gross_profit / row.base_amount) * 100.0, + (row.gross_profit / abs(row.base_amount)) * 100.0, self.currency_precision, ) else: @@ -673,9 +689,14 @@ class GrossProfitGenerator: return new_row def set_average_gross_profit(self, new_row): - new_row.gross_profit = flt(new_row.base_amount - new_row.buying_amount, self.currency_precision) + new_row.gross_profit = flt( + new_row.base_amount + abs(new_row.buying_amount) + if new_row.buying_amount < 0 + else new_row.base_amount - new_row.buying_amount, + self.currency_precision, + ) new_row.gross_profit_percent = ( - flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) + flt(((new_row.gross_profit / abs(new_row.base_amount)) * 100.0), self.currency_precision) if new_row.base_amount else 0 ) @@ -851,129 +872,173 @@ class GrossProfitGenerator: return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0 def load_invoice_items(self): - conditions = "" - if self.filters.company: - conditions += " and `tabSales Invoice`.company = %(company)s" - if self.filters.from_date: - conditions += " and posting_date >= %(from_date)s" - if self.filters.to_date: - conditions += " and posting_date <= %(to_date)s" + self.si_list = [] + + SalesInvoice = frappe.qb.DocType("Sales Invoice") + base_query = self.prepare_invoice_query() if self.filters.include_returned_invoices: - conditions += " and (is_return = 0 or (is_return=1 and return_against is null))" + invoice_query = base_query.where( + (SalesInvoice.is_return == 0) + | ((SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnull()) + ) else: - conditions += " and is_return = 0" + invoice_query = base_query.where(SalesInvoice.is_return == 0) - if self.filters.item_group: - conditions += f" and {get_item_group_condition(self.filters.item_group)}" + self.si_list += invoice_query.run(as_dict=True) + self.prepare_vouchers_to_ignore() - if self.filters.sales_person: - conditions += """ - and exists(select 1 - from `tabSales Team` st - where st.parent = `tabSales Invoice`.name - and st.sales_person = %(sales_person)s) - """ + ret_invoice_query = base_query.where( + (SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull() + ) + if self.vouchers_to_ignore: + ret_invoice_query = ret_invoice_query.where( + SalesInvoice.return_against.notin(self.vouchers_to_ignore) + ) + + self.si_list += ret_invoice_query.run(as_dict=True) + + def prepare_invoice_query(self): + SalesInvoice = frappe.qb.DocType("Sales Invoice") + SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item") + Item = frappe.qb.DocType("Item") + SalesTeam = frappe.qb.DocType("Sales Team") + PaymentSchedule = frappe.qb.DocType("Payment Schedule") + + query = ( + frappe.qb.from_(SalesInvoice) + .join(SalesInvoiceItem) + .on(SalesInvoiceItem.parent == SalesInvoice.name) + .join(Item) + .on(Item.name == SalesInvoiceItem.item_code) + .where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes")) + ) + + query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item) + + query = query.select( + SalesInvoiceItem.parenttype, + SalesInvoiceItem.parent, + SalesInvoice.posting_date, + SalesInvoice.posting_time, + SalesInvoice.project, + SalesInvoice.update_stock, + SalesInvoice.customer, + SalesInvoice.customer_group, + SalesInvoice.customer_name, + SalesInvoice.territory, + SalesInvoiceItem.item_code, + SalesInvoice.base_net_total.as_("invoice_base_net_total"), + SalesInvoiceItem.item_name, + SalesInvoiceItem.description, + SalesInvoiceItem.warehouse, + SalesInvoiceItem.item_group, + SalesInvoiceItem.brand, + SalesInvoiceItem.so_detail, + SalesInvoiceItem.sales_order, + SalesInvoiceItem.dn_detail, + SalesInvoiceItem.delivery_note, + SalesInvoiceItem.stock_qty.as_("qty"), + SalesInvoiceItem.base_net_rate, + SalesInvoiceItem.base_net_amount, + SalesInvoiceItem.name.as_("item_row"), + SalesInvoice.is_return, + SalesInvoiceItem.cost_center, + SalesInvoiceItem.serial_and_batch_bundle, + ) if self.filters.group_by == "Sales Person": - sales_person_cols = """, sales.sales_person, - sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount, - sales.incentives - """ - sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name" - else: - sales_person_cols = "" - sales_team_table = "" + query = query.select( + SalesTeam.sales_person, + (SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_( + "allocated_amount" + ), + SalesTeam.incentives, + ) + + query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name) if self.filters.group_by == "Payment Term": - payment_term_cols = """,if(`tabSales Invoice`.is_return = 1, - '{}', - coalesce(schedule.payment_term, '{}')) as payment_term, - schedule.invoice_portion, - schedule.payment_amount """.format(_("Sales Return"), _("No Terms")) - payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and - `tabSales Invoice`.is_return = 0 """ - else: - payment_term_cols = "" - payment_term_table = "" + query = query.select( + Case() + .when(SalesInvoice.is_return == 1, _("Sales Return")) + .else_(Coalesce(PaymentSchedule.payment_term, _("No Terms"))) + .as_("payment_term"), + PaymentSchedule.invoice_portion, + PaymentSchedule.payment_amount, + ) - if self.filters.get("sales_invoice"): - conditions += " and `tabSales Invoice`.name = %(sales_invoice)s" + query = query.left_join(PaymentSchedule).on( + (PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0) + ) - if self.filters.get("item_code"): - conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s" + query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby( + SalesInvoice.posting_time, order=Order.desc + ) - if self.filters.get("cost_center"): + return query + + def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item): + if self.filters.company: + query = query.where(SalesInvoice.company == self.filters.company) + + if self.filters.from_date: + query = query.where(SalesInvoice.posting_date >= self.filters.from_date) + + if self.filters.to_date: + query = query.where(SalesInvoice.posting_date <= self.filters.to_date) + + if self.filters.item_group: + query = query.where(get_item_group_condition(self.filters.item_group, Item)) + + if self.filters.sales_person: + query = query.where( + ExistsCriterion( + frappe.qb.from_(SalesTeam) + .select(1) + .where( + (SalesTeam.parent == SalesInvoice.name) + & (SalesTeam.sales_person == self.filters.sales_person) + ) + ) + ) + + if self.filters.sales_invoice: + query = query.where(SalesInvoice.name == self.filters.sales_invoice) + + if self.filters.item_code: + query = query.where(SalesInvoiceItem.item_code == self.filters.item_code) + + if self.filters.cost_center: self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center")) self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center) - conditions += " and `tabSales Invoice Item`.cost_center in %(cost_center)s" + query = query.where(SalesInvoiceItem.cost_center.isin(self.filters.cost_center)) - if self.filters.get("project"): + if self.filters.project: self.filters.project = frappe.parse_json(self.filters.get("project")) - conditions += " and `tabSales Invoice Item`.project in %(project)s" + query = query.where(SalesInvoiceItem.project.isin(self.filters.project)) - accounting_dimensions = get_accounting_dimensions(as_list=False) - if accounting_dimensions: - for dimension in accounting_dimensions: - if self.filters.get(dimension.fieldname): - if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): - self.filters[dimension.fieldname] = get_dimension_with_children( - dimension.document_type, self.filters.get(dimension.fieldname) - ) - conditions += ( - f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s" - ) - else: - conditions += ( - f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s" - ) + for dim in get_accounting_dimensions(as_list=False) or []: + if self.filters.get(dim.fieldname): + if frappe.get_cached_value("DocType", dim.document_type, "is_tree"): + self.filters[dim.fieldname] = get_dimension_with_children( + dim.document_type, self.filters.get(dim.fieldname) + ) + query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.fieldname])) - if self.filters.get("warehouse"): - warehouse_details = frappe.db.get_value( - "Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1 + if self.filters.warehouse: + lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"]) + WH = frappe.qb.DocType("Warehouse") + query = query.where( + SalesInvoiceItem.warehouse.isin( + frappe.qb.from_(WH).select(WH.name).where((WH.lft >= lft) & (WH.rgt <= rgt)) + ) ) - if warehouse_details: - conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)" - self.si_list = frappe.db.sql( - """ - select - `tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent, - `tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time, - `tabSales Invoice`.project, `tabSales Invoice`.update_stock, - `tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name, - `tabSales Invoice`.territory, `tabSales Invoice Item`.item_code, - `tabSales Invoice`.base_net_total as "invoice_base_net_total", - `tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description, - `tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group, - `tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail, - `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail, - `tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty, - `tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount, - `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, - `tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle - {sales_person_cols} - {payment_term_cols} - from - `tabSales Invoice` inner join `tabSales Invoice Item` - on `tabSales Invoice Item`.parent = `tabSales Invoice`.name - join `tabItem` item on item.name = `tabSales Invoice Item`.item_code - {sales_team_table} - {payment_term_table} - where - `tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond} - order by - `tabSales Invoice`.posting_date desc, `tabSales Invoice`.posting_time desc""".format( - conditions=conditions, - sales_person_cols=sales_person_cols, - sales_team_table=sales_team_table, - payment_term_cols=payment_term_cols, - payment_term_table=payment_term_table, - match_cond=get_match_cond("Sales Invoice"), - ), - self.filters, - as_dict=1, - ) + return query + + def prepare_vouchers_to_ignore(self): + self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list) def get_delivery_notes(self): self.delivery_notes = frappe._dict({}) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index d92c16ab440..49ca61e950d 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -465,7 +465,7 @@ class TestGrossProfit(FrappeTestCase): "selling_amount": -100.0, "buying_amount": 0.0, "gross_profit": -100.0, - "gross_profit_%": 100.0, + "gross_profit_%": -100.0, } gp_entry = [x for x in data if x.parent_invoice == sinv.name] self.assertDictContainsSubset(expected_entry, gp_entry[0]) @@ -642,21 +642,24 @@ class TestGrossProfit(FrappeTestCase): def test_profit_for_later_period_return(self): month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) + sales_inv_date = month_start_date + return_inv_date = add_days(month_end_date, 1) + # create sales invoice on month start date sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True) sinv.set_posting_time = 1 - sinv.posting_date = month_start_date + sinv.posting_date = sales_inv_date sinv.save().submit() # create credit note on next month start date cr_note = make_sales_return(sinv.name) cr_note.set_posting_time = 1 - cr_note.posting_date = add_days(month_end_date, 1) + cr_note.posting_date = return_inv_date cr_note.save().submit() # apply filters for invoiced period filters = frappe._dict( - company=self.company, from_date=month_start_date, to_date=month_end_date, group_by="Invoice" + company=self.company, from_date=month_start_date, to_date=month_start_date, group_by="Invoice" ) _, data = execute(filters=filters) @@ -668,7 +671,7 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total.get("gross_profit_%"), 100.0) # extend filters upto returned period - filters.update(to_date=add_days(month_end_date, 1)) + filters.update({"to_date": return_inv_date}) _, data = execute(filters=filters) total = data[-1] @@ -677,3 +680,63 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total.buying_amount, 0.0) self.assertEqual(total.gross_profit, 0.0) self.assertEqual(total.get("gross_profit_%"), 0.0) + + # apply filters only on returned period + filters.update({"from_date": return_inv_date, "to_date": return_inv_date}) + _, data = execute(filters=filters) + total = data[-1] + + self.assertEqual(total.selling_amount, -100.0) + self.assertEqual(total.buying_amount, 0.0) + self.assertEqual(total.gross_profit, -100.0) + self.assertEqual(total.get("gross_profit_%"), -100.0) + + def test_sales_person_wise_gross_profit(self): + sales_person = make_sales_person("_Test Sales Person") + + posting_date = get_first_day(nowdate()) + qty = 10 + rate = 100 + + sinv = self.create_sales_invoice(qty=qty, rate=rate, do_not_save=True, do_not_submit=True) + sinv.set_posting_time = 1 + sinv.posting_date = posting_date + sinv.append( + "sales_team", + { + "sales_person": sales_person.name, + "allocated_percentage": 100, + "allocated_amount": 1000.0, + "commission_rate": 5, + "incentives": 5, + }, + ) + sinv.save().submit() + + filters = frappe._dict( + company=self.company, from_date=posting_date, to_date=posting_date, group_by="Sales Person" + ) + + _, data = execute(filters=filters) + total = data[-1] + + self.assertEqual(total[5], 1000.0) + self.assertEqual(total[6], 0.0) + self.assertEqual(total[7], 1000.0) + self.assertEqual(total[8], 100.0) + + +def make_sales_person(sales_person_name="_Test Sales Person"): + if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}): + sales_person_doc = frappe.get_doc( + { + "doctype": "Sales Person", + "is_group": 0, + "parent_sales_person": "Sales Team", + "sales_person_name": sales_person_name, + } + ).insert(ignore_permissions=True) + else: + sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name}) + + return sales_person_doc diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index 44aa271846c..6c93fd50583 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, get_link_to_form +from frappe.utils import cstr, get_datetime, get_link_to_form from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity @@ -34,6 +34,7 @@ class AssetMovement(Document): for d in self.assets: self.validate_asset(d) self.validate_movement(d) + self.validate_transaction_date(d) def validate_asset(self, d): status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"]) @@ -51,6 +52,18 @@ class AssetMovement(Document): else: self.validate_employee(d) + def validate_transaction_date(self, d): + previous_movement_date = frappe.db.get_value( + "Asset Movement", + [["Asset Movement Item", "asset", "=", d.asset], ["docstatus", "=", 1]], + "transaction_date", + order_by="transaction_date desc", + ) + if previous_movement_date and get_datetime(previous_movement_date) > get_datetime( + self.transaction_date + ): + frappe.throw(_("Transaction date can't be earlier than previous movement date")) + def validate_location_and_employee(self, d): self.validate_location(d) self.validate_employee(d) diff --git a/erpnext/assets/doctype/asset_movement/test_asset_movement.py b/erpnext/assets/doctype/asset_movement/test_asset_movement.py index 07879acd1f0..e99e8c194b3 100644 --- a/erpnext/assets/doctype/asset_movement/test_asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/test_asset_movement.py @@ -4,9 +4,9 @@ import unittest import frappe -from frappe.utils import now +from frappe.utils import add_days, now -from erpnext.assets.doctype.asset.test_asset import create_asset_data +from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data from erpnext.setup.doctype.employee.test_employee import make_employee from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -147,6 +147,33 @@ class TestAssetMovement(unittest.TestCase): movement1.cancel() self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location") + def test_movement_transaction_date(self): + asset = create_asset(item_code="Macbook Pro", do_not_save=1) + asset.save().submit() + + if not frappe.db.exists("Location", "Test Location 2"): + frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert() + + asset_creation_date = frappe.db.get_value( + "Asset Movement", + [["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]], + "transaction_date", + ) + asset_movement = create_asset_movement( + purpose="Transfer", + company=asset.company, + assets=[ + { + "asset": asset.name, + "source_location": "Test Location", + "target_location": "Test Location 2", + } + ], + transaction_date=add_days(asset_creation_date, -1), + do_not_save=True, + ) + self.assertRaises(frappe.ValidationError, asset_movement.save) + def create_asset_movement(**args): args = frappe._dict(args) @@ -165,9 +192,10 @@ def create_asset_movement(**args): "reference_name": args.reference_name, } ) - - movement.insert() - movement.submit() + if not args.do_not_save: + movement.insert(ignore_if_duplicate=True) + if not args.do_not_submit: + movement.submit() return movement diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index e04ac2ce302..a27244d1528 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -139,14 +139,6 @@ frappe.ui.form.on("Supplier", { // indicators erpnext.utils.set_party_dashboard_indicators(frm); } - - frm.set_query("supplier_group", () => { - return { - filters: { - is_group: 0, - }, - }; - }); }, get_supplier_group_details: function (frm) { frappe.call({ diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index c70b3e4081a..ee3c9c840f0 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -165,6 +165,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Supplier Group", + "link_filters": "[[\"Supplier Group\",\"is_group\",\"=\",0]]", "oldfieldname": "supplier_type", "oldfieldtype": "Link", "options": "Supplier Group" @@ -485,7 +486,7 @@ "link_fieldname": "party" } ], - "modified": "2024-05-08 18:02:57.342931", + "modified": "2026-02-06 12:58:01.398824", "modified_by": "Administrator", "module": "Buying", "name": "Supplier", @@ -551,4 +552,4 @@ "states": [], "title_field": "supplier_name", "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 a5a2132dc0c..9e24a26caa8 100644 --- a/erpnext/crm/doctype/email_campaign/email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -38,18 +38,18 @@ class EmailCampaign(Document): def set_date(self): if getdate(self.start_date) < getdate(today()): frappe.throw(_("Start Date cannot be before the current date")) + # set the end date as start date + max(send after days) in campaign schedule - send_after_days = [] - campaign = frappe.get_doc("Campaign", self.campaign_name) - for entry in campaign.get("campaign_schedules"): - send_after_days.append(entry.send_after_days) - try: - self.end_date = add_days(getdate(self.start_date), max(send_after_days)) - except ValueError: + campaign = frappe.get_cached_doc("Campaign", self.campaign_name) + send_after_days = [entry.send_after_days for entry in campaign.get("campaign_schedules")] + + if not send_after_days: frappe.throw( _("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name) ) + self.end_date = add_days(getdate(self.start_date), max(send_after_days)) + def validate_lead(self): lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id") if not lead_email_id: @@ -77,58 +77,128 @@ class EmailCampaign(Document): start_date = getdate(self.start_date) end_date = getdate(self.end_date) today_date = getdate(today()) + if start_date > today_date: - self.db_set("status", "Scheduled", update_modified=False) + new_status = "Scheduled" elif end_date >= today_date: - self.db_set("status", "In Progress", update_modified=False) - elif end_date < today_date: - self.db_set("status", "Completed", update_modified=False) + new_status = "In Progress" + else: + new_status = "Completed" + + if self.status != new_status: + self.db_set("status", new_status, update_modified=False) # called through hooks to send campaign mails to leads def send_email_to_leads_or_contacts(): + today_date = getdate(today()) + + # Get all active email campaigns in a single query email_campaigns = frappe.get_all( - "Email Campaign", filters={"status": ("not in", ["Unsubscribed", "Completed", "Scheduled"])} + "Email Campaign", + filters={"status": "In Progress"}, + fields=["name", "campaign_name", "email_campaign_for", "recipient", "start_date", "sender"], ) - for camp in email_campaigns: - email_campaign = frappe.get_doc("Email Campaign", camp.name) - campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name) + + if not email_campaigns: + return + + # Process each email campaign + for email_campaign in email_campaigns: + try: + campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name) + except frappe.DoesNotExistError: + frappe.log_error( + title=_("Email Campaign Error"), + message=_("Campaign {0} not found").format(email_campaign.campaign_name), + ) + continue + + # Find schedules that match today for entry in campaign.get("campaign_schedules"): - scheduled_date = add_days(email_campaign.get("start_date"), entry.get("send_after_days")) - if scheduled_date == getdate(today()): - send_mail(entry, email_campaign) + try: + scheduled_date = add_days(getdate(email_campaign.start_date), entry.get("send_after_days")) + if scheduled_date == today_date: + send_mail(entry, email_campaign) + except Exception: + frappe.log_error( + title=_("Email Campaign Send Error"), + message=_("Failed to send email for campaign {0} to {1}").format( + email_campaign.name, email_campaign.recipient + ), + ) def send_mail(entry, email_campaign): - recipient_list = [] - if email_campaign.email_campaign_for == "Email Group": - for member in frappe.db.get_list( - "Email Group Member", filters={"email_group": email_campaign.get("recipient")}, fields=["email"] - ): - recipient_list.append(member["email"]) + campaign_for = email_campaign.get("email_campaign_for") + recipient = email_campaign.get("recipient") + sender_user = email_campaign.get("sender") + campaign_name = email_campaign.get("name") + + # Get recipient emails + if campaign_for == "Email Group": + recipient_list = frappe.get_all( + "Email Group Member", + filters={"email_group": recipient, "unsubscribed": 0}, + pluck="email", + ) else: - recipient_list.append( - frappe.db.get_value( - email_campaign.email_campaign_for, email_campaign.get("recipient"), "email_id" + email_id = frappe.db.get_value(campaign_for, recipient, "email_id") + if not email_id: + frappe.log_error( + title=_("Email Campaign Error"), + message=_("No email found for {0} {1}").format(campaign_for, recipient), ) + return + recipient_list = [email_id] + + if not recipient_list: + frappe.log_error( + title=_("Email Campaign Error"), + message=_("No recipients found for campaign {0}").format(campaign_name), + ) + return + + # Get email template and sender + email_template = frappe.get_cached_doc("Email Template", entry.get("email_template")) + sender = frappe.db.get_value("User", sender_user, "email") if sender_user else None + + # Build context for template rendering + if campaign_for != "Email Group": + context = {"doc": frappe.get_doc(campaign_for, recipient)} + else: + # For email groups, use the email group document as context + context = {"doc": frappe.get_doc("Email Group", recipient)} + + # Render template + subject = frappe.render_template(email_template.get("subject"), context) + content = frappe.render_template(email_template.response_, context) + + try: + comm = make( + doctype="Email Campaign", + name=campaign_name, + subject=subject, + content=content, + sender=sender, + recipients=recipient_list, + communication_medium="Email", + sent_or_received="Sent", + send_email=False, + email_template=email_template.name, ) - email_template = frappe.get_doc("Email Template", entry.get("email_template")) - sender = frappe.db.get_value("User", email_campaign.get("sender"), "email") - context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)} - # send mail and link communication to document - comm = make( - doctype="Email Campaign", - name=email_campaign.name, - subject=frappe.render_template(email_template.get("subject"), context), - content=frappe.render_template(email_template.response_, context), - sender=sender, - bcc=recipient_list, - communication_medium="Email", - sent_or_received="Sent", - send_email=True, - email_template=email_template.name, - ) + frappe.sendmail( + recipients=recipient_list, + subject=subject, + content=content, + sender=sender, + communication=comm["name"], + queue_separately=True, + ) + except Exception: + frappe.log_error(title="Email Campaign Failed.") + return comm @@ -140,7 +210,12 @@ def unsubscribe_recipient(unsubscribe, method): # called through hooks to update email campaign status daily def set_email_campaign_status(): - email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")}) - for entry in email_campaigns: - email_campaign = frappe.get_doc("Email Campaign", entry.name) + email_campaigns = frappe.get_all( + "Email Campaign", + filters={"status": ("!=", "Unsubscribed")}, + pluck="name", + ) + + for name in email_campaigns: + email_campaign = frappe.get_doc("Email Campaign", name) email_campaign.update_status() diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 6173f40fb10..d38da9257ff 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -478,7 +478,7 @@ class ProductionPlan(Document): item_details = get_item_details(data.item_code, throw=False) if self.combine_items: - bom_no = item_details.bom_no + bom_no = item_details.get("bom_no") if data.get("bom_no"): bom_no = data.get("bom_no") diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.py b/erpnext/manufacturing/report/production_analytics/production_analytics.py index 5c84a2dc8d1..41fd4dd0e82 100644 --- a/erpnext/manufacturing/report/production_analytics/production_analytics.py +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.py @@ -8,6 +8,8 @@ from frappe.utils import getdate, today from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges +WORK_ORDER_STATUS_LIST = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"] + def execute(filters=None): columns = get_columns(filters) @@ -16,119 +18,98 @@ def execute(filters=None): def get_columns(filters): - columns = [{"label": _("Status"), "fieldname": "Status", "fieldtype": "Data", "width": 140}] - + columns = [{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140}] ranges = get_period_date_ranges(filters) for _dummy, end_date in ranges: period = get_period(end_date, filters) - columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120}) return columns -def get_periodic_data(filters, entry): - periodic_data = { - "Not Started": {}, - "Overdue": {}, - "Pending": {}, - "Completed": {}, - "Closed": {}, - "Stopped": {}, - } +def get_work_orders(filters): + from_date = filters.get("from_date") + to_date = filters.get("to_date") - ranges = get_period_date_ranges(filters) + WorkOrder = frappe.qb.DocType("Work Order") - for from_date, end_date in ranges: - period = get_period(end_date, filters) - for d in entry: - if getdate(from_date) <= getdate(d.creation) <= getdate(end_date) and d.status not in [ - "Draft", - "Submitted", - "Completed", - "Cancelled", - ]: - if d.status in ["Not Started", "Closed", "Stopped"]: - periodic_data = update_periodic_data(periodic_data, d.status, period) - elif getdate(today()) > getdate(d.planned_end_date): - periodic_data = update_periodic_data(periodic_data, "Overdue", period) - elif getdate(today()) < getdate(d.planned_end_date): - periodic_data = update_periodic_data(periodic_data, "Pending", period) - - if ( - getdate(from_date) <= getdate(d.actual_end_date) <= getdate(end_date) - and d.status == "Completed" - ): - periodic_data = update_periodic_data(periodic_data, "Completed", period) - - return periodic_data - - -def update_periodic_data(periodic_data, status, period): - if periodic_data.get(status).get(period): - periodic_data[status][period] += 1 - else: - periodic_data[status][period] = 1 - - return periodic_data + return ( + frappe.qb.from_(WorkOrder) + .select(WorkOrder.creation, WorkOrder.actual_end_date, WorkOrder.planned_end_date, WorkOrder.status) + .where( + (WorkOrder.docstatus == 1) + & (WorkOrder.company == filters.get("company")) + & ( + (WorkOrder.creation.between(from_date, to_date)) + | (WorkOrder.actual_end_date.between(from_date, to_date)) + ) + ) + .run(as_dict=True) + ) def get_data(filters, columns): - data = [] - entry = frappe.get_all( - "Work Order", - fields=[ - "creation", - "actual_end_date", - "planned_end_date", - "status", - ], - filters={"docstatus": 1, "company": filters["company"]}, - ) + ranges = build_ranges(filters) + period_labels = [scrub(pd) for _fd, _td, pd in ranges] + periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST} + entries = get_work_orders(filters) - periodic_data = get_periodic_data(filters, entry) + for d in entries: + if d.status == "Completed": + if not d.actual_end_date: + continue - labels = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"] - chart_data = get_chart_data(periodic_data, columns) - ranges = get_period_date_ranges(filters) + if period := scrub(get_period_for_date(getdate(d.actual_end_date), ranges)): + periodic_data["Completed"][period] += 1 + continue - for label in labels: - work = {} - work["Status"] = _(label) - for _dummy, end_date in ranges: - period = get_period(end_date, filters) - if periodic_data.get(label).get(period): - work[scrub(period)] = periodic_data.get(label).get(period) + creation_date = getdate(d.creation) + period = scrub(get_period_for_date(creation_date, ranges)) + if not period: + continue + + if d.status in ("Not Started", "Closed", "Stopped"): + periodic_data[d.status][period] += 1 + else: + if d.planned_end_date and getdate(today()) > getdate(d.planned_end_date): + periodic_data["Overdue"][period] += 1 else: - work[scrub(period)] = 0.0 - data.append(work) + periodic_data["Pending"][period] += 1 - return data, chart_data + data = [] + for status in WORK_ORDER_STATUS_LIST: + row = {"status": _(status)} + for _fd, _td, period in ranges: + row[scrub(period)] = periodic_data[status].get(scrub(period), 0) + data.append(row) + + chart = get_chart_data(periodic_data, columns) + return data, chart + + +def get_period_for_date(date, ranges): + for from_date, to_date, period in ranges: + if from_date <= date <= to_date: + return period + return None + + +def build_ranges(filters): + ranges = [] + for from_date, end_date in get_period_date_ranges(filters): + period = get_period(end_date, filters) + ranges.append((getdate(from_date), getdate(end_date), period)) + return ranges def get_chart_data(periodic_data, columns): - labels = [d.get("label") for d in columns[1:]] + period_labels = [d.get("label") for d in columns[1:]] + period_fieldnames = [d.get("fieldname") for d in columns[1:]] - not_start, overdue, pending, completed, closed, stopped = [], [], [], [], [], [] datasets = [] + for status in WORK_ORDER_STATUS_LIST: + values = [periodic_data.get(status, {}).get(fieldname, 0) for fieldname in period_fieldnames] + datasets.append({"name": _(status), "values": values}) - for d in labels: - not_start.append(periodic_data.get("Not Started").get(d)) - overdue.append(periodic_data.get("Overdue").get(d)) - pending.append(periodic_data.get("Pending").get(d)) - completed.append(periodic_data.get("Completed").get(d)) - closed.append(periodic_data.get("Closed").get(d)) - stopped.append(periodic_data.get("Stopped").get(d)) - - datasets.append({"name": _("Not Started"), "values": not_start}) - datasets.append({"name": _("Overdue"), "values": overdue}) - datasets.append({"name": _("Pending"), "values": pending}) - datasets.append({"name": _("Completed"), "values": completed}) - datasets.append({"name": _("Closed"), "values": closed}) - datasets.append({"name": _("Stopped"), "values": stopped}) - - chart = {"data": {"labels": labels, "datasets": datasets}} - chart["type"] = "line" - - return chart + return {"data": {"labels": period_labels, "datasets": datasets}, "type": "line"} diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7ded266c62a..5c7102b1ad7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -429,3 +429,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11 erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges erpnext.patches.v16_0.set_ordered_qty_in_quotation_item +erpnext.patches.v15_0.replace_http_with_https_in_sales_partner diff --git a/erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py b/erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py new file mode 100644 index 00000000000..80bc418920a --- /dev/null +++ b/erpnext/patches/v15_0/replace_http_with_https_in_sales_partner.py @@ -0,0 +1,10 @@ +import frappe +from frappe import qb +from pypika.functions import Replace + + +def execute(): + sp = frappe.qb.DocType("Sales Partner") + qb.update(sp).set(sp.partner_website, Replace(sp.partner_website, "http://", "https://")).where( + sp.partner_website.rlike("^http://.*") + ).run() diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 5942b34158d..989c361dd8e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -974,12 +974,12 @@ erpnext.utils.map_current_doc = function (opts) { } if (query_args.filters || query_args.query) { - opts.get_query = () => query_args; + opts.get_query = () => JSON.parse(JSON.stringify(query_args)); } if (opts.source_doctype) { let data_fields = []; - if (["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)) { + if (["Purchase Receipt", "Delivery Note", "Purchase Invoice"].includes(opts.source_doctype)) { let target_meta = frappe.get_meta(cur_frm.doc.doctype); if (target_meta.fields.find((f) => f.fieldname === "taxes")) { data_fields.push({ diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 3f30664e39b..f41116203ba 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -617,6 +617,9 @@ def handle_mandatory_error(e, customer, lead_name): def get_ordered_items(quotation: str): return frappe._dict( frappe.get_all( - "Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True + "Quotation Item", + {"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)}, + ["name", "ordered_qty"], + as_list=True, ) ) diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 1542721d117..4bbdb20d311 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1484,9 +1484,9 @@ }, { "default": "0", + "depends_on": "eval:doc.order_type == 'Maintenance';", "fieldname": "skip_delivery_note", "fieldtype": "Check", - "hidden": 1, "hide_days": 1, "hide_seconds": 1, "label": "Skip Delivery Note", @@ -1671,7 +1671,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-07-28 12:14:29.760988", + "modified": "2026-02-06 11:06:16.092658", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 13759d0f7f7..d38473e4b69 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -57,6 +57,28 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): def tearDown(self): frappe.set_user("Administrator") + def test_sales_order_skip_delivery_note(self): + so = make_sales_order(do_not_submit=True) + so.order_type = "Maintenance" + so.skip_delivery_note = 1 + so.append( + "items", + { + "item_code": "_Test Item 2", + "qty": 2, + "rate": 100, + }, + ) + so.save() + so.submit() + + so.reload() + si = make_sales_invoice(so.name) + si.insert() + si.submit() + so.reload() + self.assertEqual(so.status, "Completed") + @change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) def test_sales_order_with_negative_rate(self): """ diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index c00b947b295..ea25bd033b9 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -162,8 +162,6 @@ class EmailDigest(Document): context.purchase_order_list, context.purchase_orders_items_overdue_list, ) = self.get_purchase_orders_items_overdue_list() - if not context.purchase_order_list: - frappe.throw(_("No items to be received are overdue")) if not context: return None diff --git a/erpnext/setup/doctype/sales_partner/sales_partner.py b/erpnext/setup/doctype/sales_partner/sales_partner.py index c6b0b944de7..7b754a6458e 100644 --- a/erpnext/setup/doctype/sales_partner/sales_partner.py +++ b/erpnext/setup/doctype/sales_partner/sales_partner.py @@ -50,8 +50,17 @@ class SalesPartner(WebsiteGenerator): if not self.route: self.route = "partners/" + self.scrub(self.partner_name) super().validate() - if self.partner_website and not self.partner_website.startswith("http"): - self.partner_website = "http://" + self.partner_website + if self.partner_website: + from urllib.parse import urlsplit, urlunsplit + + # scrub http + parts = urlsplit(self.partner_website) + if not parts.netloc and parts.path: + parts = parts._replace(netloc=parts.path, path="") + if not parts.scheme or parts.scheme == "http": + parts = parts._replace(scheme="https") + + self.partner_website = urlunsplit(parts) def get_context(self, context): address = frappe.db.get_value( diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 83efc12a7d8..c3764138a08 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -129,6 +129,74 @@ class TestBatch(FrappeTestCase): for d in batches: self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)]) + def test_batch_qty_on_pos_creation(self): + from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import ( + init_user_and_profile, + ) + from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice + from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_auto_batch_nos, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_batch_item_with_batch, + ) + + session_user = frappe.session.user + + try: + # Create batch item + create_batch_item_with_batch("_Test BATCH ITEM", "TestBatch-RS 02") + + # Create stock entry + se = make_stock_entry( + target="_Test Warehouse - _TC", + item_code="_Test BATCH ITEM", + qty=30, + basic_rate=100, + ) + + se.reload() + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + # Create opening entry + session_user = frappe.session.user + test_user, pos_profile = init_user_and_profile() + create_opening_entry(pos_profile, test_user.name) + + # POS Invoice 1, for the batch without bundle + pos_inv1 = create_pos_invoice(item="_Test BATCH ITEM", rate=300, qty=15, do_not_save=1) + pos_inv1.append( + "payments", + {"mode_of_payment": "Cash", "amount": 4500}, + ) + pos_inv1.items[0].batch_no = batch_no + pos_inv1.save() + pos_inv1.submit() + pos_inv1.reload() + + # Get auto batch nos after pos invoice + batches = get_auto_batch_nos( + frappe._dict( + { + "item_code": "_Test BATCH ITEM", + "warehouse": "_Test Warehouse - _TC", + "for_stock_levels": True, + "ignore_reserved_stock": True, + } + ) + ) + + # Check batch qty after pos invoice + row = _find_batch_row(batches, batch_no, "_Test Warehouse - _TC") + self.assertIsNotNone(row) + self.assertEqual(row.qty, 30) + + finally: + # Set user to session user + frappe.set_user(session_user) + def test_stock_entry_incoming(self): """Test batch creation via Stock Entry (Work Order)""" @@ -624,6 +692,10 @@ def create_price_list_for_batch(item_code, batch, rate): ).insert() +def _find_batch_row(batches, batch_no, warehouse): + return next((b for b in batches if b.batch_no == batch_no and b.warehouse == warehouse), None) + + def make_new_batch(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 83854ad53bb..7349838e816 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -138,7 +138,7 @@ class InventoryDimension(Document): self.source_fieldname = scrub(self.dimension_name) if not self.target_fieldname: - self.target_fieldname = scrub(self.reference_document) + self.target_fieldname = scrub(self.dimension_name) def on_update(self): self.add_custom_fields() diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index bfa1d8821ad..96088db1923 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -118,12 +118,12 @@ class TestInventoryDimension(FrappeTestCase): inward.load_from_db() sle_data = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": inward.name}, ["shelf", "warehouse"], as_dict=1 + "Stock Ledger Entry", {"voucher_no": inward.name}, ["to_shelf", "warehouse"], as_dict=1 ) self.assertEqual(inward.items[0].to_shelf, "Shelf 1") self.assertEqual(sle_data.warehouse, warehouse) - self.assertEqual(sle_data.shelf, "Shelf 1") + self.assertEqual(sle_data.to_shelf, "Shelf 1") outward = make_stock_entry( item_code=item_code, diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 978a7b41d69..89dba460809 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -30,7 +30,10 @@ frappe.ui.form.on("Material Request", { frm.set_query("from_warehouse", "items", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); @@ -62,19 +65,28 @@ frappe.ui.form.on("Material Request", { frm.set_query("warehouse", "items", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); frm.set_query("set_warehouse", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); frm.set_query("set_from_warehouse", function (doc) { return { - filters: { company: doc.company }, + filters: { + company: doc.company, + is_group: 0, + }, }; }); diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 7d160b24e2d..cc993325fb1 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -706,6 +706,9 @@ def make_stock_entry(source_name, target_doc=None): target.purpose = source.material_request_type target.from_warehouse = source.set_from_warehouse target.to_warehouse = source.set_warehouse + if source.material_request_type == "Material Issue": + target.from_warehouse = source.set_warehouse + target.to_warehouse = None if source.job_card: target.purpose = "Material Transfer for Manufacture" diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index fff0db2f93d..2da1861ba21 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -902,15 +902,27 @@ class TestMaterialRequest(FrappeTestCase): import json from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry - mr = make_material_request(material_request_type="Material Transfer") + new_item = create_item("_Test Pick List Item", is_stock_item=1) + item_code = new_item.name + + make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=10, + do_not_save=False, + do_not_submit=False, + ) + + mr = make_material_request(item_code=item_code, material_request_type="Material Transfer") pl = create_pick_list(mr.name) pl.save() pl.locations[0].qty = 5 pl.locations[0].stock_qty = 5 pl.submit() - to_warehouse = create_warehouse("Test To Warehouse") + to_warehouse = create_warehouse("_Test Warehouse - _TC") se_data = create_stock_entry(json.dumps(pl.as_dict())) se = frappe.get_doc(se_data) @@ -970,6 +982,19 @@ class TestMaterialRequest(FrappeTestCase): self.assertRaises(frappe.ValidationError, end_transit_2.submit) + def test_make_stock_entry_material_issue_warehouse_mapping(self): + """Test to ensure while making stock entry from material request of type Material Issue, warehouse is mapped correctly""" + mr = make_material_request(material_request_type="Material Issue", do_not_submit=True) + mr.set_warehouse = "_Test Warehouse - _TC" + mr.save() + mr.submit() + + se = make_stock_entry(mr.name) + self.assertEqual(se.from_warehouse, "_Test Warehouse - _TC") + self.assertIsNone(se.to_warehouse) + se.save() + se.submit() + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): 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 f51254784d9..c23ccf24083 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 @@ -1429,6 +1429,9 @@ class SerialandBatchBundle(Document): def throw_negative_batch(self, batch_no, available_qty, precision): from erpnext.stock.stock_ledger import NegativeStockError + if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"): + return + frappe.throw( _( """ @@ -2546,7 +2549,10 @@ def get_auto_batch_nos(kwargs): qty = flt(kwargs.qty) stock_ledgers_batches = get_stock_ledgers_batches(kwargs) - pos_invoice_batches = get_reserved_batches_for_pos(kwargs) + + pos_invoice_batches = frappe._dict() + if not kwargs.for_stock_levels: + pos_invoice_batches = get_reserved_batches_for_pos(kwargs) sre_reserved_batches = frappe._dict() if not kwargs.ignore_reserved_stock: diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 8ab9cf210ce..90a30ef62e6 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -524,6 +524,9 @@ class StockReconciliation(StockController): if abs(difference_amount) > 0: return True + float_precision = frappe.db.get_default("float_precision") or 3 + item_dict["rate"] = flt(item_dict.get("rate"), float_precision) + item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None if ( (item.qty is None or item.qty == item_dict.get("qty")) and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 63228e5a764..4e81c65a58d 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1698,6 +1698,101 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(docstatus, 2) + def test_stock_reco_with_opening_stock_with_diff_inventory(self): + from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import ( + create_inventory_dimension, + ) + + if frappe.db.exists("DocType", "Plant"): + return + + doctype = frappe.get_doc( + { + "doctype": "DocType", + "name": "Plant", + "module": "Stock", + "custom": 1, + "fields": [ + { + "fieldname": "plant_name", + "fieldtype": "Data", + "label": "Plant Name", + "reqd": 1, + } + ], + "autoname": "field:plant_name", + } + ) + doctype.insert(ignore_permissions=True) + create_inventory_dimension(dimension_name="ID-Plant", reference_document="Plant") + + plant_a = frappe.get_doc( + { + "doctype": "Plant", + "plant_name": "Plant A", + } + ).insert(ignore_permissions=True) + + plant_b = frappe.get_doc( + { + "doctype": "Plant", + "plant_name": "Plant B", + } + ).insert(ignore_permissions=True) + + warehouse = "_Test Warehouse - _TC" + + item_code = "Item-Test" + item = self.make_item(item_code, {"is_stock_item": 1}) + + sr = frappe.new_doc("Stock Reconciliation") + sr.purpose = "Opening Stock" + sr.posting_date = nowdate() + sr.posting_time = nowtime() + sr.company = "_Test Company" + + sr.append( + "items", + { + "item_code": item.name, + "warehouse": warehouse, + "qty": 5, + "valuation_rate": 100, + "id_plant": plant_a.name, + }, + ) + + sr.append( + "items", + { + "item_code": item.name, + "warehouse": warehouse, + "qty": 3, + "valuation_rate": 110, + "id_plant": plant_b.name, + }, + ) + + sr.insert() + sr.submit() + + self.assertEqual(len(sr.items), 2) + sle_count = frappe.db.count( + "Stock Ledger Entry", + {"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0}, + ) + self.assertEqual(sle_count, 2) + sle = frappe.get_all( + "Stock Ledger Entry", + {"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0}, + ["item_code", "id_plant", "actual_qty", "valuation_rate"], + ) + for s in sle: + if s.id_plant == plant_a.name: + self.assertEqual(s.actual_qty, 5) + elif s.id_plant == plant_b.name: + self.assertEqual(s.actual_qty, 3) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index d61c8f575cb..9e6eb8d6f2d 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -47,6 +47,7 @@ "disable_serial_no_and_batch_selector", "use_serial_batch_fields", "do_not_update_serial_batch_on_creation_of_auto_bundle", + "allow_negative_stock_for_batch", "serial_and_batch_bundle_section", "set_serial_and_batch_bundle_naming_based_on_naming_series", "section_break_gnhq", @@ -538,6 +539,13 @@ "fieldname": "validate_material_transfer_warehouses", "fieldtype": "Check", "label": "Validate Material Transfer Warehouses" + }, + { + "default": "0", + "description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.", + "fieldname": "allow_negative_stock_for_batch", + "fieldtype": "Check", + "label": "Allow Negative Stock for Batch" } ], "icon": "icon-cog", @@ -545,7 +553,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-11-11 11:35:39.864923", + "modified": "2026-02-09 15:01:12.466175", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 268d1570b08..0ea1738b60e 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -30,6 +30,7 @@ class StockSettings(Document): allow_from_pr: DF.Check allow_internal_transfer_at_arms_length_price: DF.Check allow_negative_stock: DF.Check + allow_negative_stock_for_batch: DF.Check allow_partial_reservation: DF.Check allow_to_edit_stock_uom_qty_for_purchase: DF.Check allow_to_edit_stock_uom_qty_for_sales: DF.Check