diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index f9f1b54406b..435a6b8a025 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -136,6 +136,8 @@ class BankTransaction(Document): self.set_status() def on_cancel(self): + self.ignore_linked_doctypes = ["GL Entry"] + for payment_entry in self.payment_entries: self.delink_payment_entry(payment_entry) @@ -370,11 +372,12 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries ("unallocated_amount", "bank_account"), as_dict=True, ) + bt_bank_account = frappe.db.get_value("Bank Account", bt.bank_account, "account") - if bt.bank_account != gl_bank_account: + if bt_bank_account != gl_bank_account: frappe.throw( _("Bank Account {} in Bank Transaction {} is not matching with Bank Account {}").format( - bt.bank_account, payment_entry.payment_entry, gl_bank_account + bt_bank_account, payment_entry.payment_entry, gl_bank_account ) ) diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index f97fcf1ec34..38f3a91c8fe 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -4,7 +4,7 @@ import frappe from dateutil.relativedelta import relativedelta -from frappe import _ +from frappe import _, cint from frappe.model.document import Document from frappe.utils import add_days, add_years, cstr, getdate @@ -33,24 +33,6 @@ class FiscalYear(Document): self.validate_dates() self.validate_overlap() - if not self.is_new(): - year_start_end_dates = frappe.db.sql( - """select year_start_date, year_end_date - from `tabFiscal Year` where name=%s""", - (self.name), - ) - - if year_start_end_dates: - if ( - getdate(self.year_start_date) != year_start_end_dates[0][0] - or getdate(self.year_end_date) != year_start_end_dates[0][1] - ): - frappe.throw( - _( - "Cannot change Fiscal Year Start Date and Fiscal Year End Date once the Fiscal Year is saved." - ) - ) - def validate_dates(self): self.validate_from_to_dates("year_start_date", "year_end_date") if self.is_short_year: @@ -66,28 +48,20 @@ class FiscalYear(Document): frappe.exceptions.InvalidDates, ) - def on_update(self): - check_duplicate_fiscal_year(self) - frappe.cache().delete_value("fiscal_years") - - def on_trash(self): - frappe.cache().delete_value("fiscal_years") - def validate_overlap(self): - existing_fiscal_years = frappe.db.sql( - """select name from `tabFiscal Year` - where ( - (%(year_start_date)s between year_start_date and year_end_date) - or (%(year_end_date)s between year_start_date and year_end_date) - or (year_start_date between %(year_start_date)s and %(year_end_date)s) - or (year_end_date between %(year_start_date)s and %(year_end_date)s) - ) and name!=%(name)s""", - { - "year_start_date": self.year_start_date, - "year_end_date": self.year_end_date, - "name": self.name or "No Name", - }, - as_dict=True, + fy = frappe.qb.DocType("Fiscal Year") + + name = self.name or self.year + + existing_fiscal_years = ( + frappe.qb.from_(fy) + .select(fy.name) + .where( + (fy.year_start_date <= self.year_end_date) + & (fy.year_end_date >= self.year_start_date) + & (fy.name != name) + ) + .run(as_dict=True) ) if existing_fiscal_years: @@ -110,37 +84,30 @@ class FiscalYear(Document): frappe.throw( _( "Year start date or end date is overlapping with {0}. To avoid please set company" - ).format(existing.name), + ).format(frappe.get_desk_link("Fiscal Year", existing.name, open_in_new_tab=True)), frappe.NameError, ) -@frappe.whitelist() -def check_duplicate_fiscal_year(doc): - year_start_end_dates = frappe.db.sql( - """select name, year_start_date, year_end_date from `tabFiscal Year` where name!=%s""", - (doc.name), - ) - for fiscal_year, ysd, yed in year_start_end_dates: - if (getdate(doc.year_start_date) == ysd and getdate(doc.year_end_date) == yed) and ( - not frappe.flags.in_test - ): - frappe.throw( - _( - "Fiscal Year Start Date and Fiscal Year End Date are already set in Fiscal Year {0}" - ).format(fiscal_year) - ) - - -@frappe.whitelist() def auto_create_fiscal_year(): - for d in frappe.db.sql( - """select name from `tabFiscal Year` where year_end_date = date_add(current_date, interval 3 day)""" - ): + fy = frappe.qb.DocType("Fiscal Year") + + # Skipped auto-creating Short Year, as it has very rare use case. + # Reference: https://www.irs.gov/businesses/small-businesses-self-employed/tax-years (US) + follow_up_date = add_days(getdate(), days=3) + fiscal_year = ( + frappe.qb.from_(fy) + .select(fy.name) + .where((fy.year_end_date == follow_up_date) & (fy.is_short_year == 0)) + .run() + ) + + for d in fiscal_year: try: current_fy = frappe.get_doc("Fiscal Year", d[0]) - new_fy = frappe.copy_doc(current_fy, ignore_no_copy=False) + new_fy = frappe.new_doc("Fiscal Year") + new_fy.disabled = cint(current_fy.disabled) new_fy.year_start_date = add_days(current_fy.year_end_date, 1) new_fy.year_end_date = add_years(current_fy.year_end_date, 1) @@ -148,6 +115,10 @@ def auto_create_fiscal_year(): start_year = cstr(new_fy.year_start_date.year) end_year = cstr(new_fy.year_end_date.year) new_fy.year = start_year if start_year == end_year else (start_year + "-" + end_year) + + for row in current_fy.companies: + new_fy.append("companies", {"company": row.company}) + new_fy.auto_created = 1 new_fy.insert(ignore_permissions=True) diff --git a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json index 67acb26c7ee..d1f1ecc0a09 100644 --- a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json +++ b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json @@ -15,13 +15,14 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Company", - "options": "Company" + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-09-28 18:01:53.495929", + "modified": "2026-02-20 23:02:26.193606", "modified_by": "Administrator", "module": "Accounts", "name": "Fiscal Year Company", @@ -30,4 +31,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py index 9447120d326..b68069bca27 100644 --- a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py +++ b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py @@ -14,7 +14,7 @@ class FiscalYearCompany(Document): if TYPE_CHECKING: from frappe.types import DF - company: DF.Link | None + company: DF.Link parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json index a0535c4e1ca..9fc5acf306a 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json @@ -185,7 +185,7 @@ "fieldtype": "Select", "label": "Reference Type", "no_copy": 1, - "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry", + "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry\nBank Transaction", "search_index": 1 }, { @@ -198,7 +198,7 @@ "search_index": 1 }, { - "depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance'])", + "depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance', 'Bank Transaction'])", "fieldname": "reference_due_date", "fieldtype": "Date", "label": "Reference Due Date", @@ -295,7 +295,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2025-11-27 12:23:33.157655", + "modified": "2026-02-19 17:01:22.642454", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry Account", diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py index d26224103c0..d73412f8a20 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py @@ -55,6 +55,7 @@ class JournalEntryAccount(Document): "Fees", "Full and Final Statement", "Payment Entry", + "Bank Transaction", ] user_remark: DF.SmallText | None # end: auto-generated types diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 580af69c404..60c8e47f8f0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -516,12 +516,16 @@ frappe.ui.form.on("Payment Entry", { frm.set_value("contact_email", ""); frm.set_value("contact_person", ""); } + if (frm.doc.payment_type && frm.doc.party_type && frm.doc.party && frm.doc.company) { if (!frm.doc.posting_date) { frappe.msgprint(__("Please select Posting Date before selecting Party")); frm.set_value("party", ""); return; } + + erpnext.utils.get_employee_contact_details(frm); + frm.set_party_account_based_on_party = true; let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; @@ -1465,16 +1469,15 @@ frappe.ui.form.on("Payment Entry", { callback: function (r) { if (!r.exc && r.message) { // set taxes table - if (r.message) { - for (let tax of r.message) { - if (tax.charge_type === "On Net Total") { - tax.charge_type = "On Paid Amount"; - } - frm.add_child("taxes", tax); + let taxes = r.message; + taxes.forEach((tax) => { + if (tax.charge_type === "On Net Total") { + tax.charge_type = "On Paid Amount"; } - frm.events.apply_taxes(frm); - frm.events.set_unallocated_amount(frm); - } + }); + frm.set_value("taxes", taxes); + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); } }, }); diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 86e93e1d783..8ff021660cc 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -536,7 +536,7 @@ class PaymentRequest(Document): row_number += TO_SKIP_NEW_ROW -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def make_payment_request(**args): """Make payment request""" diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 5870cd9c9da..42f62147129 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -60,7 +60,6 @@ "sec_warehouse", "set_warehouse", "items_section", - "update_stock", "scan_barcode", "last_scanned_warehouse", "items", @@ -574,7 +573,6 @@ "label": "Warehouse" }, { - "depends_on": "update_stock", "fieldname": "set_warehouse", "fieldtype": "Link", "label": "Source Warehouse", @@ -588,15 +586,6 @@ "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, - { - "default": "0", - "fieldname": "update_stock", - "fieldtype": "Check", - "label": "Update Stock", - "oldfieldname": "update_stock", - "oldfieldtype": "Check", - "print_hide": 1 - }, { "fieldname": "scan_barcode", "fieldtype": "Data", @@ -1582,7 +1571,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2025-08-04 22:22:31.471752", + "modified": "2026-02-22 04:18:50.691218", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", @@ -1627,6 +1616,7 @@ "role": "All" } ], + "row_format": "Dynamic", "search_fields": "posting_date, due_date, customer, base_grand_total, outstanding_amount", "show_name_in_global_search": 1, "sort_field": "modified", @@ -1635,4 +1625,4 @@ "timeline_field": "customer", "title_field": "title", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 0e04592aeac..6fff3c44157 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -183,7 +183,6 @@ class POSInvoice(SalesInvoice): total_taxes_and_charges: DF.Currency update_billed_amount_in_delivery_note: DF.Check update_billed_amount_in_sales_order: DF.Check - update_stock: DF.Check write_off_account: DF.Link | None write_off_amount: DF.Currency write_off_cost_center: DF.Link | None @@ -652,7 +651,6 @@ class POSInvoice(SalesInvoice): "tax_category", "ignore_pricing_rule", "company_address", - "update_stock", ): if not for_validate: self.set(fieldname, profile.get(fieldname)) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index c1be1d2eae0..56a8aac504d 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -1101,7 +1101,6 @@ def create_pos_invoice(**args): pos_inv = frappe.new_doc("POS Invoice") pos_inv.update(args) - pos_inv.update_stock = 1 pos_inv.is_pos = 1 pos_inv.pos_profile = args.pos_profile or pos_profile.name diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 43091e9bda9..05ebbdd45b4 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -146,6 +146,7 @@ class POSInvoiceMergeLog(Document): sales_invoice.is_consolidated = 1 sales_invoice.set_posting_time = 1 + sales_invoice.update_stock = 1 if not sales_invoice.posting_date: sales_invoice.posting_date = getdate(self.posting_date) @@ -174,6 +175,7 @@ class POSInvoiceMergeLog(Document): credit_note.is_consolidated = 1 credit_note.set_posting_time = 1 + credit_note.update_stock = 1 credit_note.posting_date = getdate(self.posting_date) credit_note.posting_time = get_time(self.posting_time) # TODO: return could be against multiple sales invoice which could also have been consolidated? diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 9ed39322f4f..bda915ce703 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -25,7 +25,6 @@ "validate_stock_on_save", "print_receipt_on_order_complete", "column_break_16", - "update_stock", "ignore_pricing_rule", "allow_rate_change", "allow_discount_change", @@ -297,7 +296,6 @@ "options": "Print Format" }, { - "depends_on": "update_stock", "fieldname": "warehouse", "fieldtype": "Link", "label": "Warehouse", @@ -312,14 +310,6 @@ "fieldtype": "Check", "label": "Ignore Pricing Rule" }, - { - "default": "1", - "fieldname": "update_stock", - "fieldtype": "Check", - "hidden": 1, - "label": "Update Stock", - "read_only": 1 - }, { "default": "0", "fieldname": "hide_unavailable_items", @@ -432,7 +422,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2025-04-14 15:58:20.497426", + "modified": "2026-02-22 04:17:03.308876", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 2928782a647..882b8c58eee 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -61,7 +61,6 @@ class POSProfile(Document): tax_category: DF.Link | None taxes_and_charges: DF.Link | None tc_name: DF.Link | None - update_stock: DF.Check validate_stock_on_save: DF.Check warehouse: DF.Link write_off_account: DF.Link diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json index 7a5c6d7713b..c03796fcfe8 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json @@ -121,7 +121,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Apply On", - "options": "\nItem Code\nItem Group\nBrand\nTransaction", + "options": "Item Code\nItem Group\nBrand\nTransaction", "reqd": 1 }, { @@ -657,7 +657,7 @@ "icon": "fa fa-gift", "idx": 1, "links": [], - "modified": "2025-08-20 11:40:07.096854", + "modified": "2026-02-17 12:24:07.553505", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule", @@ -719,4 +719,4 @@ "sort_order": "DESC", "states": [], "title_field": "title" -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 21e5ad18d9b..14ec325bea2 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -45,7 +45,7 @@ class PricingRule(Document): apply_discount_on: DF.Literal["Grand Total", "Net Total"] apply_discount_on_rate: DF.Check apply_multiple_pricing_rules: DF.Check - apply_on: DF.Literal["", "Item Code", "Item Group", "Brand", "Transaction"] + apply_on: DF.Literal["Item Code", "Item Group", "Brand", "Transaction"] apply_recursion_over: DF.Float apply_rule_on_other: DF.Literal["", "Item Code", "Item Group", "Brand"] brands: DF.Table[PricingRuleBrand] diff --git a/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json b/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json index b631ba33c6e..12aa97f7247 100644 --- a/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json +++ b/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json @@ -20,7 +20,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "depends_on": "eval:parent.apply_on == 'Item Code'", + "depends_on": "eval:parent.apply_on == 'Brand'", "fieldname": "brand", "fieldtype": "Link", "hidden": 0, @@ -91,7 +91,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2019-03-24 14:48:59.649168", + "modified": "2026-02-17 12:17:13.073587", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule Brand", @@ -107,4 +107,4 @@ "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json b/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json index 30027ba0e6a..9df9c3189ce 100644 --- a/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json +++ b/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json @@ -20,7 +20,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "depends_on": "eval:parent.apply_on == 'Item Code'", + "depends_on": "eval:parent.apply_on == 'Item Group'", "fieldname": "item_group", "fieldtype": "Link", "hidden": 0, @@ -91,7 +91,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2019-03-24 14:48:59.649168", + "modified": "2026-02-17 12:16:57.778471", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule Item Group", @@ -107,4 +107,4 @@ "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index c53d72f9eba..43c72938f64 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1729,10 +1729,6 @@ class PurchaseInvoice(BuyingController): project_doc.db_update() def validate_supplier_invoice(self): - if self.bill_date: - if getdate(self.bill_date) > getdate(self.posting_date): - frappe.throw(_("Supplier Invoice Date cannot be greater than Posting Date")) - if self.bill_no: if cint(frappe.db.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")): fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 8ed23724dfb..802705f3470 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -854,9 +854,6 @@ class SalesInvoice(SellingController): if selling_price_list: self.set("selling_price_list", selling_price_list) - if not for_validate: - self.update_stock = cint(pos.get("update_stock")) - # set pos values in items for item in self.get("items"): if item.get("item_code"): @@ -1097,7 +1094,9 @@ class SalesInvoice(SellingController): d.projected_qty = bin and flt(bin[0]["projected_qty"]) or 0 def update_packing_list(self): - if cint(self.update_stock) == 1: + if self.doctype == "POS Invoice" or ( + self.doctype == "Sales Invoice" and cint(self.update_stock) == 1 + ): from erpnext.stock.doctype.packed_item.packed_item import make_packing_list make_packing_list(self) diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index a5b93eae931..a2f30159e95 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -840,6 +840,7 @@ "fieldtype": "Currency", "label": "Incoming Rate (Costing)", "no_copy": 1, + "non_negative": 1, "options": "Company:company:default_currency", "print_hide": 1 }, @@ -983,7 +984,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2025-03-12 16:33:55.503777", + "modified": "2026-02-23 14:37:14.853941", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", @@ -993,4 +994,4 @@ "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html new file mode 100644 index 00000000000..542070ab6f2 --- /dev/null +++ b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html @@ -0,0 +1,43 @@ +
{{ _("A new fiscal year has been automatically created.") }}
+ +{{ _("Fiscal Year Details") }}
+ +| {{ _("Year Name") }} | +{{ doc.name }} | +
| {{ _("Start Date") }} | +{{ frappe.format_value(doc.year_start_date) }} | +
| {{ _("End Date") }} | +{{ frappe.format_value(doc.year_end_date) }} | +
| + {% if doc.companies|length < 2 %} + {{ _("Company") }} + {% else %} + {{ _("Companies") }} + {% endif %} + | +{{ doc.companies[0].company }} | +
| {{ doc.companies[idx].company }} | +
{{ _("The fiscal year has been automatically created in a Disabled state to maintain consistency with the previous fiscal year's status.") }}
+{% endif %} + +{{ _("Please review the {0} configuration and complete any required financial setup activities.").format(frappe.utils.get_link_to_form("Fiscal Year", doc.name, frappe.bold("Fiscal Year"))) }}
\ No newline at end of file diff --git a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json index 4c7faf4f65b..9160ebbae3e 100644 --- a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json +++ b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json @@ -1,7 +1,7 @@ { "attach_print": 0, "channel": "Email", - "condition": "doc.auto_created", + "condition": "doc.auto_created == 1", "creation": "2018-04-25 14:19:05.440361", "days_in_advance": 0, "docstatus": 0, @@ -11,19 +11,22 @@ "event": "New", "idx": 0, "is_standard": 1, - "message": "{{ _(\"New fiscal year created :- \") }} {{ doc.name }}
", - "modified": "2018-04-25 14:30:38.588534", + "message": "{{ _(\"A new fiscal year has been automatically created.\") }}
\n\n{{ _(\"Fiscal Year Details\") }}
\n\n| {{ _(\"Year Name\") }} | \n{{ doc.name }} | \n
| {{ _(\"Start Date\") }} | \n{{ frappe.format_value(doc.year_start_date) }} | \n
| {{ _(\"End Date\") }} | \n{{ frappe.format_value(doc.year_end_date) }} | \n
| \n {% if doc.companies|length < 2 %}\n {{ _(\"Company\") }}\n {% else %}\n {{ _(\"Companies\") }}\n {% endif %}\n | \n{{ doc.companies[0].company }} | \n
| {{ doc.companies[idx].company }} | \n
{{ _(\"The fiscal year has been automatically created in a Disabled state to maintain consistency with the previous fiscal year's status.\") }}
\n{% endif %}\n\n{{ _(\"Please review the {0} configuration and complete any required financial setup activities.\").format(frappe.utils.get_link_to_form(\"Fiscal Year\", doc.name, frappe.bold(\"Fiscal Year\"))) }}
", + "message_type": "HTML", + "modified": "2026-02-21 15:59:07.775679", "modified_by": "Administrator", "module": "Accounts", "name": "Notification for new fiscal year", "owner": "Administrator", "recipients": [ { - "email_by_role": "Accounts User" + "receiver_by_role": "Accounts Manager" }, { - "email_by_role": "Accounts Manager" + "receiver_by_role": "Accounts User" } ], - "subject": "Notification for new fiscal year {{ doc.name }}" + "send_system_notification": 0, + "send_to_all_assignees": 0, + "subject": "{{ _(\"New Fiscal Year {0} - Review Required\").format(doc.name) }}" } \ No newline at end of file diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index b8bcc3a4160..c4622f0b06b 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -7,18 +7,16 @@ from frappe import _, msgprint, qb, scrub from frappe.contacts.doctype.address.address import get_company_address, get_default_address from frappe.core.doctype.user_permission.user_permission import get_permitted_documents from frappe.model.utils import get_fetch_values -from frappe.query_builder.functions import Abs, Count, Date, Sum +from frappe.query_builder.functions import Abs, Date, Sum from frappe.utils import ( add_days, add_months, - add_years, cint, cstr, date_diff, flt, formatdate, get_last_day, - get_timestamp, getdate, nowdate, ) @@ -302,19 +300,9 @@ def complete_contact_details(party_details): contact_details = frappe._dict() if party_details.party_type == "Employee": - contact_details = frappe.db.get_value( - "Employee", - party_details.party, - [ - "employee_name as contact_display", - "prefered_email as contact_email", - "cell_number as contact_mobile", - "designation as contact_designation", - "department as contact_department", - ], - as_dict=True, - ) + from erpnext.setup.doctype.employee.employee import _get_contact_details as get_employee_contact + contact_details = get_employee_contact(party_details.party) contact_details.update({"contact_person": None, "contact_phone": None}) elif party_details.contact_person: contact_details = frappe.db.get_value( diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 49ca61e950d..35f24df015f 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -439,6 +439,7 @@ class TestGrossProfit(FrappeTestCase): qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True ) sinv.is_return = 1 + sinv.items[0].allow_zero_valuation_rate = 1 sinv = sinv.save().submit() filters = frappe._dict( diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 00ab15dba12..ccb4d26f77b 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -159,11 +159,11 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co def get_chart_data(filters, columns, income, expense, net_profit_loss, currency): - labels = [d.get("label") for d in columns[4:]] + labels = [d.get("label") for d in columns[2:]] income_data, expense_data, net_profit = [], [], [] - for p in columns[4:]: + for p in columns[2:]: if income: income_data.append(income[-2].get(p.get("fieldname"))) if expense: diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 2a977dd2c03..179126452e7 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -454,7 +454,8 @@ def _build_dimensions_dict_for_exc_gain_loss( dimensions_dict = frappe._dict() if entry and active_dimensions: for dim in active_dimensions: - dimensions_dict[dim.fieldname] = entry.get(dim.fieldname) + if entry_dimension := entry.get(dim.fieldname): + dimensions_dict[dim.fieldname] = entry_dimension return dimensions_dict diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 751254babf1..5d1513df9b2 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -196,6 +196,9 @@ class PurchaseOrder(BuyingController): self.set_has_unit_price_items() self.flags.allow_zero_qty = self.has_unit_price_items + if self.is_subcontracted: + self.status_updater[0]["source_field"] = "fg_item_qty" + def validate(self): super().validate() diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index fccca81f8ce..e81f9f9c988 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -30,6 +30,14 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.add_custom_button(__("Purchase Order"), this.make_purchase_order, __("Create")); cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); + + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } else if (this.frm.doc.docstatus === 0) { erpnext.set_unit_price_items_note(this.frm); diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 35cb2eebf8f..790e89f8c0e 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -345,3 +345,15 @@ def set_expired_status(): """, (nowdate()), ) + + +def get_purchased_items(supplier_quotation: str): + return frappe._dict( + frappe.get_all( + "Purchase Order Item", + filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, + fields=["supplier_quotation_item", "sum(qty)"], + group_by="supplier_quotation_item", + as_list=1, + ) + ) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index 60c82bbc05f..da4c78347f3 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -2,15 +2,115 @@ # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate class TestPurchaseOrder(FrappeTestCase): + def test_update_child_supplier_quotation_add_item(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": 5, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(sq.get("items")[0].qty, 5) + self.assertEqual(sq.get("items")[1].rate, 300) + + def test_update_supplier_quotation_child_rate_disallow(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": 300, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + ] + ) + self.assertRaises( + frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + + def test_update_supplier_quotation_child_remove_item(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + po = make_purchase_order(sq.name) + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + po.get("items")[0].schedule_date = add_days(today(), 1) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + po.submit() + sq.reload() + + trans_item = json.dumps( + [ + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + } + ] + ) + + frappe.db.savepoint("before_cancel") + # check if item having purchase order can be removed + self.assertRaises( + frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + frappe.db.rollback(save_point="before_cancel") + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + } + ] + ) + + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(len(sq.get("items")), 1) + def test_supplier_quotation_qty(self): sq = frappe.copy_doc(test_records[0]) sq.items[0].qty = 0 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 66a3dd93169..51f19b0dd27 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3678,7 +3678,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - if child_doctype == "Purchase Order Item": + if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 @@ -3696,7 +3696,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child return child_item -def validate_child_on_delete(row, parent): +def validate_child_on_delete(row, parent, ordered_item=None): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": if flt(row.delivered_qty): @@ -3724,13 +3724,17 @@ def validate_child_on_delete(row, parent): row.idx, row.item_code ) ) - - if flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code + if parent.doctype in ["Purchase Order", "Sales Order"]: + if flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) ) - ) + + if parent.doctype == "Quotation": + if ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) def update_bin_on_delete(row, doctype): @@ -3756,7 +3760,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data) -> bool: +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -3764,7 +3768,7 @@ def validate_and_delete_children(parent, data) -> bool: deleted_children.append(item) for d in deleted_children: - validate_child_on_delete(d, parent) + validate_child_on_delete(d, parent, ordered_item) d.cancel() d.delete() @@ -3773,16 +3777,19 @@ def validate_and_delete_children(parent, data) -> bool: # need to update ordered qty in Material Request first # bin uses Material Request Items to recalculate & update - parent.update_prevdoc_status() - - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) + if parent.doctype not in ["Quotation", "Supplier Quotation"]: + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) return bool(deleted_children) @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.quotation import get_ordered_items + def check_doc_permissions(doc, perm_type="create"): try: doc.check_permission(perm_type) @@ -3821,7 +3828,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" + child_doctype = parent_doctype + " Item" return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) def is_allowed_zero_qty(): @@ -3846,6 +3853,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): frappe.throw(_("Cannot set quantity less than received quantity")) + if parent_doctype in ["Quotation", "Supplier Quotation"]: + if (parent_doctype == "Quotation" and not ordered_items) or ( + parent_doctype == "Supplier Quotation" and not purchased_items + ): + return + + qty_to_check = ( + ordered_items.get(child_item.name) + if parent_doctype == "Quotation" + else purchased_items.get(child_item.name) + ) + if qty_to_check: + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + def should_update_supplied_items(doc) -> bool: """Subcontracted PO can allow following changes *after submit*: @@ -3888,7 +3910,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) data = json.loads(trans_items) - any_qty_changed = False # updated to true if any item's qty changes items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False @@ -3896,7 +3917,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - _removed_items = validate_and_delete_children(parent, data) + + if parent_doctype == "Quotation": + ordered_items = get_ordered_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, ordered_items) + elif parent_doctype == "Supplier Quotation": + purchased_items = get_purchased_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, purchased_items) + else: + _removed_items = validate_and_delete_children(parent, data) + items_added_or_removed |= _removed_items for d in data: @@ -3936,7 +3966,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil conversion_factor_unchanged = prev_con_fac == new_con_fac any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( - prev_date == getdate(new_date) if prev_date and new_date else False + (prev_date == getdate(new_date) if prev_date and new_date else False) + if parent_doctype not in ["Quotation", "Supplier Quotation"] + else None ) # in case of delivery note etc if ( rate_unchanged @@ -3949,6 +3981,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil continue validate_quantity(child_item, d) + if parent_doctype in ["Quotation", "Supplier Quotation"]: + if not rate_unchanged: + frappe.throw(_("Rates cannot be modified for quoted items")) + if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True @@ -3972,18 +4008,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil rate_unchanged = prev_rate == new_rate if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty(): frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - # Amount cannot be lesser than billed amount, except for negative amounts row_rate = flt(d.get("rate"), rate_precision) - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(d.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) + + if parent_doctype in ["Purchase Order", "Sales Order"]: + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(d.get("qty"), qty_precision), rate_precision ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + else: + child_item.rate = row_rate else: child_item.rate = row_rate @@ -4017,26 +4056,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if d.get("bom_no") and parent_doctype == "Sales Order": child_item.bom_no = d.get("bom_no") - if flt(child_item.price_list_rate): - if flt(child_item.rate) > flt(child_item.price_list_rate): - # if rate is greater than price_list_rate, set margin - # or set discount - child_item.discount_percentage = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.discount_percentage = flt( - (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, - child_item.precision("discount_percentage"), - ) - child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + if parent_doctype in ["Sales Order", "Purchase Order"]: + if flt(child_item.price_list_rate): + if flt(child_item.rate) > flt(child_item.price_list_rate): + # if rate is greater than price_list_rate, set margin + # or set discount + child_item.discount_percentage = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.discount_percentage = flt( + (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, + child_item.precision("discount_percentage"), + ) + child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: @@ -4044,7 +4084,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil child_item.idx = len(parent.items) + 1 child_item.insert() else: - child_item.save() + child_item.save(ignore_permissions=True) parent.reload() parent.flags.ignore_validate_update_after_submit = True @@ -4058,14 +4098,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.doctype, parent.company, parent.base_grand_total ) - parent.set_payment_schedule() + if parent_doctype != "Supplier Quotation": + parent.set_payment_schedule() if parent_doctype == "Purchase Order": parent.set_tax_withholding() parent.validate_minimum_order_qty() parent.validate_budget() if parent.is_against_so(): parent.update_status_updater() - else: + elif parent_doctype == "Sales Order": parent.check_credit_limit() # reset index of child table @@ -4098,7 +4139,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." ).format(frappe.bold(parent.name)) ) - else: # Sales Order + elif parent_doctype == "Sales Order": parent.validate_selling_price() parent.validate_for_duplicate_items() parent.validate_warehouse() @@ -4110,9 +4151,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.reload() validate_workflow_conditions(parent) - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() + if parent_doctype in ["Purchase Order", "Sales Order"]: + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() parent.validate_uom_is_integer("uom", "qty") parent.validate_uom_is_integer("stock_uom", "stock_qty") diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index c2a9afadcf0..a8c4a2733fc 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -483,10 +483,34 @@ class SellingController(StockController): sales_order.update_reserved_qty(so_item_rows) def set_incoming_rate(self): + def reset_incoming_rate(): + old_item = next( + ( + item + for item in (old_doc.get("items") + (old_doc.get("packed_items") or [])) + if item.name == d.name + ), + None, + ) + if old_item: + old_qty = flt(old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty")) + if ( + old_item.item_code != d.item_code + or old_item.warehouse != d.warehouse + or old_qty != qty + or old_item.serial_no != d.serial_no + or get_serial_nos(old_item.serial_and_batch_bundle) + != get_serial_nos(d.serial_and_batch_bundle) + or old_item.batch_no != d.batch_no + or get_batch_nos(old_item.serial_and_batch_bundle) + != get_batch_nos(d.serial_and_batch_bundle) + ): + d.incoming_rate = 0 + if self.doctype not in ("Delivery Note", "Sales Invoice"): return - from erpnext.stock.serial_batch_bundle import get_batch_nos + from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos allow_at_arms_length_price = frappe.get_cached_value( "Stock Settings", None, "allow_internal_transfer_at_arms_length_price" @@ -495,6 +519,8 @@ class SellingController(StockController): "Selling Settings", "set_zero_rate_for_expired_batch" ) + is_standalone = self.is_return and not self.return_against + old_doc = self.get_doc_before_save() items = self.get("items") + (self.get("packed_items") or []) for d in items: @@ -526,27 +552,7 @@ class SellingController(StockController): qty = flt(d.get("stock_qty") or d.get("actual_qty") or d.get("qty")) if old_doc: - old_item = next( - ( - item - for item in (old_doc.get("items") + (old_doc.get("packed_items") or [])) - if item.name == d.name - ), - None, - ) - if old_item: - old_qty = flt( - old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty") - ) - if ( - old_item.item_code != d.item_code - or old_item.warehouse != d.warehouse - or old_qty != qty - or old_item.batch_no != d.batch_no - or get_batch_nos(old_item.serial_and_batch_bundle) - != get_batch_nos(d.serial_and_batch_bundle) - ): - d.incoming_rate = 0 + reset_incoming_rate() if ( not d.incoming_rate @@ -565,11 +571,12 @@ class SellingController(StockController): "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": d.name, - "allow_zero_valuation": d.get("allow_zero_valuation"), + "allow_zero_valuation": d.get("allow_zero_valuation_rate"), "batch_no": d.batch_no, "serial_no": d.serial_no, }, - raise_error_if_no_rate=False, + raise_error_if_no_rate=is_standalone, + fallbacks=not is_standalone, ) if ( diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index dcecd995a48..290d8eb5d4b 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -111,7 +111,7 @@ status_map = { ["Pending", "eval:self.status != 'Stopped' and self.per_ordered == 0 and self.docstatus == 1"], [ "Ordered", - "eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type in ['Purchase', 'Manufacture']", + "eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type in ['Purchase', 'Manufacture', 'Subcontracting']", ], [ "Transferred", diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1bd55786f1f..337ffbfeb0c 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -602,6 +602,11 @@ class calculate_taxes_and_totals: else: self.grand_total_diff = 0 + # Apply rounding adjustment to grand_total_for_distributing_discount + # to prevent precision errors during discount distribution + if hasattr(self, "grand_total_for_distributing_discount") and not self.discount_amount_applied: + self.grand_total_for_distributing_discount += self.grand_total_diff + def calculate_totals(self): grand_total_diff = self.grand_total_diff diff --git a/erpnext/controllers/tests/test_distributed_discount.py b/erpnext/controllers/tests/test_distributed_discount.py index e4dbdbb1480..74ae69c1750 100644 --- a/erpnext/controllers/tests/test_distributed_discount.py +++ b/erpnext/controllers/tests/test_distributed_discount.py @@ -59,3 +59,41 @@ class TestTaxesAndTotals(AccountsTestMixin, FrappeTestCase): self.assertEqual(so.total, 1500) self.assertAlmostEqual(so.net_total, 1272.73, places=2) self.assertEqual(so.grand_total, 1400) + + def test_100_percent_discount_with_inclusive_tax(self): + """Test that 100% discount with inclusive taxes results in zero net_total""" + so = make_sales_order(do_not_save=1) + so.apply_discount_on = "Grand Total" + so.items[0].qty = 2 + so.items[0].rate = 1300 + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Account VAT", + "included_in_print_rate": True, + "rate": 9, + }, + ) + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Account Service Tax", + "included_in_print_rate": True, + "rate": 9, + }, + ) + so.save() + + # Apply 100% discount + so.discount_amount = 2600 + calculate_taxes_and_totals(so) + + # net_total should be exactly 0, not 0.01 + self.assertEqual(so.net_total, 0) + self.assertEqual(so.grand_total, 0) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index aaea9e712d4..f3ccc5783a7 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -256,7 +256,7 @@ standard_portal_menu_items = [ "role": "Customer", }, {"title": "Issues", "route": "/issues", "reference_doctype": "Issue", "role": "Customer"}, - {"title": "Addresses", "route": "/addresses", "reference_doctype": "Address"}, + {"title": "Addresses", "route": "/addresses", "reference_doctype": "Address", "role": "Customer"}, { "title": "Timesheets", "route": "/timesheets", diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index e069aea274a..f3f42d6551c 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -56,7 +56,6 @@ class TestRouting(FrappeTestCase): self.assertEqual(job_card_doc.total_completed_qty, 10) wo_doc.cancel() - wo_doc.delete() def test_update_bom_operation_time(self): """Update cost shouldn't update routing times.""" diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f1c9b706ca0..c23db9aa682 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -595,6 +595,33 @@ class TestWorkOrder(FrappeTestCase): work_order1.cancel() work_order.cancel() + def test_planned_qty_updates_after_closing_work_order(self): + item_code = "_Test FG Item" + fg_warehouse = "_Test Warehouse 1 - _TC" + + planned_before = ( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty") + or 0 + ) + + wo = make_wo_order_test_record(item=item_code, fg_warehouse=fg_warehouse, qty=10) + + planned_after_submit = ( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty") + or 0 + ) + self.assertEqual(planned_after_submit, planned_before + 10) + + close_work_order(wo.name, "Closed") + + self.assertEqual(frappe.db.get_value("Work Order", wo.name, "status"), "Closed") + + planned_after_close = ( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty") + or 0 + ) + self.assertEqual(planned_after_close, planned_before) + def test_work_order_with_non_transfer_item(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f5a5e2693b9..14c458015be 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -358,7 +358,7 @@ class WorkOrder(Document): if status != self.status: self.db_set("status", status) - self.update_required_items() + self.update_required_items() return status or self.status @@ -517,7 +517,6 @@ class WorkOrder(Document): self.db_set("status", "Cancelled") self.on_close_or_cancel() - self.delete_job_card() def on_close_or_cancel(self): if self.production_plan and frappe.db.exists( @@ -531,7 +530,6 @@ class WorkOrder(Document): self.update_planned_qty() self.update_ordered_qty() self.update_reserved_qty_for_production() - self.delete_auto_created_batch_and_serial_no() def create_serial_no_batch_no(self): if not (self.has_serial_no or self.has_batch_no): @@ -588,13 +586,6 @@ class WorkOrder(Document): ) ) - def delete_auto_created_batch_and_serial_no(self): - for row in frappe.get_all("Serial No", filters={"work_order": self.name}): - frappe.delete_doc("Serial No", row.name) - - for row in frappe.get_all("Batch", filters={"reference_name": self.name}): - frappe.delete_doc("Batch", row.name) - def make_serial_nos(self, args): item_details = frappe.get_cached_value( "Item", self.production_item, ["serial_no_series", "item_name", "description"], as_dict=1 @@ -1027,10 +1018,6 @@ class WorkOrder(Document): if self.actual_start_date and self.actual_end_date: self.lead_time = flt(time_diff_in_hours(self.actual_end_date, self.actual_start_date) * 60) - def delete_job_card(self): - for d in frappe.get_all("Job Card", ["name"], {"work_order": self.name}): - frappe.delete_doc("Job Card", d.name) - def validate_production_item(self): if frappe.get_cached_value("Item", self.production_item, "has_variants"): frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError) @@ -1173,6 +1160,7 @@ class WorkOrder(Document): "operation": item.operation or operation, "item_code": item.item_code, "item_name": item.item_name, + "stock_uom": item.stock_uom, "description": item.description, "allow_alternative_item": item.allow_alternative_item, "required_qty": item.qty, @@ -1197,7 +1185,7 @@ class WorkOrder(Document): .select( ste_child.item_code, ste_child.original_item, - fn.Sum(ste_child.qty).as_("qty"), + fn.Sum(ste_child.transfer_qty).as_("qty"), ) .where( (ste.docstatus == 1) @@ -1227,7 +1215,7 @@ class WorkOrder(Document): .select( ste_child.item_code, ste_child.original_item, - fn.Sum(ste_child.qty).as_("qty"), + fn.Sum(ste_child.transfer_qty).as_("qty"), ) .where( (ste.docstatus == 1) @@ -1607,8 +1595,8 @@ def close_work_order(work_order, status): ) ) - work_order.on_close_or_cancel() work_order.update_status(status) + work_order.on_close_or_cancel() frappe.msgprint(_("Work Order has been {0}").format(status)) work_order.notify_update() return work_order.status @@ -1765,6 +1753,7 @@ def create_pick_list(source_name, target_doc=None, for_qty=None): target_doc, ) + doc.purpose = "Material Transfer for Manufacture" doc.for_qty = for_qty doc.set_item_locations() diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5c7102b1ad7..9e36329daa4 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -406,6 +406,7 @@ erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices erpnext.patches.v15_0.rename_group_by_to_categorize_by execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") erpnext.patches.v14_0.set_update_price_list_based_on +erpnext.patches.v15_0.add_bank_transaction_as_journal_entry_reference erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports erpnext.patches.v14_0.update_full_name_in_contract @@ -430,3 +431,4 @@ erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12 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 +erpnext.patches.v16_0.add_portal_redirects diff --git a/erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py b/erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py new file mode 100644 index 00000000000..cfac2ab3858 --- /dev/null +++ b/erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py @@ -0,0 +1,33 @@ +import frappe + + +def execute(): + """Append Bank Transaction in custom reference_type options.""" + new_reference_type = "Bank Transaction" + property_setters = frappe.get_all( + "Property Setter", + filters={ + "doc_type": "Journal Entry Account", + "field_name": "reference_type", + "property": "options", + }, + pluck="name", + ) + + for property_setter in property_setters: + existing_value = frappe.db.get_value("Property Setter", property_setter, "value") or "" + + raw_options = [option.strip() for option in existing_value.split("\n")] + # Preserve a single leading blank (for the empty select option) but drop spurious trailing blanks + options = raw_options[:1] + [o for o in raw_options[1:] if o] + + if new_reference_type in options: + continue + + options.append(new_reference_type) + frappe.db.set_value( + "Property Setter", + property_setter, + "value", + "\n".join(options), + ) diff --git a/erpnext/patches/v16_0/add_portal_redirects.py b/erpnext/patches/v16_0/add_portal_redirects.py new file mode 100644 index 00000000000..3a3e553d2ea --- /dev/null +++ b/erpnext/patches/v16_0/add_portal_redirects.py @@ -0,0 +1,14 @@ +import frappe + + +def execute(): + if frappe.db.exists("Portal Menu Item", {"route": "/addresses", "reference_doctype": "Address"}) and ( + doc := frappe.get_doc("Portal Menu Item", {"route": "/addresses", "reference_doctype": "Address"}) + ): + doc.role = "Customer" + doc.save() + + website_settings = frappe.get_single("Website Settings") + website_settings.append("route_redirects", {"source": "addresses", "target": "address/list"}) + website_settings.append("route_redirects", {"source": "projects", "target": "project"}) + website_settings.save() diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 30c4807743d..a4b4de19303 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -584,6 +584,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else { me.grand_total_diff = 0; } + + // Apply rounding adjustment to grand_total_for_distributing_discount + // to prevent precision errors during discount distribution + if (me.grand_total_for_distributing_discount && !me.discount_amount_applied) { + me.grand_total_for_distributing_discount += me.grand_total_diff; + } } } } diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 989c361dd8e..293f8e1324e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -671,7 +671,7 @@ erpnext.utils.update_child_items = function (opts) { filters: filters, }; }, - onchange: function () { + change: function () { const me = this; frm.call({ diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 958defa32c7..1652a849850 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -293,27 +293,49 @@ erpnext.utils.set_taxes = function (frm, triggered_from_field) { erpnext.utils.get_contact_details = function (frm) { if (frm.updating_party_details) return; - if (frm.doc["contact_person"]) { - frappe.call({ - method: "frappe.contacts.doctype.contact.contact.get_contact_details", - args: { contact: frm.doc.contact_person }, - callback: function (r) { - if (r.message) frm.set_value(r.message); - }, - }); - } else { - frm.set_value({ - contact_person: "", - contact_display: "", - contact_email: "", - contact_mobile: "", - contact_phone: "", - contact_designation: "", - contact_department: "", - }); + if (!frm.doc.contact_person) { + reset_contact_fields(frm); + return; } + + frappe.call({ + method: "frappe.contacts.doctype.contact.contact.get_contact_details", + args: { contact: frm.doc.contact_person }, + callback: function (r) { + if (r.message) frm.set_value(r.message); + }, + }); }; +erpnext.utils.get_employee_contact_details = function (frm) { + if (frm.updating_party_details || frm.doc.party_type !== "Employee") return; + + if (!frm.doc.party) { + reset_contact_fields(frm); + return; + } + + frappe.call({ + method: "erpnext.setup.doctype.employee.employee.get_contact_details", + args: { employee: frm.doc.party }, + callback: function (r) { + if (r.message) frm.set_value(r.message); + }, + }); +}; + +function reset_contact_fields(frm) { + frm.set_value({ + contact_person: "", + contact_display: "", + contact_email: "", + contact_mobile: "", + contact_phone: "", + contact_designation: "", + contact_department: "", + }); +} + erpnext.utils.validate_mandatory = function (frm, label, value, trigger_on) { if (!value) { frm.doc[trigger_on] = ""; diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index f0061c016bd..480ca04b6a9 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,6 +123,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } if (doc.status !== "Ordered" && this.frm.has_perm("write")) { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index f41116203ba..7a31854d259 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -614,6 +614,7 @@ def handle_mandatory_error(e, customer, lead_name): frappe.throw(message, title=_("Mandatory Missing")) +@frappe.whitelist() def get_ordered_items(quotation: str): return frappe._dict( frappe.get_all( diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index ae756f34288..96d26b3e703 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -1,17 +1,114 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate +from erpnext.selling.doctype.quotation.quotation import make_sales_order from erpnext.setup.utils import get_exchange_rate test_dependencies = ["Product Bundle"] class TestQuotation(FrappeTestCase): + def test_update_child_quotation_add_item(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_1 = make_item("_Test Item") + item_2 = make_item("_Test Item 1") + + item_list = [ + {"item_code": item_1.item_code, "warehouse": "", "qty": 10, "rate": 300}, + {"item_code": item_2.item_code, "warehouse": "", "qty": 5, "rate": 400}, + ] + + qo = make_quotation(item_list=item_list) + first_item = qo.get("items")[0] + second_item = qo.get("items")[1] + trans_item = json.dumps( + [ + { + "item_code": first_item.item_code, + "rate": first_item.rate, + "qty": 11, + "docname": first_item.name, + }, + { + "item_code": second_item.item_code, + "rate": second_item.rate, + "qty": second_item.qty, + "docname": second_item.name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(qo.get("items")[0].qty, 11) + self.assertEqual(qo.get("items")[-1].rate, 100) + + def test_update_child_disallow_rate_change(self): + qo = make_quotation(qty=4) + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": 5000, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + def test_update_child_removing_item(self): + qo = make_quotation(qty=10) + sales_order = make_sales_order(qo.name) + sales_order.delivery_date = nowdate() + + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + sales_order.submit() + qo.reload() + self.assertEqual(qo.status, "Partially Ordered") + + trans_item = json.dumps([{"item_code": "_Test Item 2", "rate": 100, "qty": 7}]) + + # check if items having a sales order can be removed + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + + # remove item with no sales order + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(len(qo.get("items")), 1) + def test_quotation_qty(self): qo = make_quotation(qty=0, do_not_save=True) with self.assertRaises(InvalidQtyError): @@ -897,6 +994,31 @@ class TestQuotation(FrappeTestCase): so1.submit() self.assertRaises(frappe.ValidationError, so2.submit) + def test_quotation_status(self): + quotation = make_quotation() + + so1 = make_sales_order(quotation.name) + so1.delivery_date = nowdate() + so1.submit() + quotation.reload() + self.assertEqual(quotation.status, "Ordered") + so1.cancel() + + quotation.reload() + self.assertEqual(quotation.status, "Open") + + so2 = make_sales_order(quotation.name) + so2.delivery_date = nowdate() + so2.items[0].qty = 1 + so2.submit() + quotation.reload() + self.assertEqual(quotation.status, "Partially Ordered") + + so2.cancel() + + quotation.reload() + self.assertEqual(quotation.status, "Open") + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 832992198b6..7ceba32232f 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -460,7 +460,7 @@ class SalesOrder(SellingController): "Unreconcile Payment Entries", ) super().on_cancel() - + super().update_prevdoc_status() # Cannot cancel closed SO if self.status == "Closed": frappe.throw(_("Closed order cannot be cancelled. Unclose to cancel.")) diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 21054a9d81b..7edb81de48b 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -524,7 +524,7 @@ "fieldname": "warehouse", "fieldtype": "Link", "in_list_view": 1, - "label": "Delivery Warehouse", + "label": "Source Warehouse", "oldfieldname": "reserved_warehouse", "oldfieldtype": "Link", "options": "Warehouse", @@ -971,7 +971,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2025-02-28 09:45:44.934947", + "modified": "2026-02-20 16:39:00.200328", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/setup/doctype/department/department.py b/erpnext/setup/doctype/department/department.py index a698bd767b4..f1e80c17fb6 100644 --- a/erpnext/setup/doctype/department/department.py +++ b/erpnext/setup/doctype/department/department.py @@ -30,8 +30,7 @@ class Department(NestedSet): nsm_parent_field = "parent_department" def autoname(self): - root = get_root_of("Department") - if root and self.department_name != root: + if self.company: self.name = get_abbreviated_name(self.department_name, self.company) else: self.name = self.department_name diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 85f9e712fe0..14724ead051 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -436,3 +436,59 @@ def has_upload_permission(doc, ptype="read", user=None): if get_doc_permissions(doc, user=user, ptype=ptype).get(ptype): return True return doc.user_id == user + + +@frappe.whitelist() +def get_contact_details(employee: str) -> dict: + """ + Returns basic contact details for the given employee. + + Email is selected based on the following priority: + 1. Prefered Email + 2. Company Email + 3. Personal Email + 4. User ID + """ + if not employee: + frappe.throw(msg=_("Employee is required"), title=_("Missing Parameter")) + + frappe.has_permission("Employee", "read", employee, throw=True) + + return _get_contact_details(employee) + + +def _get_contact_details(employee: str) -> dict: + contact_data = frappe.db.get_value( + "Employee", + employee, + [ + "employee_name", + "prefered_email", + "company_email", + "personal_email", + "user_id", + "cell_number", + "designation", + "department", + ], + as_dict=True, + ) + + if not contact_data: + frappe.throw(msg=_("Employee {0} not found").format(employee), title=_("Not Found")) + + # Email with priority + employee_email = ( + contact_data.get("prefered_email") + or contact_data.get("company_email") + or contact_data.get("personal_email") + or contact_data.get("user_id") + ) + + return { + "contact_display": contact_data.get("employee_name"), + "contact_email": employee_email, + "contact_mobile": contact_data.get("cell_number"), + "contact_designation": contact_data.get("designation"), + "contact_department": contact_data.get("department"), + } diff --git a/erpnext/stock/doctype/material_request/material_request_list.js b/erpnext/stock/doctype/material_request/material_request_list.js index 69d8c15803d..113ab61f8c0 100644 --- a/erpnext/stock/doctype/material_request/material_request_list.js +++ b/erpnext/stock/doctype/material_request/material_request_list.js @@ -25,7 +25,7 @@ frappe.listview_settings["Material Request"] = { ) { return [__("Partially Received"), "yellow", "per_ordered,<,100"]; } else if (doc.docstatus == 1 && flt(doc.per_ordered, precision) < 100) { - return [__("Partially ordered"), "yellow", "per_ordered,<,100"]; + return [__("Partially Ordered"), "yellow", "per_ordered,<,100"]; } else if (doc.docstatus == 1 && flt(doc.per_ordered, precision) == 100) { if ( doc.material_request_type == "Purchase" && @@ -35,7 +35,7 @@ frappe.listview_settings["Material Request"] = { return [__("Partially Received"), "yellow", "per_received,<,100"]; } else if (doc.material_request_type == "Purchase" && flt(doc.per_received, precision) == 100) { return [__("Received"), "green", "per_received,=,100"]; - } else if (["Purchase", "Manufacture"].includes(doc.material_request_type)) { + } else if (["Purchase", "Manufacture", "Subcontracting"].includes(doc.material_request_type)) { return [__("Ordered"), "green", "per_ordered,=,100"]; } else if (doc.material_request_type == "Material Transfer") { return [__("Transfered"), "green", "per_ordered,=,100"]; diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index f82c53bd14f..01816eecdb7 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -997,12 +997,11 @@ def validate_picked_materials(item_code, required_qty, locations, picked_item_de if remaining_qty > 0: if picked_item_details: frappe.msgprint( - _("{0} units of Item {1} is picked in another Pick List.").format( - remaining_qty, get_link_to_form("Item", item_code) - ), + _( + "{0} units of Item {1} is not available in any of the warehouses. Other Pick Lists exist for this item." + ).format(remaining_qty, get_link_to_form("Item", item_code)), title=_("Already Picked"), ) - else: frappe.msgprint( _("{0} units of Item {1} is not available in any of the warehouses.").format( diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index b5c1c38729f..60981047d9e 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -365,6 +365,15 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend apply_putaway_rule() { if (this.frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(this.frm); } + + items_add(doc, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + this.frm.script_manager.copy_from_first_row("items", row, [ + "expense_account", + "cost_center", + "project", + ]); + } }; // for backward compatibility: combine new and previous states diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index faef473a7fa..648836d0f6e 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -274,7 +274,9 @@ class QualityInspection(Document): def set_status_based_on_acceptance_values(self, reading): if not cint(reading.numeric): - result = reading.get("reading_value") == reading.get("value") + reading_value = reading.get("reading_value") or "" + value = reading.get("value") or "" + result = reading_value == value else: # numeric readings result = self.min_max_criteria_passed(reading) 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 69a82ab64cb..54e4c4b2f56 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 @@ -299,10 +299,20 @@ class SerialandBatchBundle(Document): for serial_no in serial_nos: if not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse: - self.throw_error_message( - f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.", - SerialNoWarehouseError, - ) + reservation = get_serial_no_reservation(self.item_code, serial_no, self.warehouse) + if reservation: + self.throw_error_message( + f"Serial No {bold(serial_no)} is in warehouse {bold(self.warehouse)}" + f" but is reserved for {reservation.voucher_type} {bold(reservation.voucher_no)}" + f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}." + f" Please use an unreserved serial number or cancel the reservation.", + SerialNoWarehouseError, + ) + else: + self.throw_error_message( + f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.", + SerialNoWarehouseError, + ) def validate_serial_nos_duplicate(self): # Don't inward same serial number multiple times @@ -1032,6 +1042,8 @@ class SerialandBatchBundle(Document): qty_field = "consumed_qty" elif row.get("doctype") == "Stock Entry Detail": qty_field = "transfer_qty" + elif row.get("doctype") in ["Sales Invoice Item", "Purchase Invoice Item"]: + qty_field = "stock_qty" return qty_field @@ -2445,6 +2457,32 @@ def get_reserved_serial_nos_for_sre(kwargs) -> list: return [row[0] for row in query.run()] +def get_serial_no_reservation(item_code: str, serial_no: str, warehouse: str) -> _dict | None: + """Returns the Stock Reservation Entry that has reserved the given serial number, if any.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + result = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sre.name, sre.voucher_type, sre.voucher_no) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.status.notin(["Delivered", "Cancelled", "Closed"])) + & (sre.reservation_based_on == "Serial and Batch") + & (sb_entry.serial_no == serial_no) + & (sb_entry.qty != sb_entry.delivered_qty) + ) + .limit(1) + .run(as_dict=True) + ) + + return result[0] if result else None + + def get_reserved_batches_for_pos(kwargs) -> dict: """Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices.""" diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index bf5ea741e3f..41bfbd85e22 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -261,7 +261,6 @@ "label": "Serial and Batch Reservation" }, { - "allow_on_submit": 1, "default": "Qty", "depends_on": "eval: parent.has_serial_no || parent.has_batch_no", "fieldname": "reservation_based_on", @@ -269,7 +268,7 @@ "label": "Reservation Based On", "no_copy": 1, "options": "Qty\nSerial and Batch", - "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.from_voucher_type == \"Pick List\")" + "read_only": 1 }, { "fieldname": "column_break_7dxj", @@ -315,11 +314,11 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-02-07 16:05:17.772098", + "modified": "2026-02-19 10:17:28.695394", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -425,4 +424,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index ee7e652cf6e..a9a3fcbfae4 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -455,7 +455,7 @@ }, { "default": "0", - "description": "If enabled, the item rate won't adjust to the valuation rate during internal transfers, but accounting will still use the valuation rate.", + "description": "If enabled, the item rate won't adjust to the valuation rate during internal transfers, but accounting will still use the valuation rate. This will allow the user to specify a different rate for printing or taxation purposes.", "fieldname": "allow_internal_transfer_at_arms_length_price", "fieldtype": "Check", "label": "Allow Internal Transfers at Arm's Length Price" @@ -553,7 +553,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-02-16 10:36:59.921491", + "modified": "2026-02-25 09:56:34.105949", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ae3730d4897..d524f644a0f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1920,6 +1920,7 @@ def get_valuation_rate( allow_zero_rate=False, currency=None, company=None, + fallbacks=True, raise_error_if_no_rate=True, batch_no=None, serial_and_batch_bundle=None, @@ -1982,23 +1983,20 @@ def get_valuation_rate( ): return flt(last_valuation_rate[0][0]) - # If negative stock allowed, and item delivered without any incoming entry, - # system does not found any SLE, then take valuation rate from Item - valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate") - - if not valuation_rate: - # try Item Standard rate - valuation_rate = frappe.db.get_value("Item", item_code, "standard_rate") - - if not valuation_rate: - # try in price list - valuation_rate = frappe.db.get_value( + if fallbacks: + # If negative stock allowed, and item delivered without any incoming entry, + # system does not found any SLE, then take valuation rate from Item + if rate := ( + frappe.db.get_value("Item", item_code, "valuation_rate") + or frappe.db.get_value("Item", item_code, "standard_rate") + or frappe.db.get_value( "Item Price", dict(item_code=item_code, buying=1, currency=currency), "price_list_rate" ) + ): + return flt(rate) if ( not allow_zero_rate - and not valuation_rate and raise_error_if_no_rate and cint(erpnext.is_perpetual_inventory_enabled(company)) ): @@ -2028,8 +2026,6 @@ def get_valuation_rate( frappe.throw(msg=msg, title=_("Valuation Rate Missing")) - return valuation_rate - def update_qty_in_future_sle(args, allow_negative_stock=False): """Recalculate Qty after Transaction in future SLEs based on current SLE.""" diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 58ecb24db48..0c03e350d02 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -240,7 +240,7 @@ def _create_bin(item_code, warehouse): @frappe.whitelist() -def get_incoming_rate(args, raise_error_if_no_rate=True): +def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True): """Get Incoming Rate based on valuation method""" from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate @@ -325,6 +325,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): args.get("allow_zero_valuation"), currency=erpnext.get_company_currency(args.get("company")), company=args.get("company"), + fallbacks=fallbacks, raise_error_if_no_rate=raise_error_if_no_rate, ) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index f764329e338..aaf0ec87c23 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -81,23 +81,6 @@ class SubcontractingOrder(SubcontractingController): transaction_date: DF.Date # end: auto-generated types - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.status_updater = [ - { - "source_dt": "Subcontracting Order Item", - "target_dt": "Material Request Item", - "join_field": "material_request_item", - "target_field": "ordered_qty", - "target_parent_dt": "Material Request", - "target_parent_field": "per_ordered", - "target_ref_field": "stock_qty", - "source_field": "qty", - "percent_join_field": "material_request", - } - ] - def onload(self): self.set_onload( "over_transfer_allowance", @@ -117,12 +100,10 @@ class SubcontractingOrder(SubcontractingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") def on_submit(self): - self.update_prevdoc_status() self.update_status() self.update_subcontracted_quantity_in_po() def on_cancel(self): - self.update_prevdoc_status() self.update_status() self.update_subcontracted_quantity_in_po(cancel=True)