Merge branch 'frappe:develop' into employee_query

This commit is contained in:
Thomas antony
2025-07-12 10:04:35 +05:30
committed by GitHub
158 changed files with 57729 additions and 51079 deletions

View File

@@ -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"
}
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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}")

View File

@@ -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 = _("<p>Please correct the following row(s):</p><ul>")
if invalid_document:
msg += _("<li>Payment document required for row(s): {0}</li>").format(
", ".join(invalid_document)
)
clearance_date_updated = True
if invalid_cheque_date:
msg += _("<li>Clearance date must be after cheque date for row(s): {0}</li>").format(
", ".join(invalid_cheque_date)
)
if clearance_date_updated:
self.get_payment_entries()
msgprint(_("Clearance Date updated"))
else:
msg += "</ul>"
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(

View File

@@ -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",
}

View File

@@ -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",

View File

@@ -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:

View File

@@ -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(

View File

@@ -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,

View File

@@ -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}]},

View File

@@ -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)

View File

@@ -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 }));

View File

@@ -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",

View File

@@ -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

View File

@@ -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];

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 = _("<p>Following {0}s doesn't belong to Company {1} :</p>").format(
doctype, frappe.bold(self.company)
)
msg += (
"<ul>"
+ "".join(_("<li>{}</li>").format(frappe.bold(row)) for row in invalid_values)
+ "</ul>"
)
frappe.throw(_(msg))
def get_report_pdf(doc, consolidated=True):
statement_dict = get_statement_dict(doc)

View File

@@ -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")

View File

@@ -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",

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 ""

View File

@@ -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 {}

View File

@@ -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",
},
{

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

@@ -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:

View File

@@ -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 += "<br>"
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 += "<br>"
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

View File

@@ -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),
});
}
});
}
});
},
});

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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 (

View File

@@ -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()

View File

@@ -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 = _("<p>Posting Date {0} cannot be before Purchase Order date for the following:</p><ul>").format(
frappe.bold(format_date(self.posting_date))
)
for po, date in invalid_po:
msg += f"<li>{po} ({date})</li>"
msg += "</ul>"
frappe.throw(_(msg))
def create_package_for_transfer(self) -> None:
"""Create serial and batch package for Sourece Warehouse in case of inter transfer."""

View File

@@ -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 = {}

View File

@@ -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":

View File

@@ -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")))

View File

@@ -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"}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1037,7 +1037,7 @@ class BOM(WebsiteGenerator):
self.transfer_material_against = "Work Order"
if not self.transfer_material_against and not self.is_new():
frappe.throw(
_("Setting {} is required").format(self.meta.get_label("transfer_material_against")),
_("Setting {0} is required").format(_(self.meta.get_label("transfer_material_against"))),
title=_("Missing value"),
)
@@ -1336,7 +1336,7 @@ def get_children(parent=None, is_root=False, **filters):
bom_items = frappe.get_all(
"BOM Item",
fields=["item_code", "bom_no as value", "stock_qty"],
fields=["item_code", "bom_no as value", "stock_qty", "qty"],
filters=[["parent", "=", frappe.form_dict.parent]],
order_by="idx",
)

View File

@@ -16,7 +16,14 @@ frappe.treeview_settings["BOM"] = {
show_expand_all: false,
get_label: function (node) {
if (node.data.qty) {
return node.data.qty + " x " + node.data.item_code;
const escape = frappe.utils.escape_html;
let label = escape(node.data.item_code);
if (node.data.item_name && node.data.item_code !== node.data.item_name) {
label += `: ${escape(node.data.item_name)}`;
}
return `${label} <span class="badge badge-pill badge-light">${node.data.qty} ${escape(
__(node.data.stock_uom)
)}</span>`;
} else {
return node.data.item_code || node.data.value;
}

View File

@@ -96,9 +96,13 @@ frappe.ui.form.on("Job Card", {
let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
if (has_items && (to_request || excess_transfer_allowed)) {
frm.add_custom_button(__("Material Request"), () => {
frm.trigger("make_material_request");
});
frm.add_custom_button(
__("Material Request"),
() => {
frm.trigger("make_material_request");
},
__("Create")
);
}
// check if any row has untransferred materials
@@ -106,9 +110,13 @@ frappe.ui.form.on("Job Card", {
let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty);
if (has_items && (to_transfer || excess_transfer_allowed)) {
frm.add_custom_button(__("Material Transfer"), () => {
frm.trigger("make_stock_entry");
});
frm.add_custom_button(
__("Material Transfer"),
() => {
frm.trigger("make_stock_entry");
},
__("Create")
);
}
}

View File

@@ -217,8 +217,8 @@ frappe.ui.form.on("Production Plan", {
let has_items =
items.filter((item) => {
if (item.pending_qty) {
return item.pending_qty > item.ordered_qty;
if (item.planned_qty) {
return item.planned_qty > item.ordered_qty;
} else {
return item.qty > (item.received_qty || item.ordered_qty);
}

View File

@@ -899,6 +899,7 @@ class ProductionPlan(Document):
try:
wo.flags.ignore_mandatory = True
wo.flags.ignore_validate = True
wo.company = self.company
wo.insert()
return wo.name
except OverProductionError:
@@ -1839,13 +1840,14 @@ def get_sub_assembly_items(
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
for _bin_dict in bin_details[d.item_code]:
if _bin_dict.projected_qty > 0:
if _bin_dict.projected_qty >= stock_qty:
_bin_dict.projected_qty -= stock_qty
_bin_dict.original_projected_qty = _bin_dict.projected_qty
if _bin_dict.original_projected_qty > 0:
if _bin_dict.original_projected_qty >= stock_qty:
_bin_dict.original_projected_qty -= stock_qty
stock_qty = 0
continue
else:
stock_qty = stock_qty - _bin_dict.projected_qty
stock_qty = stock_qty - _bin_dict.original_projected_qty
sub_assembly_items.append(d.item_code)
elif warehouse:
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))

View File

@@ -2022,7 +2022,7 @@ class TestProductionPlan(IntegrationTestCase):
else:
# For raw materials 2 stock reservation entries
# 5 qty was present already in stock and 5 added from new PO
self.assertEqual(len(reserved_entries), 2)
self.assertEqual(len(reserved_entries), 1)
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
@@ -2097,7 +2097,7 @@ class TestProductionPlan(IntegrationTestCase):
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
self.assertTrue(len(reserved_entries) == 6)
self.assertTrue(len(reserved_entries) == 30)
for row in reserved_entries:
self.assertEqual(row.reserved_qty, 5.0)
@@ -2136,7 +2136,7 @@ class TestProductionPlan(IntegrationTestCase):
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
self.assertTrue(len(reserved_entries) == 9)
self.assertTrue(len(reserved_entries) == 45)
serial_nos_res_for_pp = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1},
@@ -2166,11 +2166,11 @@ class TestProductionPlan(IntegrationTestCase):
self.assertFalse(serial_no in additional_serial_nos)
if wo_doc.production_item == "Finished Good For SR":
self.assertEqual(len(reserved_entries), 3)
self.assertEqual(len(reserved_entries), 15)
else:
# For raw materials 2 stock reservation entries
# 5 qty was present already in stock and 5 added from new PO
self.assertEqual(len(reserved_entries), 2)
self.assertEqual(len(reserved_entries), 10)
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)

View File

@@ -2364,6 +2364,105 @@ class TestWorkOrder(IntegrationTestCase):
stock_entry.submit()
def test_disassembly_order_with_qty_behavior(self):
# Create raw material and FG item
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=10, raw_materials=[raw_item], rm_qty=5)
# Create and submit a Work Order for 10 qty
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
# create material receipt stock entry for raw material
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
make_stock_entry_test_record(
item_code=raw_item,
purpose="Material Receipt",
target=wo.wip_warehouse,
qty=10,
basic_rate=100,
)
make_stock_entry_test_record(
item_code=raw_item,
purpose="Material Receipt",
target=wo.fg_warehouse,
qty=10,
basic_rate=100,
)
# create material transfer for manufacture stock entry
se_for_material_tranfer_mfr = frappe.get_doc(
make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
)
se_for_material_tranfer_mfr.items[0].s_warehouse = wo.wip_warehouse
se_for_material_tranfer_mfr.save()
se_for_material_tranfer_mfr.submit()
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit()
# Simulate a disassembly stock entry
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.append(
"items",
{
"item_code": fg_item,
"qty": disassemble_qty,
"s_warehouse": wo.fg_warehouse,
},
)
for bom_item in bom.items:
stock_entry.append(
"items",
{
"item_code": bom_item.item_code,
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
"t_warehouse": wo.source_warehouse,
},
)
wo.reload()
stock_entry.save()
stock_entry.submit()
# Assert FG item is present with correct qty
finished_good_entry = next((item for item in stock_entry.items if item.item_code == fg_item), None)
self.assertIsNotNone(finished_good_entry, "Finished good item missing from stock entry")
self.assertEqual(
finished_good_entry.qty,
disassemble_qty,
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
)
# Assert raw materials
for item in stock_entry.items:
if item.item_code == fg_item:
continue
bom_item = next((i for i in bom.items if i.item_code == item.item_code), None)
if bom_item:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
self.assertAlmostEqual(
item.qty,
expected_qty,
places=3,
msg=f"Raw item {item.item_code} qty mismatch: expected {expected_qty}, got {item.qty}",
)
else:
self.fail(f"Unexpected item {item.item_code} found in stock entry")
wo.reload()
# Assert disassembled_qty field updated in Work Order
self.assertEqual(
wo.disassembled_qty,
disassemble_qty,
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
)
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
@@ -3296,6 +3395,7 @@ def make_wo_order_test_record(**args):
wo_order.transfer_material_against = args.transfer_material_against or "Work Order"
wo_order.from_wip_warehouse = args.from_wip_warehouse or 0
wo_order.batch_size = args.batch_size or 0
wo_order.status = args.status or "Draft"
if args.source_warehouse:
wo_order.source_warehouse = args.source_warehouse

View File

@@ -870,7 +870,7 @@ erpnext.work_order = {
get_max_transferable_qty: (frm, purpose) => {
let max = 0;
if (purpose === "Disassemble") {
return flt(frm.doc.produced_qty);
return flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
}
if (frm.doc.skip_transfer) {

View File

@@ -20,6 +20,7 @@
"qty",
"material_transferred_for_manufacturing",
"produced_qty",
"disassembled_qty",
"process_loss_qty",
"project",
"track_semi_finished_goods",
@@ -592,6 +593,14 @@
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": " Reserve Stock"
},
{
"depends_on": "eval:doc.docstatus==1",
"fieldname": "disassembled_qty",
"fieldtype": "Float",
"label": "Disassembled Qty",
"no_copy": 1,
"read_only": 1
}
],
"grid_page_length": 50,
@@ -600,7 +609,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-04-25 11:46:38.739588",
"modified": "2025-06-21 00:55:45.916224",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -89,6 +89,7 @@ class WorkOrder(Document):
company: DF.Link
corrective_operation_cost: DF.Currency
description: DF.SmallText | None
disassembled_qty: DF.Float
expected_delivery_date: DF.Date | None
fg_warehouse: DF.Link | None
from_wip_warehouse: DF.Check
@@ -460,7 +461,7 @@ class WorkOrder(Document):
if qty > completed_qty:
frappe.throw(
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
self.meta.get_label(fieldname), qty, completed_qty, self.name
_(self.meta.get_label(fieldname)), qty, completed_qty, self.name
),
StockOverProductionError,
)
@@ -477,6 +478,18 @@ class WorkOrder(Document):
self.set_produced_qty_for_sub_assembly_item()
self.update_production_plan_status()
def update_disassembled_qty(self, qty, is_cancel=False):
if is_cancel:
self.disassembled_qty = max(0, self.disassembled_qty - qty)
else:
if self.docstatus == 1:
self.disassembled_qty += qty
if not is_cancel and self.disassembled_qty > self.produced_qty:
frappe.throw(_("Cannot disassemble more than produced quantity."))
self.db_set("disassembled_qty", self.disassembled_qty)
def get_transferred_or_manufactured_qty(self, purpose):
table = frappe.qb.DocType("Stock Entry")
query = frappe.qb.from_(table).where(
@@ -1164,7 +1177,7 @@ class WorkOrder(Document):
self.transfer_material_against = "Work Order"
if not self.transfer_material_against:
frappe.throw(
_("Setting {} is required").format(self.meta.get_label("transfer_material_against")),
_("Setting {0} is required").format(_(self.meta.get_label("transfer_material_against"))),
title=_("Missing value"),
)
@@ -1247,7 +1260,9 @@ class WorkOrder(Document):
"description": item.description,
"allow_alternative_item": item.allow_alternative_item,
"required_qty": item.qty,
"source_warehouse": (item.source_warehouse or item.default_warehouse)
"source_warehouse": (
self.source_warehouse or item.source_warehouse or item.default_warehouse
)
if not reset_source_warehouse
else self.source_warehouse,
"include_item_in_manufacturing": item.include_item_in_manufacturing,
@@ -1981,7 +1996,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
stock_entry.set_stock_entry_type()
stock_entry.get_items()
stock_entry.get_items(qty, work_order.production_item)
if purpose != "Disassemble":
stock_entry.set_serial_no_batch_for_finished_good()

View File

@@ -1,12 +1,10 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder.functions import Floor, Sum
from frappe.utils import cint
from pypika.terms import ExistsCriterion
def execute(filters=None):
@@ -20,8 +18,7 @@ def execute(filters=None):
def get_columns():
"""return columns"""
columns = [
return [
_("Item") + ":Link/Item:150",
_("Item Name") + "::240",
_("Description") + "::300",
@@ -32,55 +29,54 @@ def get_columns():
_("Enough Parts to Build") + ":Float:200",
]
return columns
def get_bom_stock(filters):
qty_to_produce = filters.get("qty_to_produce")
if cint(qty_to_produce) <= 0:
frappe.throw(_("Quantity to Produce should be greater than zero."))
if filters.get("show_exploded_view"):
bom_item_table = "BOM Explosion Item"
else:
bom_item_table = "BOM Item"
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
warehouse_details = frappe.db.get_value("Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1)
warehouse = filters.get("warehouse")
warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1)
BOM = frappe.qb.DocType("BOM")
BOM_ITEM = frappe.qb.DocType(bom_item_table)
BIN = frappe.qb.DocType("Bin")
WH = frappe.qb.DocType("Warehouse")
CONDITIONS = ()
if warehouse_details:
CONDITIONS = ExistsCriterion(
frappe.qb.from_(WH)
.select(WH.name)
.where(
(WH.lft >= warehouse_details.lft)
& (WH.rgt <= warehouse_details.rgt)
& (BIN.warehouse == WH.name)
)
bin_subquery = (
frappe.qb.from_(BIN)
.join(WH)
.on(BIN.warehouse == WH.name)
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
.where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt))
.groupby(BIN.item_code)
)
else:
CONDITIONS = BIN.warehouse == filters.get("warehouse")
bin_subquery = (
frappe.qb.from_(BIN)
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
.where(BIN.warehouse == warehouse)
.groupby(BIN.item_code)
)
QUERY = (
frappe.qb.from_(BOM)
.inner_join(BOM_ITEM)
.join(BOM_ITEM)
.on(BOM.name == BOM_ITEM.parent)
.left_join(BIN)
.on((BOM_ITEM.item_code == BIN.item_code) & (CONDITIONS))
.left_join(bin_subquery)
.on(BOM_ITEM.item_code == bin_subquery.item_code)
.select(
BOM_ITEM.item_code,
BOM_ITEM.item_name,
BOM_ITEM.description,
BOM_ITEM.stock_qty,
Sum(BOM_ITEM.stock_qty),
BOM_ITEM.stock_uom,
BOM_ITEM.stock_qty * qty_to_produce / BOM.quantity,
BIN.actual_qty.as_("actual_qty"),
Sum(Floor(BIN.actual_qty / (BOM_ITEM.stock_qty * qty_to_produce / BOM.quantity))),
(Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity,
bin_subquery.actual_qty,
Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity)),
)
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
.groupby(BOM_ITEM.item_code)

View File

@@ -424,3 +424,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "confirm_before_resettin
erpnext.patches.v15_0.rename_pos_closing_entry_fields #2025-06-13
erpnext.patches.v15_0.update_pegged_currencies
erpnext.patches.v15_0.set_status_cancelled_on_cancelled_pos_opening_entry_and_pos_closing_entry
erpnext.patches.v15_0.set_company_on_pos_inv_merge_log

View File

@@ -0,0 +1,12 @@
import frappe
def execute():
pos_invoice_merge_logs = frappe.db.get_all(
"POS Invoice Merge Log", {"docstatus": 1}, ["name", "pos_closing_entry"]
)
for log in pos_invoice_merge_logs:
if log.pos_closing_entry and frappe.db.exists("POS Closing Entry", log.pos_closing_entry):
company = frappe.db.get_value("POS Closing Entry", log.pos_closing_entry, "company")
frappe.db.set_value("POS Invoice Merge Log", log.name, "company", company)

View File

@@ -202,6 +202,12 @@ frappe.ui.form.on("Project", {
});
});
},
collect_progress: function (frm) {
if (frm.doc.collect_progress && !frm.doc.subject) {
frm.set_value("subject", __("For project {0}, update your status", [frm.doc.name]));
}
},
});
function open_form(frm, doctype, child_doctype, parentfield) {

View File

@@ -62,6 +62,7 @@
"day_to_send",
"weekly_time_to_send",
"column_break_45",
"subject",
"message"
],
"fields": [
@@ -447,6 +448,13 @@
"print_hide": 1,
"reqd": 1,
"set_only_once": 1
},
{
"depends_on": "collect_progress",
"fieldname": "subject",
"fieldtype": "Data",
"label": "Subject",
"mandatory_depends_on": "collect_progress"
}
],
"icon": "fa fa-puzzle-piece",
@@ -454,7 +462,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2024-04-24 10:56:16.001032",
"modified": "2025-07-03 10:54:30.444139",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
@@ -501,6 +509,7 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "project_name,customer, status, priority, is_active",
"show_name_in_global_search": 1,
"sort_field": "creation",
@@ -509,4 +518,4 @@
"timeline_field": "customer",
"title_field": "project_name",
"track_seen": 1
}
}

Some files were not shown because too many files have changed in this diff Show More