diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json new file mode 100644 index 00000000000..515a1e4de9d --- /dev/null +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json @@ -0,0 +1,817 @@ +{ + "country_code": "au", + "name": "Australia - Chart of Accounts with Account Numbers", + "tree": { + "Assets": { + "Current Assets": { + "Cash On Hand": { + "Cash On Hand": { + "account_number": "11010", + "account_type": "Cash" + }, + "account_number": "110", + "is_group": 1 + }, + "Cash at Bank": { + "Every Day Bank Account": { + "account_number": "11510", + "account_type": "Bank" + }, + "Business Savings Account": { + "account_number": "11520" + }, + "Business Term Deposit": { + "account_number": "11530" + }, + "account_number": "115", + "is_group": 1 + }, + "Trade Receivables": { + "Trade Debtors": { + "account_number": "12010", + "account_type": "Receivable" + }, + "Provision for Doubtful Debts": { + "account_number": "12020" + }, + "Sundry Debtors": { + "account_number": "12030" + }, + "Debtor Refund": { + "account_number": "12040" + }, + "account_number": "120", + "is_group": 1 + }, + "Inventory": { + "Stock On Hand": { + "account_number": "13010", + "account_type": "Stock" + }, + "WIP - Work In Progress - Manufacturing": { + "account_number": "13020" + }, + "account_number": "130", + "is_group": 1 + }, + "Prepayments": { + "Prepayments": { + "account_number": "14010" + }, + "Provisional Tax Paid": { + "account_number": "14020" + }, + "account_number": "140", + "is_group": 1 + }, + "account_number": "11", + "is_group": 1 + }, + "Non Current Assets": { + "Plant & Equipment": { + "Plant & Equipment": { + "account_number": "16010", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation Plant & Equipment": { + "account_number": "16020", + "account_type": "Accumulated Depreciation" + }, + "account_number": "160", + "is_group": 1 + }, + "Motor Vehicle": { + "Motor Vehicle": { + "account_number": "16110", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation Motor Vehicle": { + "account_number": "16120", + "account_type": "Accumulated Depreciation" + }, + "account_number": "161", + "is_group": 1 + }, + "Office Equipment": { + "Office Furniture & Equipment": { + "account_number": "16210", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation Office Furniture & Equipment": { + "account_number": "16220", + "account_type": "Accumulated Depreciation" + }, + "account_number": "162", + "is_group": 1 + }, + "Computer Equipment": { + "Computer Equipment": { + "account_number": "16310", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation Computer Equipment": { + "account_number": "16320", + "account_type": "Accumulated Depreciation" + }, + "account_number": "163", + "is_group": 1 + }, + "Building": { + "Buildings": { + "account_number": "16410", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation Buildings": { + "account_number": "16420", + "account_type": "Accumulated Depreciation" + }, + "CWIP - Construction Work In Progress": { + "account_number": "16430", + "account_type": "Capital Work in Progress" + }, + "Accumulated Depreciation - Others": { + "account_number": "16440", + "account_type": "Accumulated Depreciation" + }, + "account_number": "164", + "is_group": 1 + }, + "Related Party": { + "Loan to Party 1": { + "account_number": "17010" + }, + "account_number": "170", + "is_group": 1 + }, + "Investments & Unlisted Entities": { + "Investment - Entity 1": { + "account_number": "17510" + }, + "account_number": "175", + "is_group": 1 + }, + "Intagible Assets": { + "Goodwill": { + "account_number": "18010" + }, + "Opening Balance Temporary ": { + "account_number": "18090", + "account_type": "Temporary" + }, + "account_number": "180", + "is_group": 1 + }, + "account_number": "16", + "is_group": 1 + }, + "account_number": "1", + "root_type": "Asset" + }, + "Liabilities": { + "Current Liabilities": { + "Trade Payables - Current": { + "Trade Creditors": { + "account_number": "21010", + "account_type": "Payable" + }, + "Goods Received Not Invoiced": { + "account_number": "21050", + "account_type": "Stock Received But Not Billed" + }, + "Service Received Not Invoiced": { + "account_number": "21060" + }, + "Asset Received Not Invoiced": { + "account_number": "21070", + "account_type": "Asset Received But Not Billed" + }, + "account_number": "210", + "is_group": 1 + }, + "Other Payables - Current": { + "Accrued Expenses": { + "account_number": "21510" + }, + "Payroll - Wages Clearing": { + "account_number": "21550" + }, + "Payroll - Superannuation Deductions": { + "account_number": "21555" + }, + "Payroll - Misc Deductions": { + "account_number": "21560" + }, + "Payroll - Withholding Tax Payable": { + "account_number": "21565" + }, + "account_number": "215", + "is_group": 1 + }, + "GST": { + "GST Payments to ATO": { + "account_number": "22030" + }, + "Provision for PAYG Tax": { + "account_number": "22040" + }, + "account_number": "220", + "account_type": "Tax", + "is_group": 1 + }, + "Interest & Non Bearing Liabilities - Current": { + "Credit Card - VISA": { + "account_number": "22510" + }, + "account_number": "225", + "is_group": 1 + }, + "Bank Overdraft": { + "Bank Overdraft Cash at Bank": { + "account_number": "23010" + }, + "account_number": "230", + "is_group": 1 + }, + "Trade Finance": { + "Trade Finance": { + "account_number": "23510" + }, + "account_number": "235", + "is_group": 1 + }, + "Lease Liabilities": { + "Finance Lease - Current": { + "account_number": "24010" + }, + "account_number": "240", + "is_group": 1 + }, + "Provisions": { + "Provision for Long Service Leave": { + "account_number": "24510" + }, + "Provision for Holiday Pay": { + "account_number": "24520" + }, + "account_number": "245", + "is_group": 1 + }, + "account_number": "21", + "is_group": 1 + }, + "Non Current Liabilities": { + "Trade & Other Payables - Non Current": { + "Loan Account - Party 1": { + "account_number": "25010" + }, + "account_number": "250", + "is_group": 1 + }, + "Interest & Non Bearing Liabilities - Non Current": { + "Non Current Liability - Director Loan": { + "account_number": "25510" + }, + "account_number": "255", + "is_group": 1 + }, + "Bank Loans - Non Current": { + "Bank Loan 1 - Non Current": { + "account_number": "26010" + }, + "account_number": "260", + "is_group": 1 + }, + "Lease Liabilities - Non Current": { + "Finance Lease - Non Current": { + "account_number": "27010" + }, + "account_number": "270", + "is_group": 1 + }, + "Provisions - Non Current": { + "Provision for Long Service Leave": { + "account_number": "27510" + }, + "Provision for Holiday Pay": { + "account_number": "27520" + }, + "account_number": "275", + "is_group": 1 + }, + "account_number": "25", + "is_group": 1 + }, + "account_number": "2", + "root_type": "Liability" + }, + "Equity": { + "Equity": { + "Owner's/Shareholder's Equity": { + "Owner's/Shareholders Capital": { + "account_number": "31010", + "account_type": "Equity" + }, + "Owner's/Shareholders Drawings": { + "account_number": "31020", + "account_type": "Equity" + }, + "account_number": "310", + "is_group": 1 + }, + "Earnings": { + "Current Year Earnings": { + "account_number": "35010", + "account_type": "Equity" + }, + "Retained Earnings": { + "account_number": "35020", + "account_type": "Equity" + }, + "account_number": "350", + "is_group": 1 + }, + "account_number": "31", + "is_group": 1 + }, + "account_number": "3", + "root_type": "Equity" + }, + "Revenue": { + "Revenue": { + "Sales Revenue": { + "Sales Income": { + "account_number": "41010", + "account_type": "Income Account" + }, + "Freight Income": { + "account_number": "41020", + "account_type": "Income Account" + }, + "Other Income": { + "account_number": "41030", + "account_type": "Income Account" + }, + "Service Income": { + "account_number": "41040", + "account_type": "Income Account" + }, + "account_number": "410", + "is_group": 1 + }, + "Other Revenue": { + "Commission Received": { + "account_number": "42010" + }, + "Discounts Received": { + "account_number": "42020" + }, + "Interest received": { + "account_number": "42030" + }, + "Profit/Loss on Sales of Assets": { + "account_number": "42040" + }, + "Rent Received": { + "account_number": "42050" + }, + "Sundry Income": { + "account_number": "42060" + }, + "account_number": "420", + "is_group": 1 + }, + "account_number": "41", + "is_group": 1 + }, + "account_number": "4", + "root_type": "Income" + }, + "Cost of Goods": { + "Cost of Goods": { + "Cost of Goods Sold": { + "Cost of Goods Sold": { + "account_number": "51010", + "account_type": "Cost of Goods Sold" + }, + "Freight Expenses (sales related)": { + "account_number": "51020" + }, + "Discounts Given": { + "account_number": "51030" + }, + "Subcontracting Charges": { + "account_number": "51040" + }, + "account_number": "510", + "is_group": 1 + }, + "Other COGS": { + "Purchases - Miscellaneous": { + "account_number": "52010" + }, + "Duty & Customs Fees": { + "account_number": "52020", + "account_type": "Tax" + }, + "Freight Inwards": { + "account_number": "52030", + "account_type": "Chargeable" + }, + "Stock Adjustment": { + "account_number": "52040", + "account_type": "Stock Adjustment" + }, + "Stock Wirte Off": { + "account_number": "52050", + "account_type": "Stock Adjustment" + }, + "Stock Valuation Expenses": { + "account_number": "52060", + "account_type": "Expenses Included In Valuation" + }, + "Asset Valuation Expenses": { + "account_number": "52070", + "account_type": "Expenses Included In Asset Valuation" + }, + "account_number": "520", + "is_group": 1 + }, + "account_number": "51", + "is_group": 1 + }, + "account_number": "5", + "root_type": "Expense" + }, + "Expenses": { + "Fixed Expenses": { + "Payroll & Related Expenses": { + "Salaries & Wages": { + "account_number": "61010" + }, + "Superannuation": { + "account_number": "61015" + }, + "Staff Amenities - GST Paid": { + "account_number": "61020" + }, + "Staff Amenities - GST Free": { + "account_number": "61025" + }, + "Staff Recruitment": { + "account_number": "61030" + }, + "Staff Training": { + "account_number": "61035" + }, + "Fringe Benefits Tax": { + "account_number": "61040" + }, + "Payroll Tax": { + "account_number": "61045" + }, + "Workers Compensation": { + "account_number": "61050" + }, + "Long Service Leave": { + "account_number": "61060" + }, + "Mileage Reimbursement": { + "account_number": "61070" + }, + "Overtime": { + "account_number": "61080" + }, + "Worksafe Insurance": { + "account_number": "61090" + }, + "account_number": "610", + "is_group": 1 + }, + "Depreciation Expenses": { + "Depreciation - Plant & Equipment": { + "account_number": "62010", + "account_type": "Depreciation" + }, + "Depreciation - Motor Vehicle": { + "account_number": "62020", + "account_type": "Depreciation" + }, + "Depreciation - Office Equipment": { + "account_number": "62030", + "account_type": "Depreciation" + }, + "Depreciation - Computer Equipment": { + "account_number": "62040", + "account_type": "Depreciation" + }, + "Depreciation - Building": { + "account_number": "62050", + "account_type": "Depreciation" + }, + "Depreciation - Others": { + "account_number": "62510", + "account_type": "Depreciation" + }, + "account_number": "620", + "is_group": 1 + }, + "account_number": "61", + "is_group": 1 + }, + "Accrued Expenses": { + "Accrued Expenses": { + "Accrued Expenses - Salaries & Wages": { + "account_number": "63010" + }, + "Accrued Expenses - Interest": { + "account_number": "63020" + }, + "account_number": "630", + "is_group": 1 + }, + "account_number": "63", + "is_group": 1 + }, + "Operating Expenses": { + "General and Administrative Expenses": { + "Low Value Assets less than $300": { + "account_number": "64010" + }, + "Office Supplies": { + "account_number": "64020" + }, + "Postage & Courier": { + "account_number": "64025" + }, + "Printing & Stationery": { + "account_number": "64030" + }, + "Registration Fees / Filing Fees": { + "account_number": "64040" + }, + "Travel & Accommodation - Local": { + "account_number": "64050" + }, + "Travel & Accommodation - Overseas": { + "account_number": "64060" + }, + "Relocation Costs": { + "account_number": "64070" + }, + "Hire Charges": { + "account_number": "64080" + }, + "Repairs & Maintenance": { + "account_number": "64210" + }, + "Cleaning Expenses": { + "account_number": "64215" + }, + "Uniforms": { + "account_number": "64220" + }, + "Security": { + "account_number": "64225" + }, + "Subscriptions & Licences": { + "account_number": "64510" + }, + "Software Expenses": { + "account_number": "64515" + }, + "Marketing Expenses": { + "account_number": "64520" + }, + "Advertising Expenses": { + "account_number": "64525" + }, + "Website Hosting & Domain Expenses": { + "account_number": "64530" + }, + "Computer Repairs / Supplies": { + "account_number": "64540" + }, + "Conferences": { + "account_number": "64550" + }, + "Consultancy /Contract Services": { + "account_number": "64560" + }, + "Training Services": { + "account_number": "64570" + }, + "Workshop Supplies": { + "account_number": "64580" + }, + "Consumables": { + "account_number": "64585" + }, + "Entertainment Expenses - Deductible": { + "account_number": "64810" + }, + "Entertainment Expenses - Non Deductible": { + "account_number": "64820" + }, + "Amortisation Of Goodwill": { + "account_number": "64910" + }, + "General / Miscellaneous Expenses": { + "account_number": "64915", + "account_type": "Chargeable" + }, + "Donations": { + "account_number": "64920" + }, + "Client Gifts": { + "account_number": "64930" + }, + "Employee Gifts": { + "account_number": "64935" + }, + "account_number": "640", + "is_group": 1 + }, + "Occupancy Expenses": { + "Rental Expenses": { + "account_number": "65010" + }, + "Property Insurance": { + "account_number": "65020" + }, + "Electricity Expenses": { + "account_number": "65030" + }, + "Water Rates": { + "account_number": "65040" + }, + "Gas Expenses": { + "account_number": "65050" + }, + "Property Taxes": { + "account_number": "65060" + }, + "Rates": { + "account_number": "65070" + }, + "account_number": "650", + "is_group": 1 + }, + "Communication & Vehicle Expenses": { + "Internet Expenses": { + "account_number": "66010" + }, + "Mobile Telephone": { + "account_number": "66020" + }, + "Telephone Expenses": { + "account_number": "66030" + }, + "Motor Vehicle - Fuel Expenses": { + "account_number": "66040" + }, + "Motor Vehicle - Parking & Tolls": { + "account_number": "66050" + }, + "Motor Vehicle - Registration & Insurance": { + "account_number": "66060" + }, + "Motor Vehicle - Service & Repairs": { + "account_number": "66070" + }, + "Taxi": { + "account_number": "66080" + }, + "account_number": "660", + "is_group": 1 + }, + "account_number": "64", + "is_group": 1 + }, + "Non-Operating Expenses": { + "Finance Costs": { + "Interest - Bank Loans": { + "account_number": "67010" + }, + "Interest - Finance Leases": { + "account_number": "67020" + }, + "Interest - Other Loans": { + "account_number": "67025" + }, + "Insurance": { + "account_number": "67030" + }, + "Bank Charges": { + "account_number": "67050" + }, + "Rounding off": { + "account_number": "67055", + "account_type": "Round Off" + }, + "Audit Fees": { + "account_number": "67060" + }, + "Accounting Fees": { + "account_number": "67070" + }, + "Legal Fees": { + "account_number": "67080" + }, + "Management Fees": { + "account_number": "67090" + }, + "account_number": "670", + "is_group": 1 + }, + "Other Costs": { + "Doubtful Debts": { + "account_number": "67510" + }, + "Fines": { + "account_number": "67520" + }, + "Debt Collection": { + "account_number": "67530" + }, + "Bad Debts": { + "account_number": "67540" + }, + "account_number": "675", + "is_group": 1 + }, + "account_number": "67", + "is_group": 1 + }, + "Variable Expenses": { + "Variable Expenses": { + "Bonus & Commissions Paid": { + "account_number": "68010" + }, + "Bonus & Commissions To be Paid": { + "account_number": "68020" + }, + "Warranty Claims": { + "account_number": "68030" + }, + "account_number": "680", + "is_group": 1 + }, + "account_number": "68", + "is_group": 1 + }, + "account_number": "6", + "root_type": "Expense" + }, + "Other Income": { + "Other Income": { + "Interest Income": { + "Interest Income": { + "account_number": "71010" + }, + "account_number": "710", + "is_group": 1 + }, + "Asset Disposal Income": { + "Gain on Asset Disposal": { + "account_number": "73010" + }, + "account_number": "730", + "is_group": 1 + }, + "account_number": "71", + "is_group": 1 + }, + "account_number": "7", + "root_type": "Income" + }, + "Other Expenses": { + "Other Expenses": { + "Income Tax Expenses": { + "Income Tax Expenses": { + "account_number": "81010" + }, + "account_number": "810", + "is_group": 1 + }, + "Foreign Exchange Gain/Loss": { + "Exchange Loss/Gain - Realized": { + "account_number": "82010" + }, + "account_number": "820", + "is_group": 1 + }, + "Asset Disposal Expenses": { + "Loss on Asset Disposal": { + "account_number": "83010" + }, + "account_number": "830", + "is_group": 1 + }, + "account_number": "81", + "is_group": 1 + }, + "account_number": "8", + "root_type": "Expense" + } + } +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.js b/erpnext/accounts/doctype/accounts_settings/accounts_settings.js index ba577f2b8c9..931e05a716b 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.js +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.js @@ -26,9 +26,20 @@ frappe.ui.form.on("Accounts Settings", { add_taxes_from_taxes_and_charges_template(frm) { toggle_tax_settings(frm, "add_taxes_from_taxes_and_charges_template"); }, + add_taxes_from_item_tax_template(frm) { toggle_tax_settings(frm, "add_taxes_from_item_tax_template"); }, + + drop_ar_procedures: function (frm) { + frm.call({ + doc: frm.doc, + method: "drop_ar_sql_procedures", + callback: function (r) { + frappe.show_alert(__("Procedures dropped"), 5); + }, + }); + }, }); function toggle_tax_settings(frm, field_name) { diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index fc250f65656..dcf6f24fae0 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -90,6 +90,8 @@ "receivable_payable_remarks_length", "accounts_receivable_payable_tuning_section", "receivable_payable_fetch_method", + "column_break_ntmi", + "drop_ar_procedures", "legacy_section", "ignore_is_opening_check_for_reporting", "payment_request_settings", @@ -556,7 +558,7 @@ "fieldname": "receivable_payable_fetch_method", "fieldtype": "Select", "label": "Data Fetch Method", - "options": "Buffered Cursor\nUnBuffered Cursor" + "options": "Buffered Cursor\nUnBuffered Cursor\nRaw SQL" }, { "fieldname": "accounts_receivable_payable_tuning_section", @@ -631,6 +633,17 @@ "fieldname": "add_taxes_from_taxes_and_charges_template", "fieldtype": "Check", "label": "Automatically Add Taxes from Taxes and Charges Template" + }, + { + "fieldname": "column_break_ntmi", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.receivable_payable_fetch_method == \"Raw SQL\"", + "description": "Drops existing SQL Procedures and Function setup by Accounts Receivable report", + "fieldname": "drop_ar_procedures", + "fieldtype": "Button", + "label": "Drop Procedures" } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 689d3cd2fe0..8475e54f465 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -59,7 +59,7 @@ class AccountsSettings(Document): merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency post_change_gl_entries: DF.Check - receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] + receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int role_allowed_to_over_bill: DF.Link | None @@ -149,8 +149,16 @@ class AccountsSettings(Document): if self.add_taxes_from_item_tax_template and self.add_taxes_from_taxes_and_charges_template: frappe.throw( _("You cannot enable both the settings '{0}' and '{1}'.").format( - frappe.bold(self.meta.get_label("add_taxes_from_item_tax_template")), - frappe.bold(self.meta.get_label("add_taxes_from_taxes_and_charges_template")), + frappe.bold(_(self.meta.get_label("add_taxes_from_item_tax_template"))), + frappe.bold(_(self.meta.get_label("add_taxes_from_taxes_and_charges_template"))), ), title=_("Auto Tax Settings Error"), ) + + @frappe.whitelist() + def drop_ar_sql_procedures(self): + from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR + + frappe.db.sql(f"drop function if exists {InitSQLProceduresForAR.genkey_function_name}") + frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}") + frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}") diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 5895e67523a..ad9575f005b 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -89,46 +89,64 @@ class BankClearance(Document): @frappe.whitelist() def update_clearance_date(self): - clearance_date_updated = False + invalid_document = [] + invalid_cheque_date = [] + entries_to_update = [] + + def validate_entry(d): + is_valid = True + if not d.payment_document: + invalid_document.append(str(d.idx)) + is_valid = False + + if d.clearance_date and d.cheque_date and getdate(d.clearance_date) < getdate(d.cheque_date): + invalid_cheque_date.append(str(d.idx)) + is_valid = False + + return is_valid + for d in self.get("payment_entries"): - if d.clearance_date: - if not d.payment_document: - frappe.throw(_("Row #{0}: Payment document is required to complete the transaction")) - - if d.cheque_date and getdate(d.clearance_date) < getdate(d.cheque_date): - frappe.throw( - _("Row #{0}: For {1} Clearance date {2} cannot be before Cheque Date {3}").format( - d.idx, - get_link_to_form(d.payment_document, d.payment_entry), - d.clearance_date, - d.cheque_date, - ) - ) - - if d.clearance_date or self.include_reconciled_entries: + if validate_entry(d) and (d.clearance_date or self.include_reconciled_entries): if not d.clearance_date: d.clearance_date = None - if d.payment_document == "Sales Invoice": - frappe.db.set_value( - "Sales Invoice Payment", - {"parent": d.payment_entry, "account": self.get("account"), "amount": [">", 0]}, - "clearance_date", - d.clearance_date, - ) + entries_to_update.append(d) - else: - # using db_set to trigger notification - payment_entry = frappe.get_doc(d.payment_document, d.payment_entry) - payment_entry.db_set("clearance_date", d.clearance_date) + if invalid_document or invalid_cheque_date: + msg = _("

Please correct the following row(s):

" + frappe.throw(_(msg)) + return + + if not entries_to_update: msgprint(_("Clearance Date not mentioned")) + return + + for d in entries_to_update: + if d.payment_document == "Sales Invoice": + frappe.db.set_value( + "Sales Invoice Payment", + {"parent": d.payment_entry, "account": self.get("account"), "amount": [">", 0]}, + "clearance_date", + d.clearance_date, + ) + else: + # using db_set to trigger notification + payment_entry = frappe.get_lazy_doc(d.payment_document, d.payment_entry) + payment_entry.db_set("clearance_date", d.clearance_date) + + self.get_payment_entries() + msgprint(_("Clearance Date updated")) def get_payment_entries_for_bank_clearance( diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 707a52e84ad..6f48ca65b41 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -113,6 +113,10 @@ class TestBudget(ERPNextTestSuite): frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year) + accumulated_limit = get_accumulated_monthly_budget( + budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + ) + mr = frappe.get_doc( { "doctype": "Material Request", @@ -126,7 +130,7 @@ class TestBudget(ERPNextTestSuite): "uom": "_Test UOM", "warehouse": "_Test Warehouse - _TC", "schedule_date": nowdate(), - "rate": 100000, + "rate": accumulated_limit + 1, "expense_account": "_Test Account Cost for Goods Sold - _TC", "cost_center": "_Test Cost Center - _TC", } diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 74e20cebb9d..f2998008722 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -46,7 +46,6 @@ "reference", "clearance_date", "remark", - "paid_loan", "inter_company_journal_entry_reference", "column_break98", "bill_no", @@ -310,13 +309,6 @@ "oldfieldtype": "Small Text", "read_only": 1 }, - { - "fieldname": "paid_loan", - "fieldtype": "Data", - "hidden": 1, - "label": "Paid Loan", - "print_hide": 1 - }, { "depends_on": "eval:doc.voucher_type== \"Inter Company Journal Entry\"", "fieldname": "inter_company_journal_entry_reference", @@ -599,7 +591,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2025-06-17 15:18:13.322681", + "modified": "2025-07-06 15:22:58.465131", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index ddf6c70d838..9d30c3b39e5 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -24,6 +24,7 @@ from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, get_account_currency, + get_advance_payment_doctypes, get_balance_on, get_stock_accounts, get_stock_and_account_balance, @@ -71,7 +72,6 @@ class JournalEntry(AccountsController): mode_of_payment: DF.Link | None multi_currency: DF.Check naming_series: DF.Literal["ACC-JV-.YYYY.-"] - paid_loan: DF.Data | None pay_to_recd_from: DF.Data | None payment_order: DF.Link | None periodic_entry_difference_account: DF.Link | None @@ -151,8 +151,8 @@ class JournalEntry(AccountsController): if self.docstatus == 0: self.apply_tax_withholding() - - self.title = self.get_title() + if self.is_new() or not self.title: + self.title = self.get_title() def validate_advance_accounts(self): journal_accounts = set([x.account for x in self.accounts]) @@ -311,9 +311,7 @@ class JournalEntry(AccountsController): def update_advance_paid(self): advance_paid = frappe._dict() - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) + advance_payment_doctypes = get_advance_payment_doctypes() for d in self.get("accounts"): if d.is_advance: if d.reference_type in advance_payment_doctypes: @@ -1147,7 +1145,9 @@ class JournalEntry(AccountsController): def set_print_format_fields(self): bank_amount = party_amount = total_amount = 0.0 - currency = bank_account_currency = party_account_currency = pay_to_recd_from = None + currency = ( + bank_account_currency + ) = party_account_currency = pay_to_recd_from = self.pay_to_recd_from = None party_type = None for d in self.get("accounts"): if d.party_type in ["Customer", "Supplier"] and d.party: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index a9e890c947c..8a0bd527e1c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -46,7 +46,9 @@ from erpnext.accounts.party import ( from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, get_account_currency, + get_advance_payment_doctypes, get_outstanding_invoices, + get_reconciliation_effect_date, ) from erpnext.controllers.accounts_controller import ( AccountsController, @@ -639,7 +641,7 @@ class PaymentEntry(AccountsController): def validate_mandatory(self): for field in ("paid_amount", "received_amount", "source_exchange_rate", "target_exchange_rate"): if not self.get(field): - frappe.throw(_("{0} is mandatory").format(self.meta.get_label(field))) + frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label(field)))) def validate_reference_documents(self): valid_reference_doctypes = self.get_valid_reference_doctypes() @@ -1099,10 +1101,7 @@ class PaymentEntry(AccountsController): def calculate_base_allocated_amount_for_reference(self, d) -> float: base_allocated_amount = 0 - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) - if d.reference_doctype in advance_payment_doctypes: + if d.reference_doctype in get_advance_payment_doctypes(): # When referencing Sales/Purchase Order, use the source/target exchange rate depending on payment type. # This is so there are no Exchange Gain/Loss generated for such doctypes @@ -1384,10 +1383,7 @@ class PaymentEntry(AccountsController): if not self.party_account: return - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) - + advance_payment_doctypes = get_advance_payment_doctypes() if self.payment_type == "Receive": against_account = self.paid_to else: @@ -1570,23 +1566,7 @@ class PaymentEntry(AccountsController): else: # For backwards compatibility # Supporting reposting on payment entries reconciled before select field introduction - reconciliation_takes_effect_on = frappe.get_cached_value( - "Company", self.company, "reconciliation_takes_effect_on" - ) - if reconciliation_takes_effect_on == "Advance Payment Date": - posting_date = self.posting_date - elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance": - date_field = "posting_date" - if invoice.reference_doctype in ["Sales Order", "Purchase Order"]: - date_field = "transaction_date" - posting_date = frappe.db.get_value( - invoice.reference_doctype, invoice.reference_name, date_field - ) - - if getdate(posting_date) < getdate(self.posting_date): - posting_date = self.posting_date - elif reconciliation_takes_effect_on == "Reconciliation Date": - posting_date = nowdate() + posting_date = get_reconciliation_effect_date(invoice, self.company, self.posting_date) frappe.db.set_value("Payment Entry Reference", invoice.name, "reconcile_effect_on", posting_date) dr_or_cr, account = self.get_dr_and_account_for_advances(invoice) @@ -1780,9 +1760,7 @@ class PaymentEntry(AccountsController): if self.payment_type not in ("Receive", "Pay") or not self.party: return - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) + advance_payment_doctypes = get_advance_payment_doctypes() for d in self.get("references"): if d.allocated_amount and d.reference_doctype in advance_payment_doctypes: frappe.get_lazy_doc( diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 2719d11e050..4391b0550d9 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -589,7 +589,7 @@ class PaymentReconciliation(Document): def check_mandatory_to_fetch(self): for fieldname in ["company", "party_type", "party", "receivable_payable_account"]: if not self.get(fieldname): - frappe.throw(_("Please select {0} first").format(self.meta.get_label(fieldname))) + frappe.throw(_("Please select {0} first").format(_(self.meta.get_label(fieldname)))) def validate_entries(self): if not self.get("invoices"): @@ -826,7 +826,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None): create_gain_loss_journal( company, - today(), + inv.difference_posting_date, inv.party_type, inv.party, inv.account, diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e82cbdc8be1..db0992014a7 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -16,7 +16,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import ( ) from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate from erpnext.accounts.party import get_party_account, get_party_bank_account -from erpnext.accounts.utils import get_account_currency, get_currency_precision +from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision from erpnext.utilities import payment_app_import_guard ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST = [ @@ -464,10 +464,7 @@ class PaymentRequest(Document): return create_stripe_subscription(gateway_controller, data) def update_reference_advance_payment_status(self): - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) - if self.reference_doctype in advance_payment_doctypes: + if self.reference_doctype in get_advance_payment_doctypes(): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) ref_doc.set_advance_payment_status() @@ -826,8 +823,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False): if not references: return - precision = references[0].precision("allocated_amount") - + precision = frappe.get_precision("Payment Entry Reference", "allocated_amount") referenced_payment_requests = frappe.get_all( "Payment Request", filters={"name": ["in", {row.payment_request for row in references if row.payment_request}]}, diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index cd50a4efc50..fe12c100940 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -813,3 +813,33 @@ class TestPaymentRequest(IntegrationTestCase): pi.load_from_db() pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1) self.assertEqual(pr.grand_total, pi.outstanding_amount) + + def test_payment_request_on_unreconcile(self): + pi = make_purchase_invoice(currency="INR", qty=1, rate=500) + pi.submit() + + pr = make_payment_request( + dt=pi.doctype, + dn=pi.name, + mute_email=1, + submit_doc=True, + return_doc=True, + ) + self.assertEqual(pr.grand_total, pi.outstanding_amount) + + pe = pr.create_payment_entry() + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payment", + "company": pe.company, + "voucher_type": pe.doctype, + "voucher_no": pe.name, + } + ) + unreconcile.add_references() + unreconcile.submit() + + pi.load_from_db() + pr.load_from_db() + + self.assertEqual(pr.grand_total, pi.outstanding_amount) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 3a38432ad53..17024e249c1 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -66,6 +66,13 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex if (doc.docstatus == 1 && !doc.is_return) { this.frm.add_custom_button(__("Return"), this.make_sales_return.bind(this), __("Create")); + if (["Partly Paid", "Overdue", "Unpaid"].includes(doc.status)) { + this.frm.add_custom_button( + __("Payment"), + this.collect_outstanding_payment.bind(this), + __("Create") + ); + } this.frm.page.set_inner_btn_group_as_primary(__("Create")); } @@ -210,6 +217,138 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex frm: this.frm, }); } + + async collect_outstanding_payment() { + const total_amount = flt(this.frm.doc.rounded_total) | flt(this.frm.doc.grand_total); + const paid_amount = flt(this.frm.doc.paid_amount); + const outstanding_amount = flt(this.frm.doc.outstanding_amount); + const me = this; + + const table_fields = [ + { + fieldname: "mode_of_payment", + fieldtype: "Link", + in_list_view: 1, + label: __("Mode of Payment"), + options: "Mode of Payment", + reqd: 1, + }, + { + fieldname: "amount", + fieldtype: "Currency", + in_list_view: 1, + label: __("Amount"), + options: this.frm.doc.currency, + reqd: 1, + onchange: function () { + dialog.fields_dict.payments.df.data.some((d) => { + if (d.idx == this.doc.idx) { + d.amount = this.value === null ? 0 : this.value; + dialog.fields_dict.payments.grid.refresh(); + return true; + } + }); + + let amount = 0; + for (let d of dialog.fields_dict.payments.df.data) { + amount += d.amount; + } + + let change_amount = total_amount - (paid_amount + amount); + + dialog.fields_dict.outstanding_amount.set_value( + outstanding_amount - amount < 0 ? 0 : outstanding_amount - amount + ); + dialog.fields_dict.paid_amount.set_value(paid_amount + amount); + dialog.fields_dict.change_amount.set_value(change_amount < 0 ? change_amount * -1 : 0); + }, + }, + ]; + const payment_method_data = await this.fetch_pos_payment_methods(); + const dialog = new frappe.ui.Dialog({ + title: __("Collect Outstanding Amount"), + fields: [ + { + fieldname: "payments", + fieldtype: "Table", + label: __("Payments"), + cannot_add_rows: false, + in_place_edit: true, + reqd: 1, + data: payment_method_data, + fields: table_fields, + }, + { + fieldname: "section_break_1", + fieldtype: "Section Break", + }, + { + fieldname: "outstanding_amount", + fieldtype: "Currency", + label: __("Outstanding Amount"), + read_only: 1, + default: outstanding_amount, + }, + { + fieldname: "column_break_1", + fieldtype: "Column Break", + }, + { + fieldname: "paid_amount", + fieldtype: "Currency", + label: __("Paid Amount"), + read_only: 1, + default: paid_amount, + }, + { + fieldname: "change_amount", + fieldtype: "Currency", + label: __("Change Amount"), + read_only: 1, + default: 0, + }, + ], + primary_action_label: __("Submit"), + primary_action(values) { + dialog.hide(); + me.frm.call({ + doc: me.frm.doc, + method: "update_payments", + args: { + payments: values.payments.filter((d) => d.amount != 0), + }, + freeze: true, + callback: function (r) { + if (!r.exc) { + frappe.show_alert({ + message: __("Payments updated."), + indicator: "green", + }); + me.frm.reload_doc(); + } else { + frappe.show_alert({ + message: __("Payments could not be updated."), + indicator: "red", + }); + } + }, + }); + }, + }); + dialog.show(); + } + + async fetch_pos_payment_methods() { + const pos_profile = this.frm.doc.pos_profile; + if (!pos_profile) return; + const pos_profile_doc = await frappe.db.get_doc("POS Profile", pos_profile); + const data = []; + pos_profile_doc.payments.forEach((pay) => { + const { mode_of_payment } = pay; + data.push({ mode_of_payment, amount: 0 }); + }); + return data; + } }; extend_cscript(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm })); diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 684b0b0ff49..5750d51fdff 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -1330,7 +1330,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "\nDraft\nReturn\nCredit Note Issued\nConsolidated\nSubmitted\nPaid\nUnpaid\nUnpaid and Discounted\nOverdue and Discounted\nOverdue\nCancelled", + "options": "\nDraft\nReturn\nCredit Note Issued\nConsolidated\nSubmitted\nPaid\nPartly Paid\nUnpaid\nPartly Paid and Discounted\nUnpaid and Discounted\nOverdue and Discounted\nOverdue\nCancelled", "print_hide": 1, "read_only": 1 }, @@ -1573,7 +1573,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2025-01-06 15:03:19.957277", + "modified": "2025-06-24 12:13:28.242649", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", @@ -1618,6 +1618,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": "creation", diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 195bda08151..f8516d6932d 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -149,7 +149,9 @@ class POSInvoice(SalesInvoice): "Consolidated", "Submitted", "Paid", + "Partly Paid", "Unpaid", + "Partly Paid and Discounted", "Unpaid and Discounted", "Overdue and Discounted", "Overdue", @@ -220,6 +222,9 @@ class POSInvoice(SalesInvoice): validate_coupon_code(self.coupon_code) + def before_submit(self): + self.set_outstanding_amount() + def on_submit(self): # create the loyalty point ledger entry if the customer is enrolled in any loyalty program if not self.is_return and self.loyalty_program: @@ -525,6 +530,10 @@ class POSInvoice(SalesInvoice): ) ) + def set_outstanding_amount(self): + total = flt(self.rounded_total) or flt(self.grand_total) + self.outstanding_amount = total - flt(self.paid_amount) if total > flt(self.paid_amount) else 0 + def validate_loyalty_transaction(self): if self.redeem_loyalty_points and ( not self.loyalty_redemption_account or not self.loyalty_redemption_cost_center @@ -546,6 +555,8 @@ class POSInvoice(SalesInvoice): self.status = "Draft" return + total = flt(self.rounded_total) or flt(self.grand_total) + if not status: if self.docstatus == 2: status = "Cancelled" @@ -561,6 +572,14 @@ class POSInvoice(SalesInvoice): self.status = "Overdue and Discounted" elif flt(self.outstanding_amount) > 0 and getdate(self.due_date) < getdate(nowdate()): self.status = "Overdue" + elif ( + 0 < flt(self.outstanding_amount) < total + and self.is_discounted + and self.get_discounting_status() == "Disbursed" + ): + self.status = "Partly Paid and Discounted" + elif 0 < flt(self.outstanding_amount) < total: + self.status = "Partly Paid" elif ( flt(self.outstanding_amount) > 0 and getdate(self.due_date) >= getdate(nowdate()) @@ -781,6 +800,48 @@ class POSInvoice(SalesInvoice): if pr: return frappe.get_doc("Payment Request", pr) + @frappe.whitelist() + def update_payments(self, payments): + if self.status == "Consolidated": + frappe.throw(_("Create Payment Entry for Consolidated POS Invoices.")) + + paid_amount = flt(self.paid_amount) + total = flt(self.rounded_total) or flt(self.grand_total) + + if paid_amount >= total: + frappe.throw(title=_("Invoice Paid"), msg=_("This invoice has already been paid.")) + + idx = self.payments[-1].idx if self.payments else -1 + + for d in payments: + idx += 1 + payment = create_payments_on_invoice(self, idx, frappe._dict(d)) + paid_amount += flt(payment.amount) + payment.submit() + + paid_amount = flt(flt(paid_amount), self.precision("paid_amount")) + base_paid_amount = flt(flt(paid_amount * self.conversion_rate), self.precision("base_paid_amount")) + outstanding_amount = ( + flt(flt(total - paid_amount), self.precision("outstanding_amount")) if total > paid_amount else 0 + ) + change_amount = ( + flt(flt(paid_amount - total), self.precision("change_amount")) if paid_amount > total else 0 + ) + + pi = frappe.qb.DocType("POS Invoice") + query = ( + frappe.qb.update(pi) + .set(pi.paid_amount, paid_amount) + .set(pi.base_paid_amount, base_paid_amount) + .set(pi.outstanding_amount, outstanding_amount) + .set(pi.change_amount, change_amount) + .where(pi.name == self.name) + ) + query.run() + self.reload() + + self.set_status(update=True) + @frappe.whitelist() def get_stock_availability(item_code, warehouse): @@ -932,3 +993,19 @@ def get_item_group(pos_profile): item_groups.extend(get_descendants_of("Item Group", row.item_group)) return list(set(item_groups)) + + +def create_payments_on_invoice(doc, idx, payment_details): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account + + payment = frappe.new_doc("Sales Invoice Payment") + payment.idx = idx + payment.mode_of_payment = payment_details.mode_of_payment + payment.amount = payment_details.amount + payment.base_amount = payment.amount * doc.conversion_rate + payment.parent = doc.name + payment.parentfield = "payments" + payment.parenttype = doc.doctype + payment.account = get_bank_cash_account(payment.mode_of_payment, doc.company).get("account") + + return payment diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice_list.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice_list.js index 0379932bb7a..3778e3dc0a2 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice_list.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice_list.js @@ -18,11 +18,13 @@ frappe.listview_settings["POS Invoice"] = { Draft: "red", Unpaid: "orange", Paid: "green", + "Partly Paid": "yellow", Submitted: "blue", Consolidated: "green", Return: "darkgrey", "Unpaid and Discounted": "orange", "Overdue and Discounted": "red", + "Partly Paid and Discounted": "yellow", Overdue: "red", }; return [__(doc.status), status_color[doc.status], "status,=," + doc.status]; diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 73cb6634b91..b9c479b012c 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -401,6 +401,50 @@ class TestPOSInvoice(IntegrationTestCase): pos_inv.insert() self.assertRaises(PartialPaymentValidationError, pos_inv.submit) + def test_partly_paid_invoices(self): + set_allow_partial_payment(self.pos_profile, 1) + + pos_inv = create_pos_invoice(pos_profile=self.pos_profile.name, rate=100, do_not_save=1) + pos_inv.append( + "payments", + {"mode_of_payment": "Cash", "amount": 90}, + ) + pos_inv.save() + pos_inv.submit() + + self.assertEqual(pos_inv.paid_amount, 90) + self.assertEqual(pos_inv.status, "Partly Paid") + + pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 10}]) + self.assertEqual(pos_inv.paid_amount, 100) + self.assertEqual(pos_inv.status, "Paid") + + set_allow_partial_payment(self.pos_profile, 0) + + def test_multi_payment_for_partly_paid_invoices(self): + set_allow_partial_payment(self.pos_profile, 1) + + pos_inv = create_pos_invoice(pos_profile=self.pos_profile.name, rate=100, do_not_save=1) + pos_inv.append( + "payments", + {"mode_of_payment": "Cash", "amount": 90}, + ) + pos_inv.save() + pos_inv.submit() + + self.assertEqual(pos_inv.paid_amount, 90) + self.assertEqual(pos_inv.status, "Partly Paid") + + pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 5}]) + self.assertEqual(pos_inv.paid_amount, 95) + self.assertEqual(pos_inv.status, "Partly Paid") + + pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 5}]) + self.assertEqual(pos_inv.paid_amount, 100) + self.assertEqual(pos_inv.status, "Paid") + + set_allow_partial_payment(self.pos_profile, 0) + def test_serialized_item_transaction(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item @@ -1094,3 +1138,9 @@ def make_batch_item(item_name): if not frappe.db.exists(item_name): return make_item(item_name, dict(has_batch_no=1, create_new_batch=1, is_stock_item=1)) + + +def set_allow_partial_payment(pos_profile, value): + pos_profile.reload() + pos_profile.allow_partial_payment = value + pos_profile.save() diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json index 2f94e341eb4..7d9b90f1b65 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json @@ -5,6 +5,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "company", "posting_date", "posting_time", "merge_invoices_based_on", @@ -113,12 +114,22 @@ "label": "Posting Time", "no_copy": 1, "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "print_hide": 1, + "remember_last_selected_value": 1, + "reqd": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:10:15.620564", + "modified": "2025-07-02 17:08:04.747202", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Merge Log", @@ -179,8 +190,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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 818a689681c..74c6845755e 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 @@ -29,11 +29,10 @@ class POSInvoiceMergeLog(Document): if TYPE_CHECKING: from frappe.types import DF - from erpnext.accounts.doctype.pos_invoice_reference.pos_invoice_reference import ( - POSInvoiceReference, - ) + from erpnext.accounts.doctype.pos_invoice_reference.pos_invoice_reference import POSInvoiceReference amended_from: DF.Link | None + company: DF.Link consolidated_credit_note: DF.Link | None consolidated_invoice: DF.Link | None customer: DF.Link @@ -584,6 +583,7 @@ def create_merge_logs(invoice_by_customer, closing_entry=None): merge_log.posting_time = ( get_time(closing_entry.get("posting_time")) if closing_entry else nowtime() ) + merge_log.company = closing_entry.get("company") if closing_entry else None merge_log.customer = customer merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None merge_log.set("pos_invoices", _invoices) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index a7618377291..3c5c0c2abeb 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -491,3 +491,26 @@ class TestPOSInvoiceMergeLog(IntegrationTestCase): self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice)) self.assertTrue(pos_inv2.consolidated_invoice == pos_inv3.consolidated_invoice) + + def test_company_in_pos_invoice_merge_log(self): + """ + Test if the company is fetched from POS Closing Entry + """ + test_user, pos_profile = init_user_and_profile() + opening_entry = create_opening_entry(pos_profile, test_user.name) + + pos_inv = create_pos_invoice(rate=300, do_not_submit=1) + pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300}) + pos_inv.save() + pos_inv.submit() + + closing_entry = make_closing_entry_from_opening(opening_entry) + closing_entry.insert() + closing_entry.submit() + + self.assertTrue(frappe.db.exists("POS Invoice Merge Log", {"pos_closing_entry": closing_entry.name})) + + pos_merge_log_company = frappe.db.get_value( + "POS Invoice Merge Log", {"pos_closing_entry": closing_entry.name}, "company" + ) + self.assertEqual(pos_merge_log_company, closing_entry.company) diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 4e37791e078..c0e5c895403 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -26,13 +26,14 @@ "auto_add_item_to_cart", "validate_stock_on_save", "print_receipt_on_order_complete", + "action_on_new_invoice", "column_break_16", "update_stock", "ignore_pricing_rule", "allow_rate_change", "allow_discount_change", "set_grand_total_to_default_mop", - "action_on_new_invoice", + "allow_partial_payment", "section_break_23", "item_groups", "column_break_25", @@ -423,6 +424,12 @@ "fieldtype": "Select", "label": "Action on New Invoice", "options": "Always Ask\nSave Changes and Load New Invoice\nDiscard Changes and Load New Invoice" + }, + { + "default": "0", + "fieldname": "allow_partial_payment", + "fieldtype": "Check", + "label": "Allow Partial Payment" } ], "grid_page_length": 50, @@ -451,7 +458,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2025-05-23 12:12:32.247652", + "modified": "2025-06-24 11:19:19.834905", "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 e3e5c84d3d9..6f96137274d 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -32,6 +32,7 @@ class POSProfile(Document): "Always Ask", "Save Changes and Load New Invoice", "Discard Changes and Load New Invoice" ] allow_discount_change: DF.Check + allow_partial_payment: DF.Check allow_rate_change: DF.Check applicable_for_users: DF.Table[POSProfileUser] apply_discount_on: DF.Literal["Grand Total", "Net Total"] diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index a45f6276ff8..398149968b9 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -169,7 +169,7 @@ class PricingRule(Document): tocheck = frappe.scrub(self.get("applicable_for", "")) if tocheck and not self.get(tocheck): - throw(_("{0} is required").format(self.meta.get_label(tocheck)), frappe.MandatoryError) + throw(_("{0} is required").format(_(self.meta.get_label(tocheck))), frappe.MandatoryError) if self.apply_rule_on_other: o_field = "other_" + frappe.scrub(self.apply_rule_on_other) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index caf8ac78e80..aa4cd12afc2 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -206,6 +206,56 @@ class TestPricingRule(IntegrationTestCase): details = get_item_details(args) self.assertEqual(details.get("discount_percentage"), 10) + def test_unset_group_condition(self): + """ + If args are not set for group condition, then pricing rule should not be applied. + """ + from erpnext.stock.get_item_details import get_item_details + + test_record = { + "doctype": "Pricing Rule", + "title": "_Test Pricing Rule", + "apply_on": "Item Code", + "items": [{"item_code": "_Test Item"}], + "currency": "USD", + "selling": 1, + "rate_or_discount": "Discount Percentage", + "rate": 0, + "discount_percentage": 10, + "applicable_for": "Territory", + "territory": "All Territories", + "company": "_Test Company", + } + frappe.get_doc(test_record.copy()).insert() + args = frappe._dict( + { + "item_code": "_Test Item", + "company": "_Test Company", + "price_list": "_Test Price List", + "currency": "_Test Currency", + "doctype": "Sales Order", + "conversion_rate": 1, + "price_list_currency": "_Test Currency", + "plc_conversion_rate": 1, + "order_type": "Sales", + "customer": "_Test Customer", + "name": None, + } + ) + + # without territory in customer + customer = frappe.get_doc("Customer", "_Test Customer") + territory = customer.territory + + customer.territory = None + customer.save() + + details = get_item_details(args) + self.assertEqual(details.get("discount_percentage"), 0) + + customer.territory = territory + customer.save() + def test_pricing_rule_for_variants(self): from erpnext.stock.get_item_details import get_item_details diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index ff1ccfd352a..af970401263 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -223,6 +223,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True): ) frappe.flags.tree_conditions[key] = condition + + elif allow_blank: + condition = f"ifnull({table}.{field}, '') = ''" + return condition diff --git a/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py b/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py index 2764ec5c951..97fc47b01b9 100644 --- a/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py +++ b/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py @@ -40,6 +40,13 @@ class TestProcessDeferredAccounting(IntegrationTestCase): si.save() si.submit() + original_gle = [ + ["Debtors - _TC", 3000.0, 0, "2023-07-01"], + [deferred_account, 0.0, 3000, "2023-07-01"], + ] + + check_gl_entries(self, si.name, original_gle, "2023-07-01") + process_deferred_accounting = frappe.get_doc( dict( doctype="Process Deferred Accounting", @@ -63,6 +70,12 @@ class TestProcessDeferredAccounting(IntegrationTestCase): ] check_gl_entries(self, si.name, expected_gle, "2023-07-01") + + # cancel the process deferred accounting document + process_deferred_accounting.cancel() + + # check if gl entries are cancelled + check_gl_entries(self, si.name, original_gle, "2023-07-01") change_acc_settings() def test_pda_submission_and_cancellation(self): diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js index 40534059711..57d0c59329c 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js @@ -54,6 +54,9 @@ frappe.ui.form.on("Process Statement Of Accounts", { }; }); frm.set_query("account", function () { + if (!frm.doc.company) { + frappe.throw(__("Please set Company")); + } return { filters: { company: frm.doc.company, @@ -61,6 +64,9 @@ frappe.ui.form.on("Process Statement Of Accounts", { }; }); frm.set_query("cost_center", function () { + if (!frm.doc.company) { + frappe.throw(__("Please set Company")); + } return { filters: { company: frm.doc.company, @@ -68,6 +74,9 @@ frappe.ui.form.on("Process Statement Of Accounts", { }; }); frm.set_query("project", function () { + if (!frm.doc.company) { + frappe.throw(__("Please set Company")); + } return { filters: { company: frm.doc.company, @@ -79,6 +88,11 @@ frappe.ui.form.on("Process Statement Of Accounts", { frm.set_value("to_date", frappe.datetime.get_today()); } }, + company: function (frm) { + frm.set_value("account", ""); + frm.set_value("cost_center", ""); + frm.set_value("project", ""); + }, report: function (frm) { let filters = { company: frm.doc.company, diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json index 5b4f75c25fe..ee6bb4ebd17 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json @@ -376,7 +376,7 @@ "default": "0", "fieldname": "ignore_exchange_rate_revaluation_journals", "fieldtype": "Check", - "label": "Ignore Exchange Rate Revaluation Journals" + "label": "Ignore Exchange Rate Revaluation and Gain / Loss Journals" }, { "default": "0", @@ -400,7 +400,7 @@ } ], "links": [], - "modified": "2025-04-30 14:43:23.643006", + "modified": "2025-07-08 16:52:12.602384", "modified_by": "Administrator", "module": "Accounts", "name": "Process Statement Of Accounts", diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 714ed623796..bbf59aa7f02 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -82,6 +82,10 @@ class ProcessStatementOfAccounts(Document): # end: auto-generated types def validate(self): + self.validate_account() + self.validate_company_for_table("Cost Center") + self.validate_company_for_table("Project") + if not self.subject: self.subject = "Statement Of Accounts for {{ customer.customer_name }}" if not self.body: @@ -104,6 +108,43 @@ class ProcessStatementOfAccounts(Document): self.to_date = self.start_date self.from_date = add_months(self.to_date, -1 * self.filter_duration) + def validate_account(self): + if not self.account: + return + + if self.company != frappe.get_cached_value("Account", self.account, "company"): + frappe.throw( + _("Account {0} doesn't belong to Company {1}").format( + frappe.bold(self.account), + frappe.bold(self.company), + ) + ) + + def validate_company_for_table(self, doctype): + field = frappe.scrub(doctype) + if not self.get(field): + return + + fieldname = field + "_name" + + values = set(d.get(fieldname) for d in self.get(field)) + invalid_values = frappe.db.get_all( + doctype, filters={"name": ["in", values], "company": ["!=", self.company]}, pluck="name" + ) + + if invalid_values: + msg = _("

Following {0}s doesn't belong to Company {1} :

").format( + doctype, frappe.bold(self.company) + ) + + msg += ( + "" + ) + + frappe.throw(_(msg)) + def get_report_pdf(doc, consolidated=True): statement_dict = get_statement_dict(doc) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 9336279cfe0..34e48b31c08 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1338,17 +1338,12 @@ class PurchaseInvoice(BuyingController): warehouse_debit_amount = stock_amount - elif self.is_return and self.update_stock and self.is_internal_supplier and warehouse_debit_amount: + elif self.is_return and self.update_stock and (self.is_internal_supplier or not self.return_against): net_rate = item.base_net_amount if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate - stock_amount = ( - net_rate - + item.item_tax_amount - + flt(item.landed_cost_voucher_amount) - + flt(item.get("amount_difference_with_purchase_invoice")) - ) + stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount) if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision): cost_of_goods_sold_account = self.get_company_default("default_expense_account") diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index ef2f455f0c5..45f0fc3c418 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -1997,6 +1997,7 @@ "fieldname": "amount_eligible_for_commission", "fieldtype": "Currency", "label": "Amount Eligible for Commission", + "options": "Company:company:default_currency", "read_only": 1 }, { @@ -2231,7 +2232,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2025-03-17 19:32:31.809658", + "modified": "2025-06-26 14:06:56.773552", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 9e3c1c58aec..46e3413a656 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1104,20 +1104,19 @@ class SalesInvoice(SellingController): self.validate_pos_opening_entry() def validate_full_payment(self): + allow_partial_payment = frappe.db.get_value("POS Profile", self.pos_profile, "allow_partial_payment") invoice_total = flt(self.rounded_total) or flt(self.grand_total) - if self.docstatus == 1: - if self.is_return and self.paid_amount != invoice_total: - frappe.throw( - msg=_("Partial Payment in POS Transactions are not allowed."), - exc=PartialPaymentValidationError, - ) - - if self.paid_amount < invoice_total: - frappe.throw( - msg=_("Partial Payment in POS Transactions are not allowed."), - exc=PartialPaymentValidationError, - ) + if ( + self.docstatus == 1 + and not self.is_return + and not allow_partial_payment + and self.paid_amount < invoice_total + ): + frappe.throw( + msg=_("Partial Payment in POS Transactions are not allowed."), + exc=PartialPaymentValidationError, + ) def validate_pos_opening_entry(self): opening_entries = frappe.get_all( diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 49f27d4de73..d8931694dde 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2483,6 +2483,10 @@ class TestSalesInvoice(ERPNextTestSuite): for gle in gl_entries: self.assertEqual(expected_values[gle.account]["cost_center"], gle.cost_center) + @IntegrationTestCase.change_settings( + "Accounts Settings", + {"book_deferred_entries_based_on": "Days", "book_deferred_entries_via_journal_entry": 0}, + ) def test_deferred_revenue(self): deferred_account = create_account( account_name="Deferred Revenue", @@ -2537,6 +2541,10 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, si.save) + @IntegrationTestCase.change_settings( + "Accounts Settings", + {"book_deferred_entries_based_on": "Months", "book_deferred_entries_via_journal_entry": 0}, + ) def test_fixed_deferred_revenue(self): deferred_account = create_account( account_name="Deferred Revenue", @@ -2544,10 +2552,6 @@ class TestSalesInvoice(ERPNextTestSuite): company="_Test Company", ) - acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") - acc_settings.book_deferred_entries_based_on = "Months" - acc_settings.save() - item = create_item("_Test Item for Deferred Accounting") item.enable_deferred_revenue = 1 item.deferred_revenue_account = deferred_account @@ -2587,10 +2591,6 @@ class TestSalesInvoice(ERPNextTestSuite): check_gl_entries(self, si.name, expected_gle, "2019-01-30") - acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") - acc_settings.book_deferred_entries_based_on = "Days" - acc_settings.save() - def test_validate_inter_company_transaction_address_links(self): def _validate_address_link(address, link_doctype, link_name): return frappe.db.get_value( @@ -2833,7 +2833,9 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(si.items[0].rate, rate) self.assertEqual(target_doc.items[0].rate, rate) - check_gl_entries(self, target_doc.name, pi_gl_entries, add_days(nowdate(), -1)) + check_gl_entries( + self, target_doc.name, pi_gl_entries, add_days(nowdate(), -1), voucher_type="Purchase Invoice" + ) def test_internal_transfer_gl_precision_issues(self): # Make a stock queue of an item with two valuations @@ -4561,6 +4563,8 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type=" ) gl_entries = q.run(as_dict=True) + doc.assertGreater(len(gl_entries), 0) + for i, gle in enumerate(gl_entries): doc.assertEqual(expected_gle[i][0], gle.account) doc.assertEqual(expected_gle[i][1], gle.debit) diff --git a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py index aa5e6d323b3..e57b90f11f7 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py @@ -12,6 +12,7 @@ from frappe.utils.data import comma_and from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, + get_advance_payment_doctypes, unlink_ref_doc_from_payment_entries, update_voucher_outstanding, ) @@ -84,9 +85,7 @@ class UnreconcilePayment(Document): update_voucher_outstanding( alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party ) - if doc.doctype in frappe.get_hooks("advance_payment_payable_doctypes") + frappe.get_hooks( - "advance_payment_receivable_doctypes" - ): + if doc.doctype in get_advance_payment_doctypes(): doc.set_total_advance_paid() frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index b1eeab9e6f8..8c7ec2795e0 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -703,7 +703,18 @@ def make_reverse_gl_entries( query.run() else: if not immutable_ledger_enabled: - set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) + gle_names = [x.get("name") for x in gl_entries] + + # if names are available, cancel only that set of entries + if not all(gle_names): + set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) + else: + frappe.db.sql( + """UPDATE `tabGL Entry` SET is_cancelled = 1, + modified=%s, modified_by=%s + where name in %s and is_cancelled = 0""", + (now(), frappe.session.user, tuple(gle_names)), + ) for entry in gl_entries: new_gle = copy.deepcopy(entry) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index d047782c44f..e191b0ad790 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -6,7 +6,7 @@ from collections import OrderedDict import frappe from frappe import _, qb, query_builder, scrub -from frappe.desk.reportview import build_match_conditions +from frappe.database.schema import get_definition from frappe.query_builder import Criterion from frappe.query_builder.functions import Date, Substring, Sum from frappe.utils import cint, cstr, flt, getdate, nowdate @@ -15,7 +15,12 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, get_dimension_with_children, ) -from erpnext.accounts.utils import get_currency_precision, get_party_types_from_account_type +from erpnext.accounts.utils import ( + build_qb_match_conditions, + get_advance_payment_doctypes, + get_currency_precision, + get_party_types_from_account_type, +) # This report gives a summary of all Outstanding Invoices considering the following @@ -62,6 +67,9 @@ class ReceivablePayableReport: frappe.get_single_value("Accounts Settings", "receivable_payable_fetch_method") or "Buffered Cursor" ) # Fail Safe + self.advance_payment_doctypes = frappe.get_hooks( + "advance_payment_receivable_doctypes" + ) + frappe.get_hooks("advance_payment_payable_doctypes") def run(self, args): self.filters.update(args) @@ -85,6 +93,7 @@ class ReceivablePayableReport: self.party_details = {} self.invoices = set() self.skip_total_row = 0 + self.advance_payment_doctypes = get_advance_payment_doctypes() if self.filters.get("group_by_party"): self.previous_party = "" @@ -121,6 +130,8 @@ class ReceivablePayableReport: self.fetch_ple_in_buffered_cursor() elif self.ple_fetch_method == "UnBuffered Cursor": self.fetch_ple_in_unbuffered_cursor() + elif self.ple_fetch_method == "Raw SQL": + self.fetch_ple_in_sql_procedures() # Build delivery note map against all sales invoices self.build_delivery_note_map() @@ -128,8 +139,7 @@ class ReceivablePayableReport: self.build_data() def fetch_ple_in_buffered_cursor(self): - query, param = self.ple_query - self.ple_entries = frappe.db.sql(query, param, as_dict=True) + self.ple_entries = self.ple_query.run(as_dict=True) for ple in self.ple_entries: self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding @@ -142,9 +152,8 @@ class ReceivablePayableReport: def fetch_ple_in_unbuffered_cursor(self): self.ple_entries = [] - query, param = self.ple_query with frappe.db.unbuffered_cursor(): - for ple in frappe.db.sql(query, param, as_dict=True, as_iterator=True): + for ple in self.ple_query.run(as_dict=True, as_iterator=True): self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding self.ple_entries.append(ple) @@ -181,7 +190,10 @@ class ReceivablePayableReport: if key not in self.voucher_balance: self.voucher_balance[key] = self.build_voucher_dict(ple) - if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no: + if (ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no) or ( + ple.voucher_type in ("Payment Entry", "Journal Entry") + and ple.against_voucher_type in self.advance_payment_doctypes + ): self.voucher_balance[key].cost_center = ple.cost_center self.get_invoices(ple) @@ -309,6 +321,79 @@ class ReceivablePayableReport: row.paid -= amount row.paid_in_account_currency -= amount_in_account_currency + def fetch_ple_in_sql_procedures(self): + self.proc = InitSQLProceduresForAR() + + build_balance = f""" + begin not atomic + declare done boolean default false; + declare rec1 row type of `{self.proc._row_def_table_name}`; + declare ple cursor for {self.ple_query.get_sql()}; + declare continue handler for not found set done = true; + + open ple; + fetch ple into rec1; + while not done do + call {self.proc.init_procedure_name}(rec1); + fetch ple into rec1; + end while; + close ple; + + set done = false; + open ple; + fetch ple into rec1; + while not done do + call {self.proc.allocate_procedure_name}(rec1); + fetch ple into rec1; + end while; + close ple; + end; + """ + frappe.db.sql(build_balance) + + balances = frappe.db.sql( + f"""select + name, + voucher_type, + voucher_no, + party, + party_account `account`, + posting_date, + account_currency, + cost_center, + sum(invoiced) `invoiced`, + sum(paid) `paid`, + sum(credit_note) `credit_note`, + sum(invoiced) - sum(paid) - sum(credit_note) `outstanding`, + sum(invoiced_in_account_currency) `invoiced_in_account_currency`, + sum(paid_in_account_currency) `paid_in_account_currency`, + sum(credit_note_in_account_currency) `credit_note_in_account_currency`, + sum(invoiced_in_account_currency) - sum(paid_in_account_currency) - sum(credit_note_in_account_currency) `outstanding_in_account_currency` + from `{self.proc._voucher_balance_name}` group by name order by posting_date;""", + as_dict=True, + ) + for x in balances: + if self.filters.get("ignore_accounts"): + key = (x.voucher_type, x.voucher_no, x.party) + else: + key = (x.account, x.voucher_type, x.voucher_no, x.party) + + _d = self.build_voucher_dict(x) + for field in [ + "invoiced", + "paid", + "credit_note", + "outstanding", + "invoiced_in_account_currency", + "paid_in_account_currency", + "credit_note_in_account_currency", + "outstanding_in_account_currency", + "cost_center", + ]: + _d[field] = x.get(field) + + self.voucher_balance[key] = _d + def update_sub_total_row(self, row, party): total_row = self.total_row_map.get(party) @@ -852,18 +937,15 @@ class ReceivablePayableReport: else: query = query.select(ple.remarks) - query, param = query.walk() - - match_conditions = build_match_conditions("Payment Ledger Entry") - if match_conditions: - query += " AND " + match_conditions + if match_conditions := build_qb_match_conditions("Payment Ledger Entry"): + query = query.where(Criterion.all(match_conditions)) if self.filters.get("group_by_party"): - query += f" ORDER BY `{self.ple.party.name}`, `{self.ple.posting_date.name}`" + query = query.orderby(self.ple.party, self.ple.posting_date) else: - query += f" ORDER BY `{self.ple.posting_date.name}`, `{self.ple.party.name}`" + query = query.orderby(self.ple.posting_date, self.ple.party) - self.ple_query = (query, param) + self.ple_query = query def get_sales_invoices_or_customers_based_on_sales_person(self): if self.filters.get("sales_person"): @@ -1244,3 +1326,134 @@ def get_customer_group_with_children(customer_groups): frappe.throw(_("Customer Group: {0} does not exist").format(d)) return list(set(all_customer_groups)) + + +class InitSQLProceduresForAR: + """ + Initialize SQL Procedures, Functions and Temporary tables to build Receivable / Payable report + """ + + _varchar_type = get_definition("Data") + _currency_type = get_definition("Currency") + # Temporary Tables + _voucher_balance_name = "_ar_voucher_balance" + _voucher_balance_definition = f""" + create temporary table `{_voucher_balance_name}`( + name {_varchar_type}, + voucher_type {_varchar_type}, + voucher_no {_varchar_type}, + party {_varchar_type}, + party_account {_varchar_type}, + posting_date date, + account_currency {_varchar_type}, + cost_center {_varchar_type}, + invoiced {_currency_type}, + paid {_currency_type}, + credit_note {_currency_type}, + invoiced_in_account_currency {_currency_type}, + paid_in_account_currency {_currency_type}, + credit_note_in_account_currency {_currency_type}) engine=memory; + """ + + _row_def_table_name = "_ar_ple_row" + _row_def_table_definition = f""" + create temporary table `{_row_def_table_name}`( + name {_varchar_type}, + account {_varchar_type}, + voucher_type {_varchar_type}, + voucher_no {_varchar_type}, + against_voucher_type {_varchar_type}, + against_voucher_no {_varchar_type}, + party_type {_varchar_type}, + cost_center {_varchar_type}, + party {_varchar_type}, + posting_date date, + due_date date, + account_currency {_varchar_type}, + amount {_currency_type}, + amount_in_account_currency {_currency_type}) engine=memory; + """ + + # Function + genkey_function_name = "ar_genkey" + genkey_function_sql = f""" + create function `{genkey_function_name}`(rec row type of `{_row_def_table_name}`, allocate bool) returns char(40) + begin + if allocate then + return sha1(concat_ws(',', rec.account, rec.against_voucher_type, rec.against_voucher_no, rec.party)); + else + return sha1(concat_ws(',', rec.account, rec.voucher_type, rec.voucher_no, rec.party)); + end if; + end + """ + + # Procedures + init_procedure_name = "ar_init_tmp_table" + init_procedure_sql = f""" + create procedure ar_init_tmp_table(in ple row type of `{_row_def_table_name}`) + begin + if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false)) + then + insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0); + end if; + end; + """ + + allocate_procedure_name = "ar_allocate_to_tmp_table" + allocate_procedure_sql = f""" + create procedure ar_allocate_to_tmp_table(in ple row type of `{_row_def_table_name}`) + begin + declare invoiced {_currency_type} default 0; + declare invoiced_in_account_currency {_currency_type} default 0; + declare paid {_currency_type} default 0; + declare paid_in_account_currency {_currency_type} default 0; + declare credit_note {_currency_type} default 0; + declare credit_note_in_account_currency {_currency_type} default 0; + + + if ple.amount > 0 then + if (ple.voucher_type in ("Journal Entry", "Payment Entry") and (ple.voucher_no != ple.against_voucher_no)) then + set paid = -1 * ple.amount; + set paid_in_account_currency = -1 * ple.amount_in_account_currency; + else + set invoiced = ple.amount; + set invoiced_in_account_currency = ple.amount_in_account_currency; + end if; + else + + if ple.voucher_type in ("Sales Invoice", "Purchase Invoice") then + if (ple.voucher_no = ple.against_voucher_no) then + set paid = -1 * ple.amount; + set paid_in_account_currency = -1 * ple.amount_in_account_currency; + else + set credit_note = -1 * ple.amount; + set credit_note_in_account_currency = -1 * ple.amount_in_account_currency; + end if; + else + set paid = -1 * ple.amount; + set paid_in_account_currency = -1 * ple.amount_in_account_currency; + end if; + + end if; + + insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); + end; + """ + + def __init__(self): + existing_procedures = frappe.db.get_routines() + + if self.genkey_function_name not in existing_procedures: + frappe.db.sql(self.genkey_function_sql) + + if self.init_procedure_name not in existing_procedures: + frappe.db.sql(self.init_procedure_sql) + + if self.allocate_procedure_name not in existing_procedures: + frappe.db.sql(self.allocate_procedure_sql) + + frappe.db.sql(f"drop table if exists `{self._voucher_balance_name}`") + frappe.db.sql(self._voucher_balance_definition) + + frappe.db.sql(f"drop table if exists `{self._row_def_table_name}`") + frappe.db.sql(self._row_def_table_definition) diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py index 561034db0c2..caa464c5447 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py @@ -79,6 +79,14 @@ class Deferred_Item: return - estimated amount to post for given period Calculated based on already booked amount and item service period """ + if self.filters.book_deferred_entries_based_on == "Months": + # if the deferred entries are based on service period, use service start and end date + return self.calculate_monthly_amount(start_date, end_date) + + else: + return self.calculate_days_amount(start_date, end_date) + + def calculate_monthly_amount(self, start_date, end_date): total_months = ( (self.service_end_date.year - self.service_start_date.year) * 12 + (self.service_end_date.month - self.service_start_date.month) @@ -105,6 +113,19 @@ class Deferred_Item: return base_amount + def calculate_days_amount(self, start_date, end_date): + base_amount = 0 + total_days = date_diff(self.service_end_date, self.service_start_date) + 1 + total_booking_days = date_diff(end_date, start_date) + 1 + already_booked_amount = self.get_item_total() + + base_amount = flt(self.base_net_amount * total_booking_days / flt(total_days)) + + if base_amount + already_booked_amount > self.base_net_amount: + base_amount = self.base_net_amount - already_booked_amount + + return base_amount + def make_dummy_gle(self, name, date, amount): """ return - frappe._dict() of a dummy gle entry @@ -245,6 +266,10 @@ class Deferred_Revenue_and_Expense_Report: else: self.filters = frappe._dict(filters) + self.filters.book_deferred_entries_based_on = frappe.db.get_singles_value( + "Accounts Settings", "book_deferred_entries_based_on" + ) + self.period_list = None self.deferred_invoices = [] # holds period wise total for report @@ -289,7 +314,11 @@ class Deferred_Revenue_and_Expense_Report: .join(inv) .on(inv.name == inv_item.parent) .left_join(gle) - .on((inv_item.name == gle.voucher_detail_no) & (deferred_account_field == gle.account)) + .on( + (inv_item.name == gle.voucher_detail_no) + & (deferred_account_field == gle.account) + & (gle.is_cancelled == 0) + ) .select( inv.name.as_("doc"), inv.posting_date, diff --git a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py index db984e821a5..084ea9b80ea 100644 --- a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py +++ b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py @@ -179,7 +179,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, dimension_list): def get_condition(dimension): conditions = [] - conditions.append(f"{frappe.scrub(dimension)} in %(dimensions)s") + conditions.append(f"{frappe.scrub(dimension)} in (%(dimensions)s)") return " and {}".format(" and ".join(conditions)) if conditions else "" diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index 5b9685de3c6..23f1d48392f 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -9,7 +9,7 @@ import re import frappe from frappe import _ -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Max, Min, Sum from frappe.utils import add_days, add_months, cint, cstr, flt, formatdate, get_first_day, getdate from pypika.terms import ExistsCriterion @@ -109,14 +109,19 @@ def get_period_list( def get_fiscal_year_data(from_fiscal_year, to_fiscal_year): - fiscal_year = frappe.db.sql( - """select min(year_start_date) as year_start_date, - max(year_end_date) as year_end_date from `tabFiscal Year` where - name between %(from_fiscal_year)s and %(to_fiscal_year)s""", - {"from_fiscal_year": from_fiscal_year, "to_fiscal_year": to_fiscal_year}, - as_dict=1, + from_year_start_date = frappe.get_cached_value("Fiscal Year", from_fiscal_year, "year_start_date") + to_year_end_date = frappe.get_cached_value("Fiscal Year", to_fiscal_year, "year_end_date") + + fy = frappe.qb.DocType("Fiscal Year") + + query = ( + frappe.qb.from_(fy) + .select(Min(fy.year_start_date).as_("year_start_date"), Max(fy.year_end_date).as_("year_end_date")) + .where(fy.year_start_date >= from_year_start_date) + .where(fy.year_end_date <= to_year_end_date) ) + fiscal_year = query.run(as_dict=True) return fiscal_year[0] if fiscal_year else {} diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index e97a49012c6..bb8f0f1af7a 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -209,7 +209,7 @@ frappe.query_reports["General Ledger"] = { }, { fieldname: "ignore_err", - label: __("Ignore Exchange Rate Revaluation Journals"), + label: __("Ignore Exchange Rate Revaluation and Gain / Loss Journals"), fieldtype: "Check", }, { diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py index 9824d128a68..24280d4d620 100644 --- a/erpnext/accounts/report/general_ledger/test_general_ledger.py +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -3,15 +3,12 @@ import frappe from frappe import qb -from frappe.tests import IntegrationTestCase, change_settings +from frappe.tests import IntegrationTestCase from frappe.utils import flt, today -from erpnext.accounts.doctype.account.test_account import create_account -from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.general_ledger.general_ledger import execute from erpnext.controllers.sales_and_purchase_return import make_return_doc -from erpnext.selling.doctype.customer.test_customer import create_internal_customer class TestGeneralLedger(IntegrationTestCase): @@ -171,90 +168,6 @@ class TestGeneralLedger(IntegrationTestCase): self.assertEqual(data[3]["debit"], 100) self.assertEqual(data[3]["credit"], 100) - @change_settings("Accounts Settings", {"delete_linked_ledger_entries": True}) - def test_debit_in_exchange_gain_loss_account(self): - company = "_Test Company" - - exchange_gain_loss_account = frappe.db.get_value("Company", "exchange_gain_loss_account") - if not exchange_gain_loss_account: - frappe.db.set_value( - "Company", company, "exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC" - ) - - account_name = "_Test Receivable USD - _TC" - customer_name = "_Test Customer USD" - - sales_invoice = create_sales_invoice( - company=company, - customer=customer_name, - currency="USD", - debit_to=account_name, - conversion_rate=85, - posting_date=today(), - ) - - payment_entry = create_payment_entry( - company=company, - party_type="Customer", - party=customer_name, - payment_type="Receive", - paid_from=account_name, - paid_from_account_currency="USD", - paid_to="Cash - _TC", - paid_to_account_currency="INR", - paid_amount=10, - do_not_submit=True, - ) - payment_entry.base_paid_amount = 800 - payment_entry.received_amount = 800 - payment_entry.currency = "USD" - payment_entry.source_exchange_rate = 80 - payment_entry.append( - "references", - frappe._dict( - { - "reference_doctype": "Sales Invoice", - "reference_name": sales_invoice.name, - "total_amount": 10, - "outstanding_amount": 10, - "exchange_rate": 85, - "allocated_amount": 10, - "exchange_gain_loss": -50, - } - ), - ) - payment_entry.save() - payment_entry.submit() - - journal_entry = frappe.get_all( - "Journal Entry Account", filters={"reference_name": sales_invoice.name}, fields=["parent"] - ) - - columns, data = execute( - frappe._dict( - { - "company": company, - "from_date": today(), - "to_date": today(), - "include_dimensions": 1, - "include_default_book_entries": 1, - "account": ["_Test Exchange Gain/Loss - _TC"], - "categorize_by": "Categorize by Voucher (Consolidated)", - } - ) - ) - - entry = data[1] - self.assertEqual(entry["debit"], 50) - self.assertEqual(entry["voucher_type"], "Journal Entry") - self.assertEqual(entry["voucher_no"], journal_entry[0]["parent"]) - - payment_entry.cancel() - payment_entry.delete() - sales_invoice.reload() - sales_invoice.cancel() - sales_invoice.delete() - def test_ignore_exchange_rate_journals_filter(self): # create a new account with USD currency account_name = "Test Debtors USD" diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 52096ce365a..5a7ee976aee 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -5,13 +5,14 @@ import frappe from frappe import _ from frappe.model.meta import get_field_precision +from frappe.query_builder import functions as fn from frappe.utils import cstr, flt from frappe.utils.nestedset import get_descendants_of from frappe.utils.xlsxutils import handle_html from pypika import Order from erpnext.accounts.report.sales_register.sales_register import get_mode_of_payments -from erpnext.accounts.report.utils import get_query_columns, get_values_for_columns +from erpnext.accounts.report.utils import get_values_for_columns from erpnext.controllers.taxes_and_totals import ItemWiseTaxDetail from erpnext.selling.report.item_wise_sales_history.item_wise_sales_history import ( get_customer_details, @@ -434,7 +435,7 @@ def get_items(filters, additional_query_columns, additional_conditions=None): si.is_internal_customer, si.customer, si.remarks, - si.territory, + fn.IfNull(si.territory, "Not Specified").as_("territory"), si.company, si.base_net_total, sii.project, @@ -457,7 +458,7 @@ def get_items(filters, additional_query_columns, additional_conditions=None): sii.base_net_rate, sii.base_net_amount, si.customer_name, - si.customer_group, + fn.IfNull(si.customer_group, "Not Specified").as_("customer_group"), sii.so_detail, si.update_stock, sii.uom, diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 02dec9686c5..d8012377743 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -121,7 +121,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ ) out.append(row) - out.sort(key=lambda x: x["section_code"]) + out.sort(key=lambda x: (x["section_code"], x["transaction_date"])) return out diff --git a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py index 9e7a2cc52b0..30a5df3fcb2 100644 --- a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py @@ -67,11 +67,12 @@ class TestTaxWithholdingDetails(AccountsTestMixin, IntegrationTestCase): mid_year = add_to_date(fiscal_year[1], months=6) tds_doc = frappe.get_doc("Tax Withholding Category", "TDS - 3") tds_doc.rates[0].to_date = mid_year + from_date = add_to_date(mid_year, days=1) tds_doc.append( "rates", { "tax_withholding_rate": 20, - "from_date": add_to_date(mid_year, days=1), + "from_date": from_date, "to_date": fiscal_year[2], "single_threshold": 1, "cumulative_threshold": 1, @@ -80,18 +81,19 @@ class TestTaxWithholdingDetails(AccountsTestMixin, IntegrationTestCase): tds_doc.save() - inv_1 = make_purchase_invoice(rate=1000, do_not_submit=True) + inv_1 = make_purchase_invoice( + rate=1000, posting_date=add_to_date(fiscal_year[1], days=1), do_not_save=True, do_not_submit=True + ) + inv_1.set_posting_time = 1 inv_1.apply_tds = 1 - inv_1.tax_withholding_category = "TDS - 3" + inv_1.tax_withholding_category = tds_doc.name + inv_1.save() inv_1.submit() - inv_2 = make_purchase_invoice( - rate=1000, do_not_submit=True, posting_date=add_to_date(mid_year, days=1), do_not_save=True - ) + inv_2 = make_purchase_invoice(rate=1000, posting_date=from_date, do_not_save=True, do_not_submit=True) inv_2.set_posting_time = 1 - - inv_1.apply_tds = 1 - inv_2.tax_withholding_category = "TDS - 3" + inv_2.apply_tds = 1 + inv_2.tax_withholding_category = tds_doc.name inv_2.save() inv_2.submit() diff --git a/erpnext/accounts/report/trial_balance/trial_balance.js b/erpnext/accounts/report/trial_balance/trial_balance.js index 2f701900cf7..e3f32ab974f 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.js +++ b/erpnext/accounts/report/trial_balance/trial_balance.js @@ -111,6 +111,12 @@ frappe.query_reports["Trial Balance"] = { fieldtype: "Check", default: 1, }, + { + fieldname: "show_group_accounts", + label: __("Show Group Accounts"), + fieldtype: "Check", + default: 1, + }, ], formatter: erpnext.financial_statements.formatter, tree: true, diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 9524db3add6..587a8ea95cb 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -81,7 +81,7 @@ def validate_filters(filters): def get_data(filters): accounts = frappe.db.sql( - """select name, account_number, parent_account, account_name, root_type, report_type, lft, rgt + """select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt from `tabAccount` where company=%s order by lft""", filters.company, @@ -401,6 +401,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency): "from_date": filters.from_date, "to_date": filters.to_date, "currency": company_currency, + "is_group_account": d.is_group, "account_name": ( f"{d.account_number} - {d.account_name}" if d.account_number else d.account_name ), @@ -417,6 +418,10 @@ def prepare_data(accounts, filters, parent_children_map, company_currency): data.append(row) total_row = calculate_total_row(accounts, company_currency) + + if not filters.get("show_group_accounts"): + data = hide_group_accounts(data) + data.extend([{}, total_row]) return data @@ -496,3 +501,12 @@ def prepare_opening_closing(row): row[valid_col] = 0.0 else: row[reverse_col] = 0.0 + + +def hide_group_accounts(data): + non_group_accounts_data = [] + for d in data: + if not d.get("is_group_account"): + d.update(indent=0) + non_group_accounts_data.append(d) + return non_group_accounts_data diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 02ba54604c4..2a72b10e4eb 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -107,11 +107,7 @@ def convert_to_presentation_currency(gl_entries, currency_info): credit_in_account_currency = flt(entry["credit_in_account_currency"]) account_currency = entry["account_currency"] - if ( - len(account_currencies) == 1 - and account_currency == presentation_currency - and (debit_in_account_currency or credit_in_account_currency) - ): + if len(account_currencies) == 1 and account_currency == presentation_currency: entry["debit"] = debit_in_account_currency entry["credit"] = credit_in_account_currency else: diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 9d73681cf95..9e1579bb0a7 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Optional import frappe import frappe.defaults from frappe import _, qb, throw +from frappe.desk.reportview import build_match_conditions from frappe.model.meta import get_field_precision from frappe.query_builder import AliasedQuery, Case, Criterion, Table from frappe.query_builder.functions import Count, Max, Round, Sum @@ -179,7 +180,7 @@ def validate_fiscal_year(date, fiscal_year, company, label="Date", doc=None): if doc: doc.fiscal_year = years[0] else: - throw(_("{0} '{1}' not in Fiscal Year {2}").format(label, formatdate(date), fiscal_year)) + throw(_("{0} '{1}' not in Fiscal Year {2}").format(_(label), formatdate(date), fiscal_year)) @frappe.whitelist() @@ -644,10 +645,8 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): # Update Advance Paid in SO/PO since they might be getting unlinked update_advance_paid = [] - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) - if jv_detail.get("reference_type") in advance_payment_doctypes: + + if jv_detail.get("reference_type") in get_advance_payment_doctypes(): update_advance_paid.append((jv_detail.reference_type, jv_detail.reference_name)) rev_dr_or_cr = ( @@ -731,33 +730,15 @@ def update_reference_in_payment_entry( update_advance_paid = [] # Update Reconciliation effect date in reference - reconciliation_takes_effect_on = frappe.get_cached_value( - "Company", payment_entry.company, "reconciliation_takes_effect_on" - ) if payment_entry.book_advance_payments_in_separate_party_account: - if reconciliation_takes_effect_on == "Advance Payment Date": - reconcile_on = payment_entry.posting_date - elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance": - date_field = "posting_date" - if d.against_voucher_type in ["Sales Order", "Purchase Order"]: - date_field = "transaction_date" - reconcile_on = frappe.db.get_value(d.against_voucher_type, d.against_voucher, date_field) - - if getdate(reconcile_on) < getdate(payment_entry.posting_date): - reconcile_on = payment_entry.posting_date - elif reconciliation_takes_effect_on == "Reconciliation Date": - reconcile_on = nowdate() - + reconcile_on = get_reconciliation_effect_date(d, payment_entry.company, payment_entry.posting_date) reference_details.update({"reconcile_effect_on": reconcile_on}) if d.voucher_detail_no: existing_row = payment_entry.get("references", {"name": d["voucher_detail_no"]})[0] # Update Advance Paid in SO/PO since they are getting unlinked - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) - if existing_row.get("reference_doctype") in advance_payment_doctypes: + if existing_row.get("reference_doctype") in get_advance_payment_doctypes(): update_advance_paid.append((existing_row.reference_doctype, existing_row.reference_name)) if d.allocated_amount <= existing_row.allocated_amount: @@ -805,6 +786,28 @@ def update_reference_in_payment_entry( return row, update_advance_paid +def get_reconciliation_effect_date(reference, company, posting_date): + reconciliation_takes_effect_on = frappe.get_cached_value( + "Company", company, "reconciliation_takes_effect_on" + ) + + if reconciliation_takes_effect_on == "Advance Payment Date": + reconcile_on = posting_date + elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance": + date_field = "posting_date" + if reference.against_voucher_type in ["Sales Order", "Purchase Order"]: + date_field = "transaction_date" + reconcile_on = frappe.db.get_value( + reference.against_voucher_type, reference.against_voucher, date_field + ) + if getdate(reconcile_on) < getdate(posting_date): + reconcile_on = posting_date + elif reconciliation_takes_effect_on == "Reconciliation Date": + reconcile_on = nowdate() + + return reconcile_on + + def cancel_exchange_gain_loss_journal( parent_doc: dict | object, referenced_dt: str | None = None, referenced_dn: str | None = None ) -> None: @@ -1019,58 +1022,79 @@ def remove_ref_doc_link_from_pe( per = qb.DocType("Payment Entry Reference") pay = qb.DocType("Payment Entry") - linked_pe = ( + query = ( qb.from_(per) - .select(per.parent) - .where((per.reference_doctype == ref_type) & (per.reference_name == ref_no) & (per.docstatus.lt(2))) - .run(as_list=1) - ) - linked_pe = convert_to_list(linked_pe) - # remove reference only from specified payment - linked_pe = [x for x in linked_pe if x == payment_name] if payment_name else linked_pe - - if linked_pe: - update_query = ( - qb.update(per) - .set(per.allocated_amount, 0) - .set(per.modified, now()) - .set(per.modified_by, frappe.session.user) - .where(per.docstatus.lt(2) & (per.reference_doctype == ref_type) & (per.reference_name == ref_no)) + .select("*") + .where( + (per.reference_doctype == ref_type) + & (per.reference_name == ref_no) + & (per.docstatus.lt(2)) + & (per.parenttype == "Payment Entry") ) + ) - if payment_name: - update_query = update_query.where(per.parent == payment_name) + # update reference only from specified payment + if payment_name: + query = query.where(per.parent == payment_name) - update_query.run() + reference_rows = query.run(as_dict=True) - for pe in linked_pe: - try: - pe_doc = frappe.get_doc("Payment Entry", pe) - pe_doc.set_amounts() + if not reference_rows: + return - # Call cancel on only removed reference - references = [ - x - for x in pe_doc.references - if x.reference_doctype == ref_type and x.reference_name == ref_no - ] - [pe_doc.make_advance_gl_entries(x, cancel=1) for x in references] + linked_pe = set() + row_names = set() - pe_doc.clear_unallocated_reference_document_rows() - pe_doc.validate_payment_type_with_outstanding() - except Exception: - msg = _("There were issues unlinking payment entry {0}.").format(pe_doc.name) - msg += "
" - msg += _("Please cancel payment entry manually first") - frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error")) + for row in reference_rows: + linked_pe.add(row.parent) + row_names.add(row.name) - qb.update(pay).set(pay.total_allocated_amount, pe_doc.total_allocated_amount).set( - pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount - ).set(pay.unallocated_amount, pe_doc.unallocated_amount).set(pay.modified, now()).set( - pay.modified_by, frappe.session.user - ).where(pay.name == pe).run() + from erpnext.accounts.doctype.payment_request.payment_request import ( + update_payment_requests_as_per_pe_references, + ) - frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) + # Update payment request amount + update_payment_requests_as_per_pe_references(reference_rows, cancel=True) + + # Update allocated amounts and modified fields in one go + ( + qb.update(per) + .set(per.allocated_amount, 0) + .set(per.modified, now()) + .set(per.modified_by, frappe.session.user) + .where(per.name.isin(row_names)) + .where(per.parenttype == "Payment Entry") + .run() + ) + + for pe in linked_pe: + try: + pe_doc = frappe.get_doc("Payment Entry", pe) + pe_doc.set_amounts() + + # Call cancel on only removed reference + references = [x for x in pe_doc.references if x.name in row_names] + [pe_doc.make_advance_gl_entries(x, cancel=1) for x in references] + + pe_doc.clear_unallocated_reference_document_rows() + pe_doc.validate_payment_type_with_outstanding() + except Exception: + msg = _("There were issues unlinking payment entry {0}.").format(pe_doc.name) + msg += "
" + msg += _("Please cancel payment entry manually first") + frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error")) + + ( + qb.update(pay) + .set(pay.total_allocated_amount, pe_doc.total_allocated_amount) + .set(pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount) + .set(pay.unallocated_amount, pe_doc.unallocated_amount) + .set(pay.modified, now()) + .set(pay.modified_by, frappe.session.user) + .where(pay.name == pe) + .run() + ) + frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) @frappe.whitelist() @@ -2270,6 +2294,19 @@ def get_party_types_from_account_type(account_type): return frappe.db.get_all("Party Type", {"account_type": account_type}, pluck="name") +def get_advance_payment_doctypes(payment_type=None): + """ + Get list of advance payment doctypes based on type. + :param type: Optional, can be "receivable" or "payable". If not provided, returns both. + """ + if payment_type: + return frappe.get_hooks(f"advance_payment_{payment_type}_doctypes") or [] + + return (frappe.get_hooks("advance_payment_receivable_doctypes") or []) + ( + frappe.get_hooks("advance_payment_payable_doctypes") or [] + ) + + def run_ledger_health_checks(): health_monitor_settings = frappe.get_doc("Ledger Health Monitor") if health_monitor_settings.enable_health_monitor: @@ -2344,3 +2381,19 @@ def sync_auto_reconcile_config(auto_reconciliation_job_trigger: int = 15): "frequency": "Cron", } ).save() + + +def build_qb_match_conditions(doctype, user=None) -> list: + match_filters = build_match_conditions(doctype, user, False) + criterion = [] + if match_filters: + from frappe import qb + + _dt = qb.DocType(doctype) + + for filter in match_filters: + for d, names in filter.items(): + fieldname = d.lower().replace(" ", "_") + criterion.append(_dt[fieldname].isin(names)) + + return criterion diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 3ce1d5390db..5dc32d363d4 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -3,6 +3,8 @@ frappe.ui.form.on("Asset Repair", { setup: function (frm) { + frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"]; + frm.fields_dict.cost_center.get_query = function (doc) { return { filters: { @@ -177,4 +179,37 @@ frappe.ui.form.on("Asset Repair Consumed Item", { var row = locals[cdt][cdn]; frappe.model.set_value(cdt, cdn, "total_value", row.consumed_quantity * row.valuation_rate); }, + + pick_serial_and_batch(frm, cdt, cdn) { + let item = locals[cdt][cdn]; + let doc = frm.doc; + + frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]).then((r) => { + if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { + item.has_serial_no = r.message.has_serial_no; + item.has_batch_no = r.message.has_batch_no; + item.qty = item.consumed_quantity; + item.type_of_transaction = item.consumed_quantity > 0 ? "Outward" : "Inward"; + + item.title = item.has_serial_no ? __("Select Serial No") : __("Select Batch No"); + + if (item.has_serial_no && item.has_batch_no) { + item.title = __("Select Serial and Batch"); + } + frm.doc.posting_date = frappe.datetime.get_today(); + frm.doc.posting_time = frappe.datetime.now_time(); + + new erpnext.SerialBatchPackageSelector(frm, item, (r) => { + if (r) { + frappe.model.set_value(item.doctype, item.name, { + serial_and_batch_bundle: r.name, + use_serial_batch_fields: 0, + valuation_rate: r.avg_rate, + consumed_quantity: Math.abs(r.total_qty), + }); + } + }); + } + }); + }, }); diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index f49de50838a..935ed1b4e98 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -127,6 +127,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Asset", + "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",null]]]", "options": "Asset", "reqd": 1 }, @@ -259,7 +260,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-12-27 18:11:40.548727", + "modified": "2025-06-29 22:30:00.589597", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", @@ -297,10 +298,11 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "title_field": "asset_name", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 8a4349233e5..a1c741316a6 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -57,6 +57,7 @@ class AssetRepair(AccountsController): def validate(self): self.asset_doc = frappe.get_doc("Asset", self.asset) + self.validate_asset() self.validate_dates() self.validate_purchase_invoices() self.update_status() @@ -65,6 +66,14 @@ class AssetRepair(AccountsController): self.calculate_total_repair_cost() self.check_repair_status() + def validate_asset(self): + if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"): + frappe.throw( + _("Asset {0} is in {1} status and cannot be repaired.").format( + get_link_to_form("Asset", self.asset), self.asset_doc.status + ) + ) + def validate_dates(self): if self.completion_date and (self.failure_date > self.completion_date): frappe.throw( @@ -156,6 +165,13 @@ class AssetRepair(AccountsController): self.make_gl_entries() + def cancel_sabb(self): + for row in self.stock_items: + if sabb := row.serial_and_batch_bundle: + row.db_set("serial_and_batch_bundle", None) + doc = frappe.get_doc("Serial and Batch Bundle", sabb) + doc.cancel() + def on_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) if self.get("capitalize_repair_cost"): @@ -167,6 +183,8 @@ class AssetRepair(AccountsController): reschedule_depreciation(self.asset_doc, depreciation_note) self.add_asset_activity() + self.cancel_sabb() + def after_delete(self): frappe.get_doc("Asset", self.asset).set_status() diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index f0b4f4fb6b2..0a9f1d7189a 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -4,11 +4,12 @@ import unittest import frappe from frappe.tests import IntegrationTestCase -from frappe.utils import flt, nowdate, nowtime, today +from frappe.utils import add_months, flt, get_first_day, nowdate, nowtime, today from erpnext.assets.doctype.asset.asset import ( get_asset_account, get_asset_value_after_depreciation, + make_sales_invoice, ) from erpnext.assets.doctype.asset.test_asset import ( create_asset, @@ -34,6 +35,33 @@ class TestAssetRepair(IntegrationTestCase): create_item("_Test Stock Item") frappe.db.sql("delete from `tabTax Rule`") + def test_asset_status(self): + date = nowdate() + purchase_date = add_months(get_first_day(date), -2) + + asset = create_asset( + calculate_depreciation=1, + available_for_use_date=purchase_date, + purchase_date=purchase_date, + expected_value_after_useful_life=10000, + total_number_of_depreciations=10, + frequency_of_depreciation=1, + submit=1, + ) + + si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") + si.customer = "_Test Customer" + si.due_date = date + si.get("items")[0].rate = 25000 + si.insert() + si.submit() + + asset.reload() + self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") + asset_repair = frappe.new_doc("Asset Repair") + asset_repair.update({"company": "_Test Company", "asset": asset.name, "asset_name": asset.asset_name}) + self.assertRaises(frappe.ValidationError, asset_repair.save) + def test_update_status(self): asset = create_asset(submit=1) initial_status = asset.status diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json index 763308139e8..5ee245339eb 100644 --- a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json @@ -11,6 +11,8 @@ "consumed_quantity", "total_value", "serial_no", + "column_break_xzfr", + "pick_serial_and_batch", "serial_and_batch_bundle" ], "fields": [ @@ -61,19 +63,29 @@ "label": "Warehouse", "options": "Warehouse", "reqd": 1 + }, + { + "fieldname": "pick_serial_and_batch", + "fieldtype": "Button", + "label": "Pick Serial / Batch" + }, + { + "fieldname": "column_break_xzfr", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-06-13 12:01:47.147333", + "modified": "2025-06-27 14:52:56.311166", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair Consumed Item", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index d37b33f9540..37b2830c084 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -831,11 +831,19 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions target.credit_to = get_party_account("Supplier", source.supplier, source.company) def update_item(obj, target, source_parent): - target.amount = flt(obj.amount) - flt(obj.billed_amt) - target.base_amount = target.amount * flt(source_parent.conversion_rate) - target.qty = ( - target.amount / flt(obj.rate) if (flt(obj.rate) and flt(obj.billed_amt)) else flt(obj.qty) - ) + def get_billed_qty(po_item_name): + from frappe.query_builder.functions import Sum + + table = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty).as_("qty")) + .where((table.docstatus == 1) & (table.po_detail == po_item_name)) + ) + return query.run(pluck="qty")[0] or 0 + + billed_qty = flt(get_billed_qty(obj.name)) + target.qty = flt(obj.qty) - billed_qty item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index b1138d64161..6a1dd34d83b 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1319,6 +1319,25 @@ class TestPurchaseOrder(IntegrationTestCase): self.assertFalse(po.per_billed) self.assertEqual(po.status, "To Receive and Bill") + @IntegrationTestCase.change_settings("Buying Settings", {"maintain_same_rate": 0}) + def test_purchase_invoice_creation_with_partial_qty(self): + po = create_purchase_order(qty=100, rate=10) + + pi = make_pi_from_po(po.name) + pi.items[0].qty = 42 + pi.items[0].rate = 7.5 + pi.submit() + + pi = make_pi_from_po(po.name) + self.assertEqual(pi.items[0].qty, 58) + self.assertEqual(pi.items[0].rate, 10) + pi.items[0].qty = 8 + pi.items[0].rate = 5 + pi.submit() + + pi = make_pi_from_po(po.name) + self.assertEqual(pi.items[0].qty, 50) + def create_po_for_sc_testing(): from erpnext.controllers.tests.test_subcontracting_controller import ( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index daef99b25a9..43ea3029a06 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -51,6 +51,9 @@ from erpnext.accounts.utils import ( get_fiscal_years, validate_fiscal_year, ) +from erpnext.accounts.utils import ( + get_advance_payment_doctypes as _get_advance_payment_doctypes, +) from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, @@ -383,10 +386,7 @@ class AccountsController(TransactionBase): adv = qb.DocType("Advance Payment Ledger Entry") qb.from_(adv).delete().where(adv.voucher_type.eq(self.doctype) & adv.voucher_no.eq(self.name)).run() - advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) - if self.doctype in advance_payment_doctypes: + if self.doctype in self.get_advance_payment_doctypes(): qb.from_(adv).delete().where( adv.against_voucher_type.eq(self.doctype) & adv.against_voucher_no.eq(self.name) ).run() @@ -1138,6 +1138,10 @@ class AccountsController(TransactionBase): return True def set_taxes_and_charges(self): + if self.doctype == "Material Request": + # Material Request does not have taxes + return + if self.get("taxes") or self.get("is_pos"): return @@ -2265,9 +2269,9 @@ class AccountsController(TransactionBase): ) if not paid_amount: - if self.doctype in frappe.get_hooks("advance_payment_receivable_doctypes"): + if self.doctype in self.get_advance_payment_doctypes(payment_type="receivable"): new_status = "Not Requested" if paid_amount is None else "Requested" - elif self.doctype in frappe.get_hooks("advance_payment_payable_doctypes"): + elif self.doctype in self.get_advance_payment_doctypes(payment_type="payable"): new_status = "Not Initiated" if paid_amount is None else "Initiated" else: total_amount = self.get("rounded_total") or self.get("grand_total") @@ -2921,10 +2925,8 @@ class AccountsController(TransactionBase): repost_ledger.insert() repost_ledger.submit() - def get_advance_payment_doctypes(self) -> list: - return frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks( - "advance_payment_payable_doctypes" - ) + def get_advance_payment_doctypes(self, payment_type=None) -> list: + return _get_advance_payment_doctypes(payment_type=payment_type) def make_advance_payment_ledger_for_journal(self): advance_payment_doctypes = self.get_advance_payment_doctypes() @@ -3981,6 +3983,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ).format(frappe.bold(parent.name)) ) else: # Sales Order + parent.validate_selling_price() parent.validate_for_duplicate_items() parent.validate_warehouse() parent.update_reserved_qty() diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index e4bd2536ec3..699adb49b9b 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -7,7 +7,7 @@ import json import frappe from frappe import ValidationError, _, msgprint from frappe.contacts.doctype.address.address import render_address -from frappe.utils import cint, flt, getdate +from frappe.utils import cint, flt, format_date, get_link_to_form, getdate from frappe.utils.data import nowtime import erpnext @@ -81,19 +81,38 @@ class BuyingController(SubcontractingController): ) def validate_posting_date_with_po(self): - po_list = [] - for item in self.items: - if item.purchase_order and item.purchase_order not in po_list: - po_list.append(item.purchase_order) + po_list = {x.purchase_order for x in self.items if x.purchase_order} + + if not po_list: + return + + invalid_po = [] + po_dates = frappe._dict( + frappe.get_all( + "Purchase Order", + filters={"name": ["in", po_list]}, + fields=["name", "transaction_date"], + as_list=True, + ) + ) for po in po_list: - po_posting_date = frappe.get_value("Purchase Order", po, "transaction_date") - if getdate(po_posting_date) > getdate(self.posting_date): - frappe.throw( - _("Posting Date {0} cannot be before Purchase Order Posting Date {1}").format( - frappe.bold(self.posting_date), frappe.bold(po_posting_date) - ) - ) + po_date = po_dates[po] + if getdate(po_date) > getdate(self.posting_date): + invalid_po.append((get_link_to_form("Purchase Order", po), format_date(po_date))) + + if not invalid_po: + return + + msg = _("

Posting Date {0} cannot be before Purchase Order date for the following:

" + + frappe.throw(_(msg)) def create_package_for_transfer(self) -> None: """Create serial and batch package for Sourece Warehouse in case of inter transfer.""" diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 76b5adbb300..750978b0847 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1581,7 +1581,7 @@ def get_accounting_ledger_preview(doc, filters): doc.docstatus = 1 - if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note"): + if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): doc.update_stock_ledger() doc.make_gl_entries() @@ -1622,7 +1622,7 @@ def get_stock_ledger_preview(doc, filters): "stock_value_difference", ] - if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note"): + if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"): doc.docstatus = 1 doc.update_stock_ledger() columns = get_sl_columns(filters) @@ -1757,8 +1757,9 @@ def make_quality_inspections(doctype, docname, items, inspection_type): "sample_size": flt(item.get("sample_size")), "item_serial_no": item.get("serial_no").split("\n")[0] if item.get("serial_no") else None, "batch_no": item.get("batch_no"), + "child_row_reference": item.get("child_row_reference"), } - ).insert() + ) quality_inspection.save() inspections.append(quality_inspection.name) @@ -1771,14 +1772,9 @@ def is_reposting_pending(): ) -def future_sle_exists(args, sl_entries=None, allow_force_reposting=True): +def future_sle_exists(args, sl_entries=None): from erpnext.stock.utils import get_combine_datetime - if allow_force_reposting and frappe.get_single_value( - "Stock Reposting Settings", "do_reposting_for_each_stock_transaction" - ): - return True - key = (args.voucher_type, args.voucher_no) if not hasattr(frappe.local, "future_sle"): frappe.local.future_sle = {} diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index ada72c24ac5..a6cb7fa5349 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -470,7 +470,7 @@ class SubcontractingController(StockController): i += 1 def __remove_serial_and_batch_bundle(self, item): - if item.serial_and_batch_bundle: + if item.get("serial_and_batch_bundle"): frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True) def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): @@ -607,12 +607,15 @@ class SubcontractingController(StockController): rm_obj.use_serial_batch_fields = 1 self.__set_batch_nos(bom_item, item_row, rm_obj, qty) - if self.doctype == "Subcontracting Receipt" and not use_serial_batch_fields: - rm_obj.serial_and_batch_bundle = self.__set_serial_and_batch_bundle( - item_row, rm_obj, rm_obj.consumed_qty - ) + if self.doctype == "Subcontracting Receipt": + if not use_serial_batch_fields: + rm_obj.serial_and_batch_bundle = self.__set_serial_and_batch_bundle( + item_row, rm_obj, rm_obj.consumed_qty + ) - self.set_rate_for_supplied_items(rm_obj, item_row) + self.set_rate_for_supplied_items(rm_obj, item_row) + elif self.backflush_based_on == "BOM": + self.update_rate_for_supplied_items() def update_rate_for_supplied_items(self): if self.doctype != "Subcontracting Receipt": diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index f5046bb4c67..81d5621de0e 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -47,7 +47,7 @@ def get_columns(filters, trans): def validate_filters(filters): for f in ["Fiscal Year", "Based On", "Period", "Company"]: if not filters.get(f.lower().replace(" ", "_")): - frappe.throw(_("{0} is mandatory").format(f)) + frappe.throw(_("{0} is mandatory").format(_(f))) if not frappe.db.exists("Fiscal Year", filters.get("fiscal_year")): frappe.throw(_("Fiscal Year {0} Does Not Exist").format(filters.get("fiscal_year"))) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 17b7993eff7..806955d6e7c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -24,7 +24,6 @@ develop_version = "15.x.x-develop" app_include_js = "erpnext.bundle.js" app_include_css = "erpnext.bundle.css" -web_include_js = "erpnext-web.bundle.js" web_include_css = "erpnext-web.bundle.css" email_css = "email_erpnext.bundle.css" @@ -281,6 +280,7 @@ standard_portal_menu_items = [ sounds = [ {"name": "incoming-call", "src": "/assets/erpnext/sounds/incoming-call.mp3", "volume": 0.2}, {"name": "call-disconnect", "src": "/assets/erpnext/sounds/call-disconnect.mp3", "volume": 0.2}, + {"name": "numpad-touch", "src": "/assets/erpnext/sounds/numpad-touch.mp3", "volume": 0.8}, ] has_upload_permission = {"Employee": "erpnext.setup.doctype.employee.employee.has_upload_permission"} diff --git a/erpnext/locale/ar.po b/erpnext/locale/ar.po index 6a06f3c8dc4..0073453dbd6 100644 --- a/erpnext/locale/ar.po +++ b/erpnext/locale/ar.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" -"POT-Creation-Date: 2025-06-22 09:36+0000\n" -"PO-Revision-Date: 2025-06-23 03:29\n" +"POT-Creation-Date: 2025-07-06 09:36+0000\n" +"PO-Revision-Date: 2025-07-07 05:54\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Arabic\n" "MIME-Version: 1.0\n" @@ -224,7 +224,7 @@ msgstr "" msgid "% of materials delivered against this Sales Order" msgstr "" -#: erpnext/controllers/accounts_controller.py:2282 +#: erpnext/controllers/accounts_controller.py:2308 msgid "'Account' in the Accounting section of Customer {0}" msgstr "" @@ -240,11 +240,11 @@ msgstr "'على أساس' و 'المجموعة حسب' لا يمكن أن يكو msgid "'Days Since Last Order' must be greater than or equal to zero" msgstr "يجب أن تكون \"الأيام منذ آخر طلب\" أكبر من أو تساوي الصفر" -#: erpnext/controllers/accounts_controller.py:2287 +#: erpnext/controllers/accounts_controller.py:2313 msgid "'Default {0} Account' in Company {1}" msgstr "" -#: erpnext/accounts/doctype/journal_entry/journal_entry.py:1274 +#: erpnext/accounts/doctype/journal_entry/journal_entry.py:1273 msgid "'Entries' cannot be empty" msgstr "المدخلات لا يمكن أن تكون فارغة" @@ -281,15 +281,15 @@ msgstr "'افتتاحي'" msgid "'To Date' is required" msgstr "' إلى تاريخ ' مطلوب" -#: erpnext/stock/doctype/packing_slip/packing_slip.py:94 +#: erpnext/stock/doctype/packing_slip/packing_slip.py:95 msgid "'To Package No.' cannot be less than 'From Package No.'" msgstr "" -#: erpnext/controllers/sales_and_purchase_return.py:68 +#: erpnext/controllers/sales_and_purchase_return.py:69 msgid "'Update Stock' can not be checked because items are not delivered via {0}" msgstr ""الأوراق المالية التحديث" لا يمكن التحقق من أنه لم يتم تسليم المواد عن طريق {0}" -#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:380 +#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:381 msgid "'Update Stock' cannot be checked for fixed asset sale" msgstr "لا يمكن التحقق من ' تحديث المخزون ' لبيع الأصول الثابتة\\n
\\n'Update Stock' cannot be checked for fixed asset sale" @@ -670,6 +670,22 @@ msgstr "" msgid "" msgstr "" +#: erpnext/accounts/doctype/bank_clearance/bank_clearance.py:123 +msgid "
  • Clearance date must be after cheque date for row(s): {0}
  • " +msgstr "" + +#: erpnext/controllers/accounts_controller.py:2176 +msgid "
  • Item {0} in row(s) {1} billed more than {2}
  • " +msgstr "" + +#: erpnext/accounts/doctype/bank_clearance/bank_clearance.py:118 +msgid "
  • Payment document required for row(s): {0}
  • " +msgstr "" + +#: erpnext/controllers/accounts_controller.py:2173 +msgid "

    Cannot overbill for the following Items:

    " +msgstr "" + #. Content of the 'html_llwp' (HTML) field in DocType 'Request for Quotation' #: erpnext/buying/doctype/request_for_quotation/request_for_quotation.json msgid "

    In your Email Template, you can use the following special variables:\n" @@ -694,10 +710,22 @@ msgid "

    In your Email Template, you can use the following special varia "

    Apart from these, you can access all values in this RFQ, like {{ message_for_supplier }} or {{ terms }}.

    " msgstr "" +#: erpnext/accounts/doctype/bank_clearance/bank_clearance.py:116 +msgid "

    Please correct the following row(s):