Merge branch 'develop' into fixing-emp-contacts

This commit is contained in:
mergify[bot]
2026-02-18 10:47:34 +00:00
committed by GitHub
325 changed files with 134547 additions and 111672 deletions

View File

@@ -60,7 +60,7 @@ body:
description: Share exact version number of Frappe and ERPNext you are using.
placeholder: |
Frappe version -
ERPNext Verion -
ERPNext version -
validations:
required: true

View File

@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop"]
branch: ["develop", "version-16-hotfix"]
permissions:
contents: write

View File

@@ -7,6 +7,7 @@ on:
paths:
- "**.js"
- "**.css"
- "**.svg"
- "**.md"
- "**.html"
- 'crowdin.yml'

View File

@@ -1,3 +1,4 @@
<div align="center">
<a href="https://frappe.io/erpnext">
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80xp"/>

View File

@@ -0,0 +1,50 @@
{
"cards": [
{
"card": "Total Outgoing Bills"
},
{
"card": "Total Incoming Bills"
},
{
"card": "Total Incoming Payment"
},
{
"card": "Total Outgoing Payment"
}
],
"charts": [
{
"chart": "Incoming Bills (Purchase Invoice)",
"width": "Half"
},
{
"chart": "Outgoing Bills (Sales Invoice)",
"width": "Half"
},
{
"chart": "Accounts Receivable Ageing",
"width": "Half"
},
{
"chart": "Accounts Payable Ageing",
"width": "Half"
},
{
"chart": "Bank Balance",
"width": "Full"
}
],
"creation": "2026-01-26 21:25:12.793893",
"dashboard_name": "Payments",
"docstatus": 0,
"doctype": "Dashboard",
"idx": 0,
"is_default": 0,
"is_standard": 1,
"modified": "2026-01-26 21:25:12.793893",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payments",
"owner": "Administrator"
}

View File

@@ -33,6 +33,17 @@
},
"account_number": "1151.000"
},
"Pajak Dibayar di Muka": {
"PPN Masukan": {
"account_number": "1152.001",
"account_type": "Tax"
},
"PPh 23 Dibayar di Muka": {
"account_number": "1152.002",
"account_type": "Tax"
},
"account_number": "1152.000"
},
"account_number": "1150.000"
},
"Kas": {
@@ -97,17 +108,6 @@
},
"account_number": "1130.000"
},
"Pajak Dibayar di Muka": {
"PPN Masukan": {
"account_number": "1151.001",
"account_type": "Tax"
},
"PPh 23 Dibayar di Muka": {
"account_number": "1152.001",
"account_type": "Tax"
},
"account_number": "1150.000"
},
"account_number": "1100.000"
},

View File

@@ -97,7 +97,7 @@ def validate_accounting_period_on_doc_save(doc, method=None):
if doc.doctype == "Bank Clearance":
return
elif doc.doctype == "Asset":
if doc.is_existing_asset:
if doc.asset_type == "Existing Asset":
return
else:
date = doc.available_for_use_date

View File

@@ -20,6 +20,10 @@
"enable_common_party_accounting",
"allow_multi_currency_invoices_against_single_party_account",
"confirm_before_resetting_posting_date",
"analytics_section",
"enable_accounting_dimensions",
"column_break_vtnr",
"enable_discounts_and_margin",
"journals_section",
"merge_similar_account_heads",
"deferred_accounting_settings_section",
@@ -51,12 +55,16 @@
"allow_pegged_currencies_exchange_rates",
"column_break_yuug",
"stale_days",
"payments_tab",
"section_break_jpd0",
"auto_reconcile_payments",
"auto_reconciliation_job_trigger",
"reconciliation_queue_size",
"column_break_resa",
"exchange_gain_loss_posting_date",
"payment_options_section",
"enable_loyalty_point_program",
"column_break_ctam",
"invoicing_settings_tab",
"accounts_transactions_settings_section",
"over_billing_allowance",
@@ -281,7 +289,7 @@
},
{
"default": "0",
"description": "Learn about <a href=\"https://docs.frappe.io/erpnext/user/manual/en/common_party_accounting\">Common Party</a>",
"description": "Learn about <a href=\"https://docs.frappe.io/erpnext/user/manual/en/common_party_accounting\" rel=\"noopener noreferrer\">Common Party</a>",
"fieldname": "enable_common_party_accounting",
"fieldtype": "Check",
"label": "Enable Common Party Accounting"
@@ -637,16 +645,59 @@
"fieldname": "budget_section",
"fieldtype": "Section Break",
"label": "Budget"
},
{
"fieldname": "analytics_section",
"fieldtype": "Section Break",
"label": "Analytical Accounting"
},
{
"fieldname": "column_break_vtnr",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Apply discounts and margins on products",
"fieldname": "enable_discounts_and_margin",
"fieldtype": "Check",
"label": "Enable Discounts and Margin"
},
{
"fieldname": "payments_tab",
"fieldtype": "Tab Break",
"label": "Payments"
},
{
"fieldname": "payment_options_section",
"fieldtype": "Section Break",
"label": "Payment Options"
},
{
"default": "0",
"fieldname": "enable_loyalty_point_program",
"fieldtype": "Check",
"label": "Enable Loyalty Point Program"
},
{
"fieldname": "column_break_ctam",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Enable cost center, projects and other custom accounting dimensions",
"fieldname": "enable_accounting_dimensions",
"fieldtype": "Check",
"label": "Enable Accounting Dimensions"
}
],
"grid_page_length": 50,
"hide_toolbar": 1,
"hide_toolbar": 0,
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-11 18:30:45.968531",
"modified": "2026-02-04 17:15:38.609327",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -12,6 +12,28 @@ from frappe.utils import cint
from erpnext.accounts.utils import sync_auto_reconcile_config
SELLING_DOCTYPES = [
"Sales Invoice",
"Sales Order",
"Delivery Note",
"Quotation",
"Sales Invoice Item",
"Sales Order Item",
"Delivery Note Item",
"Quotation Item",
"POS Invoice",
"POS Invoice Item",
]
BUYING_DOCTYPES = [
"Purchase Invoice",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice Item",
"Purchase Order Item",
"Purchase Receipt Item",
]
class AccountsSettings(Document):
# begin: auto-generated types
@@ -43,9 +65,12 @@ class AccountsSettings(Document):
default_ageing_range: DF.Data | None
delete_linked_ledger_entries: DF.Check
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
enable_accounting_dimensions: DF.Check
enable_common_party_accounting: DF.Check
enable_discounts_and_margin: DF.Check
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check
enable_party_matching: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
fetch_valuation_rate_for_internal_transaction: DF.Check
@@ -98,6 +123,18 @@ class AccountsSettings(Document):
if old_doc.show_payment_schedule_in_print != self.show_payment_schedule_in_print:
self.enable_payment_schedule_in_print()
if old_doc.enable_accounting_dimensions != self.enable_accounting_dimensions:
toggle_accounting_dimension_sections(not self.enable_accounting_dimensions)
clear_cache = True
if old_doc.enable_discounts_and_margin != self.enable_discounts_and_margin:
toggle_sales_discount_section(not self.enable_discounts_and_margin)
clear_cache = True
if old_doc.enable_loyalty_point_program != self.enable_loyalty_point_program:
toggle_loyalty_point_program_section(not self.enable_loyalty_point_program)
clear_cache = True
if clear_cache:
frappe.clear_cache()
@@ -154,3 +191,36 @@ class AccountsSettings(Document):
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")
def toggle_accounting_dimension_sections(hide):
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
for doctype in accounting_dimension_doctypes:
create_property_setter_for_hiding_field(doctype, "accounting_dimensions_section", hide)
def toggle_sales_discount_section(hide):
for doctype in SELLING_DOCTYPES + BUYING_DOCTYPES:
meta = frappe.get_meta(doctype)
if meta.has_field("additional_discount_section"):
create_property_setter_for_hiding_field(doctype, "additional_discount_section", hide)
if meta.has_field("discount_and_margin"):
create_property_setter_for_hiding_field(doctype, "discount_and_margin", hide)
def toggle_loyalty_point_program_section(hide):
for doctype in SELLING_DOCTYPES:
meta = frappe.get_meta(doctype)
if meta.has_field("loyalty_points_redemption"):
create_property_setter_for_hiding_field(doctype, "loyalty_points_redemption", hide)
def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter(
doctype,
field_name,
"hidden",
hide,
"Check",
validate_fields_for_doctype=False,
)

View File

@@ -50,6 +50,7 @@
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"options": "currency",
"read_only": 1
},
{

View File

@@ -3,9 +3,6 @@
frappe.provide("erpnext.integrations");
frappe.ui.form.on("Bank", {
onload: function (frm) {
add_fields_to_mapping_table(frm);
},
refresh: function (frm) {
add_fields_to_mapping_table(frm);
frm.toggle_display(["address_html", "contact_html"], !frm.doc.__islocal);
@@ -37,11 +34,11 @@ let add_fields_to_mapping_table = function (frm) {
});
});
frm.fields_dict.bank_transaction_mapping.grid.update_docfield_property(
"bank_transaction_field",
"options",
options
);
const grid = frm.fields_dict.bank_transaction_mapping?.grid;
if (grid) {
grid.update_docfield_property("bank_transaction_field", "options", options);
}
};
erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
@@ -116,7 +113,7 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
"There was an issue connecting to Plaid's authentication server. Check browser console for more information"
)
);
console.log(error);
console.error(error);
}
plaid_success(token, response) {

View File

@@ -42,8 +42,4 @@ frappe.ui.form.on("Bank Account", {
});
}
},
is_company_account: function (frm) {
frm.set_df_property("account", "reqd", frm.doc.is_company_account);
},
});

View File

@@ -52,6 +52,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company Account",
"mandatory_depends_on": "is_company_account",
"options": "Account"
},
{
@@ -98,6 +99,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"mandatory_depends_on": "is_company_account",
"options": "Company"
},
{
@@ -252,7 +254,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2025-08-29 12:32:01.081687",
"modified": "2026-01-20 00:46:16.633364",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",

View File

@@ -51,25 +51,29 @@ class BankAccount(Document):
delete_contact_and_address("Bank Account", self.name)
def validate(self):
self.validate_company()
self.validate_account()
self.validate_is_company_account()
self.update_default_bank_account()
def validate_account(self):
if self.account:
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def validate_is_company_account(self):
if self.is_company_account:
if not self.company:
frappe.throw(_("Company is mandatory for company account"))
def validate_company(self):
if self.is_company_account and not self.company:
frappe.throw(_("Company is mandatory for company account"))
if not self.account:
frappe.throw(_("Company Account is mandatory"))
self.validate_account()
def validate_account(self):
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def update_default_bank_account(self):
if self.is_default and not self.disabled:

View File

@@ -15,7 +15,7 @@ from frappe.database.operator_map import OPERATOR_MAP
from frappe.query_builder import Case
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, date_diff, flt, getdate
from pypika.terms import LiteralValue
from pypika.terms import Bracket, LiteralValue
from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -541,7 +541,7 @@ class FinancialQueryBuilder:
.where(acb_table.period_closing_voucher == closing_voucher)
)
query = self._apply_standard_filters(query, acb_table)
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
results = self._execute_with_permissions(query, "Account Closing Balance")
for row in results:
@@ -636,12 +636,15 @@ class FinancialQueryBuilder:
return self._execute_with_permissions(query, "GL Entry")
def _calculate_running_balances(self, balances_data: dict, gl_data: list[dict]) -> dict:
for row in gl_data:
account = row["account"]
gl_dict = {row["account"]: row for row in gl_data}
accounts = set(balances_data.keys()) | set(gl_dict.keys())
for account in accounts:
if account not in balances_data:
balances_data[account] = AccountData(account=account, **self._get_account_meta(account))
account_data: AccountData = balances_data[account]
gl_movement = gl_dict.get(account, {})
if account_data.has_periods():
first_period = account_data.get_period(self.periods[0]["key"])
@@ -651,20 +654,13 @@ class FinancialQueryBuilder:
for period in self.periods:
period_key = period["key"]
movement = row.get(period_key, 0.0)
movement = gl_movement.get(period_key, 0.0)
closing_balance = current_balance + movement
account_data.add_period(PeriodValue(period_key, current_balance, closing_balance, movement))
current_balance = closing_balance
# Accounts with no movements
for account_data in balances_data.values():
for period in self.periods:
period_key = period["key"]
if period_key not in account_data.period_values:
account_data.add_period(PeriodValue(period_key, 0.0, 0.0, 0.0))
def _handle_balance_accumulation(self, balances_data):
for account_data in balances_data.values():
account_data: AccountData
@@ -683,12 +679,12 @@ class FinancialQueryBuilder:
else:
account_data.unaccumulate_values()
def _apply_standard_filters(self, query, table):
def _apply_standard_filters(self, query, table, doctype: str = "GL Entry"):
if self.filters.get("ignore_closing_entries"):
if hasattr(table, "is_period_closing_voucher_entry"):
query = query.where(table.is_period_closing_voucher_entry == 0)
else:
if doctype == "GL Entry":
query = query.where(table.voucher_type != "Period Closing Voucher")
else:
query = query.where(table.is_period_closing_voucher_entry == 0)
if self.filters.get("project"):
projects = self.filters.get("project")
@@ -736,7 +732,7 @@ class FinancialQueryBuilder:
user_conditions = build_match_conditions(doctype)
if user_conditions:
query = query.where(LiteralValue(user_conditions))
query = query.where(Bracket(LiteralValue(user_conditions)))
return query.run(as_dict=True)

View File

@@ -7,12 +7,14 @@ from frappe.utils import flt
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
DependencyResolver,
FilterExpressionParser,
FinancialQueryBuilder,
FormulaCalculator,
)
from erpnext.accounts.doctype.financial_report_template.test_financial_report_template import (
FinancialReportTemplateTestCase,
)
from erpnext.accounts.utils import get_currency_precision
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
@@ -1668,3 +1670,360 @@ class TestFilterExpressionParser(FinancialReportTemplateTestCase):
mock_row_invalid = self._create_mock_report_row(invalid_formula)
condition = parser.build_condition(mock_row_invalid, account_table)
self.assertIsNone(condition)
class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
def test_fetch_balances_with_journal_entries(self):
company = "_Test Company"
cash_account = "_Test Cash - _TC"
bank_account = "_Test Bank - _TC"
# Create journal entries in different periods
# October: Transfer 1000 from Bank to Cash
jv_oct = make_journal_entry(
account1=cash_account,
account2=bank_account,
amount=1000,
posting_date="2024-10-15",
company=company,
submit=True,
)
# November: Transfer 500 from Bank to Cash
jv_nov = make_journal_entry(
account1=cash_account,
account2=bank_account,
amount=500,
posting_date="2024-11-20",
company=company,
submit=True,
)
# December: No transactions (test zero movement period)
try:
# Set up filters and periods for Q4 2024
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-10-01",
"period_end_date": "2024-12-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
}
periods = [
{"key": "2024_oct", "from_date": "2024-10-01", "to_date": "2024-10-31"},
{"key": "2024_nov", "from_date": "2024-11-01", "to_date": "2024-11-30"},
{"key": "2024_dec", "from_date": "2024-12-01", "to_date": "2024-12-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
# Create account objects as expected by fetch_account_balances
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
frappe._dict({"name": bank_account, "account_name": "Bank", "account_number": "1002"}),
]
# Fetch balances using the full workflow
balances_data = query_builder.fetch_account_balances(accounts)
# Verify Cash account balances
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account should exist in results")
# October: movement = +1000 (debit)
oct_cash = cash_data.get_period("2024_oct")
self.assertIsNotNone(oct_cash, "October period should exist for cash")
self.assertEqual(oct_cash.movement, 1000.0, "October cash movement should be 1000")
# November: movement = +500
nov_cash = cash_data.get_period("2024_nov")
self.assertIsNotNone(nov_cash, "November period should exist for cash")
self.assertEqual(nov_cash.movement, 500.0, "November cash movement should be 500")
self.assertEqual(
nov_cash.opening, oct_cash.closing, "November opening should equal October closing"
)
# December: movement = 0 (no transactions)
dec_cash = cash_data.get_period("2024_dec")
self.assertIsNotNone(dec_cash, "December period should exist for cash")
self.assertEqual(dec_cash.movement, 0.0, "December cash movement should be 0")
self.assertEqual(
dec_cash.closing,
nov_cash.closing,
"December closing should equal November closing when no movement",
)
# Verify Bank account balances (opposite direction)
bank_data = balances_data.get(bank_account)
self.assertIsNotNone(bank_data, "Bank account should exist in results")
oct_bank = bank_data.get_period("2024_oct")
self.assertEqual(oct_bank.movement, -1000.0, "October bank movement should be -1000")
nov_bank = bank_data.get_period("2024_nov")
self.assertEqual(nov_bank.movement, -500.0, "November bank movement should be -500")
finally:
# Clean up: cancel journal entries
jv_nov.cancel()
jv_oct.cancel()
def test_opening_balance_from_previous_period_closing(self):
company = "_Test Company"
cash_account = "_Test Cash - _TC"
sales_account = "Sales - _TC"
posting_date_2023 = "2023-06-15"
# Create journal entry in prior period (2023)
# Cash Dr 5000, Sales Cr 5000
jv_2023 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=5000,
posting_date=posting_date_2023,
company=company,
submit=True,
)
pcv = None
jv_2024 = None
original_pcv_setting = frappe.db.get_single_value(
"Accounts Settings", "use_legacy_controller_for_pcv"
)
try:
# Create Period Closing Voucher for 2023
# This will create Account Closing Balance entries
closing_account = frappe.db.get_value(
"Account",
{
"company": company,
"root_type": "Liability",
"is_group": 0,
"account_type": ["not in", ["Payable", "Receivable"]],
},
"name",
)
fy_2023 = get_fiscal_year(posting_date_2023, company=company)
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": "2023-12-31",
"period_start_date": fy_2023[1],
"period_end_date": fy_2023[2],
"company": company,
"fiscal_year": fy_2023[0],
"cost_center": "_Test Cost Center - _TC",
"closing_account_head": closing_account,
"remarks": "Test Period Closing",
}
)
pcv.insert()
pcv.submit()
pcv.reload()
# Now create a small transaction in 2024 to ensure the account appears
jv_2024 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=100,
posting_date="2024-01-15",
company=company,
submit=True,
)
# Set up filters for Q1 2024 (after the period closing)
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-03-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True, # Don't include PCV entries in movements
}
periods = [
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
# Verify Cash account has opening balance from 2023 transactions
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account should exist in results")
jan_cash = cash_data.get_period("2024_jan")
self.assertIsNotNone(jan_cash, "January period should exist")
# Opening balance should be from prior period
# Cash had 5000 debit in 2023, so opening in 2024 should be >= 5000
# (may be higher if there were other test transactions)
self.assertEqual(
jan_cash.opening,
5000.0,
"January opening should equal to balance from 2023 (5000)",
)
# Verify running balance logic
# Movement in January is 100 (from jv_2024)
self.assertEqual(jan_cash.movement, 100.0, "January movement should be 100")
self.assertEqual(
jan_cash.closing, jan_cash.opening + jan_cash.movement, "Closing = Opening + Movement"
)
# February and March should have no movement but carry the balance
feb_cash = cash_data.get_period("2024_feb")
self.assertEqual(feb_cash.opening, jan_cash.closing, "Feb opening = Jan closing")
self.assertEqual(feb_cash.movement, 0.0, "February should have no movement")
self.assertEqual(feb_cash.closing, feb_cash.opening, "Feb closing = opening when no movement")
mar_cash = cash_data.get_period("2024_mar")
self.assertEqual(mar_cash.opening, feb_cash.closing, "Mar opening = Feb closing")
self.assertEqual(mar_cash.movement, 0.0, "March should have no movement")
self.assertEqual(mar_cash.closing, mar_cash.opening, "Mar closing = opening when no movement")
# Set up filters for Q2 2024
filters_q2 = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-04-01",
"period_end_date": "2024-06-30",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True,
}
periods_q2 = [
{"key": "2024_apr", "from_date": "2024-04-01", "to_date": "2024-04-30"},
{"key": "2024_may", "from_date": "2024-05-01", "to_date": "2024-05-31"},
{"key": "2024_jun", "from_date": "2024-06-01", "to_date": "2024-06-30"},
]
query_builder_q2 = FinancialQueryBuilder(filters_q2, periods_q2)
balances_data_q2 = query_builder_q2.fetch_account_balances(accounts)
# Verify Cash account in Q2
cash_data_q2 = balances_data_q2.get(cash_account)
self.assertIsNotNone(cash_data_q2, "Cash account should exist in Q2 results")
apr_cash = cash_data_q2.get_period("2024_apr")
self.assertIsNotNone(apr_cash, "April period should exist")
# Opening balance in April should equal closing in March
self.assertEqual(
apr_cash.opening,
mar_cash.closing,
"April opening should equal March closing balance",
)
self.assertEqual(apr_cash.closing, apr_cash.opening, "April closing = opening when no movement")
finally:
# Clean up
frappe.db.set_single_value(
"Accounts Settings", "use_legacy_controller_for_pcv", original_pcv_setting or 0
)
if jv_2024:
jv_2024.cancel()
if pcv:
pcv.reload()
if pcv.docstatus == 1:
pcv.cancel()
jv_2023.cancel()
def test_account_with_gl_entries_but_no_prior_closing_balance(self):
company = "_Test Company"
cash_account = "_Test Cash - _TC"
bank_account = "_Test Bank - _TC"
# Create journal entries WITHOUT any prior Period Closing Voucher
# This ensures the account exists in gl_dict but NOT in balances_data
jv = make_journal_entry(
account1=cash_account,
account2=bank_account,
amount=2500,
posting_date="2024-07-15",
company=company,
submit=True,
)
try:
# Set up filters - use a period with no prior PCV
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-07-01",
"period_end_date": "2024-09-30",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
}
periods = [
{"key": "2024_jul", "from_date": "2024-07-01", "to_date": "2024-07-31"},
{"key": "2024_aug", "from_date": "2024-08-01", "to_date": "2024-08-31"},
{"key": "2024_sep", "from_date": "2024-09-01", "to_date": "2024-09-30"},
]
query_builder = FinancialQueryBuilder(filters, periods)
# Use accounts that have GL entries but may not have Account Closing Balance
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
frappe._dict({"name": bank_account, "account_name": "Bank", "account_number": "1002"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
# Verify accounts are present in results even without prior closing balance
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account should exist in results")
bank_data = balances_data.get(bank_account)
self.assertIsNotNone(bank_data, "Bank account should exist in results")
# Verify July has the movement from journal entry
jul_cash = cash_data.get_period("2024_jul")
self.assertIsNotNone(jul_cash, "July period should exist for cash")
self.assertEqual(jul_cash.movement, 2500.0, "July cash movement should be 2500")
jul_bank = bank_data.get_period("2024_jul")
self.assertIsNotNone(jul_bank, "July period should exist for bank")
self.assertEqual(jul_bank.movement, -2500.0, "July bank movement should be -2500")
# Verify subsequent periods exist with zero movement
aug_cash = cash_data.get_period("2024_aug")
self.assertIsNotNone(aug_cash, "August period should exist for cash")
self.assertEqual(aug_cash.movement, 0.0, "August cash movement should be 0")
self.assertEqual(aug_cash.opening, jul_cash.closing, "August opening = July closing")
sep_cash = cash_data.get_period("2024_sep")
self.assertIsNotNone(sep_cash, "September period should exist for cash")
self.assertEqual(sep_cash.movement, 0.0, "September cash movement should be 0")
self.assertEqual(sep_cash.opening, aug_cash.closing, "September opening = August closing")
finally:
jv.cancel()

View File

@@ -277,7 +277,21 @@ frappe.ui.form.on("Journal Entry", {
var update_jv_details = function (doc, r) {
$.each(r, function (i, d) {
var row = frappe.model.add_child(doc, "Journal Entry Account", "accounts");
frappe.model.set_value(row.doctype, row.name, "account", d.account);
const {
idx,
name,
owner,
parent,
parenttype,
parentfield,
creation,
modified,
modified_by,
doctype,
docstatus,
...fields
} = d;
frappe.model.set_value(row.doctype, row.name, fields);
});
refresh_field("accounts");
};

View File

@@ -9,6 +9,7 @@
"engine": "InnoDB",
"field_order": [
"entry_type_and_date",
"company",
"is_system_generated",
"title",
"voucher_type",
@@ -17,7 +18,6 @@
"reversal_of",
"column_break1",
"from_template",
"company",
"posting_date",
"finance_book",
"apply_tds",
@@ -638,7 +638,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-11-13 17:54:14.542903",
"modified": "2026-02-03 14:40:39.944524",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",

View File

@@ -74,8 +74,8 @@ class JournalEntry(AccountsController):
mode_of_payment: DF.Link | None
multi_currency: DF.Check
naming_series: DF.Literal["ACC-JV-.YYYY.-"]
party_not_required: DF.Check
override_tax_withholding_entries: DF.Check
party_not_required: DF.Check
pay_to_recd_from: DF.Data | None
payment_order: DF.Link | None
periodic_entry_difference_account: DF.Link | None
@@ -179,7 +179,7 @@ class JournalEntry(AccountsController):
validate_docs_for_deferred_accounting([self.name], [])
def submit(self):
if len(self.accounts) > 100:
if len(self.accounts) > 100 and not self.meta.queue_in_background:
queue_submission(self, "_submit")
else:
return self._submit()
@@ -1691,6 +1691,10 @@ def get_exchange_rate(
credit=None,
exchange_rate=None,
):
# Ensure exchange_rate is always numeric to avoid calculation errors
if isinstance(exchange_rate, str):
exchange_rate = flt(exchange_rate) or 1
account_details = frappe.get_cached_value(
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
)

View File

@@ -3,6 +3,7 @@
frappe.ui.form.on("Journal Entry Template", {
onload: function (frm) {
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (frm.is_new()) {
frappe.call({
type: "GET",
@@ -37,6 +38,31 @@ frappe.ui.form.on("Journal Entry Template", {
return { filters: filters };
});
frm.set_query("project", "accounts", function (doc, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let filters = {
company: doc.company,
};
if (row.party_type == "Customer") {
filters.customer = row.party;
}
return {
query: "erpnext.controllers.queries.get_project_name",
filters,
};
});
frm.set_query("party_type", "accounts", function (doc, cdt, cdn) {
const row = locals[cdt][cdn];
return {
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",
filters: {
account: row.account,
},
};
});
},
voucher_type: function (frm) {
var add_accounts = function (doc, r) {

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
@@ -42,7 +43,29 @@ class JournalEntryTemplate(Document):
]
# end: auto-generated types
pass
def validate(self):
self.validate_party()
def validate_party(self):
"""
Loop over all accounts and see if party and party type is set correctly
"""
for account in self.accounts:
if account.party_type:
account_type = frappe.get_cached_value("Account", account.account, "account_type")
if account_type not in ["Receivable", "Payable"]:
frappe.throw(
_(
"Check row {0} for account {1}: Party Type is only allowed for Receivable or Payable accounts"
).format(account.idx, account.account)
)
if account.party and not account.party_type:
frappe.throw(
_("Check row {0} for account {1}: Party is only allowed if Party Type is set").format(
account.idx, account.account
)
)
@frappe.whitelist()

View File

@@ -5,7 +5,13 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"account"
"account",
"party_type",
"party",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project"
],
"fields": [
{
@@ -15,18 +21,55 @@
"label": "Account",
"options": "Account",
"reqd": 1
},
{
"fieldname": "party_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Party Type",
"options": "DocType"
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Party",
"options": "party_type"
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:09:58.986448",
"modified": "2026-01-09 13:16:27.615083",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Template Account",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -16,9 +16,13 @@ class JournalEntryTemplateAccount(Document):
from frappe.types import DF
account: DF.Link
cost_center: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
party: DF.DynamicLink | None
party_type: DF.Link | None
project: DF.Link | None
# end: auto-generated types
pass

View File

@@ -400,6 +400,16 @@ frappe.ui.form.on("Payment Entry", {
);
frm.refresh_fields();
const party_currency =
frm.doc.payment_type === "Receive" ? "paid_from_account_currency" : "paid_to_account_currency";
var reference_grid = frm.fields_dict["references"].grid;
["total_amount", "outstanding_amount", "allocated_amount"].forEach((fieldname) => {
reference_grid.update_docfield_property(fieldname, "options", party_currency);
});
reference_grid.refresh();
},
show_general_ledger: function (frm) {
@@ -1108,7 +1118,7 @@ frappe.ui.form.on("Payment Entry", {
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
await frm.call("allocate_amount_to_references", {
paid_amount: paid_amount,
paid_amount: flt(paid_amount),
paid_amount_change: paid_amount_change,
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
});
@@ -1444,16 +1454,15 @@ frappe.ui.form.on("Payment Entry", {
callback: function (r) {
if (!r.exc && r.message) {
// set taxes table
if (r.message) {
for (let tax of r.message) {
if (tax.charge_type === "On Net Total") {
tax.charge_type = "On Paid Amount";
}
frm.add_child("taxes", tax);
let taxes = r.message;
taxes.forEach((tax) => {
if (tax.charge_type === "On Net Total") {
tax.charge_type = "On Paid Amount";
}
frm.events.apply_taxes(frm);
frm.events.set_unallocated_amount(frm);
}
});
frm.set_value("taxes", taxes);
frm.events.apply_taxes(frm);
frm.events.set_unallocated_amount(frm);
}
},
});

View File

@@ -132,6 +132,12 @@
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "due_date",

View File

@@ -38,6 +38,7 @@ class PaymentLedgerEntry(Document):
amount_in_account_currency: DF.Currency
company: DF.Link | None
cost_center: DF.Link | None
project: DF.Link | None
delinked: DF.Check
due_date: DF.Date | None
finance_book: DF.Link | None

View File

@@ -746,7 +746,7 @@ class PaymentReconciliation(Document):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):

View File

@@ -535,7 +535,7 @@ class PaymentRequest(Document):
row_number += TO_SKIP_NEW_ROW
@frappe.whitelist(allow_guest=True)
@frappe.whitelist()
def make_payment_request(**args):
"""Make payment request"""
@@ -546,6 +546,9 @@ def make_payment_request(**args):
if args.dn and not isinstance(args.dn, str):
frappe.throw(_("Invalid parameter. 'dn' should be of type str"))
frappe.has_permission("Payment Request", "create", throw=True)
frappe.has_permission(args.dt, "read", args.dn, throw=True)
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
if not args.get("company"):
args.company = ref_doc.company
@@ -819,7 +822,7 @@ def get_print_format_list(ref_doctype):
return {"print_format": print_format_list}
@frappe.whitelist(allow_guest=True)
@frappe.whitelist()
def resend_payment_email(docname):
return frappe.get_doc("Payment Request", docname).send_email()

View File

@@ -159,15 +159,16 @@
"language",
"column_break_84",
"select_print_heading",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_bhao",
"utm_campaign",
"more_information",
"inter_company_invoice_reference",
"customer_group",
"is_discounted",
"col_break23",
"utm_source",
"utm_campaign",
"utm_medium",
"column_break_gpiw",
"status",
"more_info",
"debit_to",
@@ -1541,10 +1542,6 @@
"fieldtype": "Check",
"label": "Update Billed Amount in Delivery Note"
},
{
"fieldname": "column_break_gpiw",
"fieldtype": "Column Break"
},
{
"fieldname": "utm_medium",
"fieldtype": "Link",
@@ -1610,13 +1607,24 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
},
{
"fieldname": "column_break_bhao",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2025-08-04 22:22:31.471752",
"modified": "2026-02-10 14:23:07.181782",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -897,6 +897,53 @@ class TestPOSInvoice(IntegrationTestCase):
if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
self.assertEqual(batch.qty, 5)
def test_pos_batch_reservation_with_return_qty(self):
"""
Test POS Invoice reserved qty for batch without bundle with return invoices.
"""
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_batch_item_with_batch,
)
create_batch_item_with_batch("_Batch Item Reserve Return", "TestBatch-RR 01")
se = make_stock_entry(
target="_Test Warehouse - _TC",
item_code="_Batch Item Reserve Return",
qty=30,
basic_rate=100,
)
se.reload()
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
# POS Invoice for the batch without bundle
pos_inv = create_pos_invoice(item="_Batch Item Reserve Return", rate=300, qty=15, do_not_save=1)
pos_inv.append(
"payments",
{"mode_of_payment": "Cash", "amount": 4500},
)
pos_inv.items[0].batch_no = batch_no
pos_inv.save()
pos_inv.submit()
# POS Invoice return
pos_return = make_sales_return(pos_inv.name)
pos_return.insert()
pos_return.submit()
batches = get_auto_batch_nos(
frappe._dict({"item_code": "_Batch Item Reserve Return", "warehouse": "_Test Warehouse - _TC"})
)
for batch in batches:
if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
self.assertEqual(batch.qty, 30)
def test_pos_batch_item_qty_validation(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
BatchNegativeStockError,

View File

@@ -12,56 +12,78 @@
"disabled",
"column_break_9",
"warehouse",
"utm_source",
"utm_campaign",
"utm_medium",
"company_address",
"section_break_15",
"applicable_for_users",
"accounting_tab",
"section_break_11",
"payments",
"set_grand_total_to_default_mop",
"price_list_and_currency_section",
"currency",
"column_break_bptt",
"selling_price_list",
"write_off_section",
"write_off_account",
"column_break_ukpz",
"write_off_cost_center",
"column_break_pkca",
"write_off_limit",
"income_and_expense_account",
"income_account",
"column_break_byzk",
"expense_account",
"taxes_section",
"taxes_and_charges",
"column_break_cjpp",
"tax_category",
"section_break_19",
"account_for_change_amount",
"disable_rounded_total",
"column_break_23",
"apply_discount_on",
"allow_partial_payment",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"pos_configurations_tab",
"section_break_14",
"hide_images",
"hide_unavailable_items",
"auto_add_item_to_cart",
"validate_stock_on_save",
"print_receipt_on_order_complete",
"action_on_new_invoice",
"validate_stock_on_save",
"column_break_16",
"update_stock",
"ignore_pricing_rule",
"print_receipt_on_order_complete",
"pos_item_selector_section",
"hide_images",
"column_break_rpny",
"hide_unavailable_items",
"column_break_stcl",
"auto_add_item_to_cart",
"pos_item_details_section",
"allow_rate_change",
"column_break_hwfg",
"allow_discount_change",
"set_grand_total_to_default_mop",
"allow_partial_payment",
"column_break_egpi",
"allow_warehouse_change",
"section_break_15",
"applicable_for_users",
"section_break_23",
"item_groups",
"column_break_25",
"customer_groups",
"more_info_tab",
"section_break_16",
"print_format",
"letter_head",
"column_break0",
"tc_name",
"select_print_heading",
"section_break_19",
"selling_price_list",
"currency",
"write_off_account",
"write_off_cost_center",
"write_off_limit",
"account_for_change_amount",
"disable_rounded_total",
"column_break_23",
"income_account",
"expense_account",
"taxes_and_charges",
"tax_category",
"apply_discount_on",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project"
"utm_analytics_section",
"utm_source",
"column_break_tvls",
"utm_campaign",
"column_break_xygw",
"utm_medium"
],
"fields": [
{
@@ -130,8 +152,7 @@
},
{
"fieldname": "section_break_14",
"fieldtype": "Section Break",
"label": "Configuration"
"fieldtype": "Section Break"
},
{
"description": "Only show Items from these Item Groups",
@@ -152,6 +173,7 @@
"options": "POS Customer Group"
},
{
"collapsible": 1,
"fieldname": "section_break_16",
"fieldtype": "Section Break",
"label": "Print Settings"
@@ -191,7 +213,7 @@
{
"fieldname": "section_break_19",
"fieldtype": "Section Break",
"label": "Accounting"
"label": "Miscellaneous"
},
{
"fieldname": "selling_price_list",
@@ -427,9 +449,111 @@
},
{
"default": "0",
"description": "Applicable only on Transactions made using POS",
"fieldname": "allow_partial_payment",
"fieldtype": "Check",
"label": "Allow Partial Payment"
},
{
"fieldname": "column_break_tvls",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_xygw",
"fieldtype": "Column Break"
},
{
"fieldname": "accounting_tab",
"fieldtype": "Tab Break",
"label": "Accounting"
},
{
"fieldname": "more_info_tab",
"fieldtype": "Tab Break",
"label": "More Info"
},
{
"fieldname": "pos_configurations_tab",
"fieldtype": "Tab Break",
"label": "POS Configurations"
},
{
"fieldname": "price_list_and_currency_section",
"fieldtype": "Section Break",
"label": "Price List & Currency"
},
{
"fieldname": "column_break_bptt",
"fieldtype": "Column Break"
},
{
"fieldname": "write_off_section",
"fieldtype": "Section Break",
"label": "Write Off"
},
{
"fieldname": "column_break_ukpz",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_pkca",
"fieldtype": "Column Break"
},
{
"fieldname": "income_and_expense_account",
"fieldtype": "Section Break",
"label": "Income and Expense"
},
{
"fieldname": "column_break_byzk",
"fieldtype": "Column Break"
},
{
"fieldname": "taxes_section",
"fieldtype": "Section Break",
"label": "Taxes"
},
{
"fieldname": "column_break_cjpp",
"fieldtype": "Column Break"
},
{
"fieldname": "pos_item_selector_section",
"fieldtype": "Section Break",
"label": "POS Item Selector"
},
{
"fieldname": "column_break_rpny",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_stcl",
"fieldtype": "Column Break"
},
{
"fieldname": "pos_item_details_section",
"fieldtype": "Section Break",
"label": "POS Item Details"
},
{
"fieldname": "column_break_hwfg",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_egpi",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "allow_warehouse_change",
"fieldtype": "Check",
"label": "Allow User to Edit Warehouse"
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "Campaign"
}
],
"grid_page_length": 50,
@@ -458,7 +582,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2025-06-24 11:19:19.834905",
"modified": "2026-02-10 14:24:48.597412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

@@ -34,6 +34,7 @@ class POSProfile(Document):
allow_discount_change: DF.Check
allow_partial_payment: DF.Check
allow_rate_change: DF.Check
allow_warehouse_change: DF.Check
applicable_for_users: DF.Table[POSProfileUser]
apply_discount_on: DF.Literal["Grand Total", "Net Total"]
auto_add_item_to_cart: DF.Check

View File

@@ -98,8 +98,7 @@ def get_customers_list(pos_profile=None):
return (
frappe.db.sql(
f""" select name, customer_name, customer_group,
territory, customer_pos_id from tabCustomer where disabled = 0
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
and {cond}""",
tuple(customer_groups),
as_dict=1,

View File

@@ -121,7 +121,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Apply On",
"options": "\nItem Code\nItem Group\nBrand\nTransaction",
"options": "Item Code\nItem Group\nBrand\nTransaction",
"reqd": 1
},
{
@@ -657,7 +657,7 @@
"icon": "fa fa-gift",
"idx": 1,
"links": [],
"modified": "2025-08-20 11:40:07.096854",
"modified": "2026-02-17 12:24:07.553505",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Pricing Rule",
@@ -714,9 +714,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_name_in_global_search": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}
}

View File

@@ -45,7 +45,7 @@ class PricingRule(Document):
apply_discount_on: DF.Literal["Grand Total", "Net Total"]
apply_discount_on_rate: DF.Check
apply_multiple_pricing_rules: DF.Check
apply_on: DF.Literal["", "Item Code", "Item Group", "Brand", "Transaction"]
apply_on: DF.Literal["Item Code", "Item Group", "Brand", "Transaction"]
apply_recursion_over: DF.Float
apply_rule_on_other: DF.Literal["", "Item Code", "Item Group", "Brand"]
brands: DF.Table[PricingRuleBrand]

View File

@@ -10,7 +10,7 @@
],
"fields": [
{
"depends_on": "eval:parent.apply_on == 'Item Code'",
"depends_on": "eval:parent.apply_on == 'Brand'",
"fieldname": "brand",
"fieldtype": "Link",
"in_list_view": 1,
@@ -28,14 +28,15 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:17.857046",
"modified": "2026-02-17 12:17:13.073587",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Pricing Rule Brand",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -10,7 +10,7 @@
],
"fields": [
{
"depends_on": "eval:parent.apply_on == 'Item Code'",
"depends_on": "eval:parent.apply_on == 'Item Group'",
"fieldname": "item_group",
"fieldtype": "Link",
"in_list_view": 1,
@@ -28,14 +28,15 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:18.221095",
"modified": "2026-02-17 12:16:57.778471",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Pricing Rule Item Group",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -415,8 +415,9 @@ def reconcile(doc: None | str = None) -> None:
for x in allocations:
pr.append("allocation", x)
skip_ref_details_update_for_pe = check_multi_currency(pr)
# reconcile
pr.reconcile_allocations(skip_ref_details_update_for_pe=True)
pr.reconcile_allocations(skip_ref_details_update_for_pe=skip_ref_details_update_for_pe)
# If Payment Entry, update details only for newly linked references
# This is for performance
@@ -504,6 +505,37 @@ def reconcile(doc: None | str = None) -> None:
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
def check_multi_currency(pr_doc):
GL = frappe.qb.DocType("GL Entry")
Account = frappe.qb.DocType("Account")
def get_account_currency(voucher_type, voucher_no):
currency = (
frappe.qb.from_(GL)
.join(Account)
.on(GL.account == Account.name)
.select(Account.account_currency)
.where(
(GL.voucher_type == voucher_type)
& (GL.voucher_no == voucher_no)
& (Account.account_type.isin(["Payable", "Receivable"]))
)
.limit(1)
).run(as_dict=True)
return currency[0].account_currency if currency else None
for allocation in pr_doc.allocation:
reference_currency = get_account_currency(allocation.reference_type, allocation.reference_name)
invoice_currency = get_account_currency(allocation.invoice_type, allocation.invoice_number)
if reference_currency != invoice_currency:
return True
return False
@frappe.whitelist()
def is_any_doc_running(for_filter: str | dict | None = None) -> str | None:
running_doc = None

View File

@@ -534,7 +534,7 @@ cur_frm.fields_dict["select_print_heading"].get_query = function (doc, cdt, cdn)
cur_frm.set_query("wip_composite_asset", "items", function () {
return {
filters: { is_composite_asset: 1, docstatus: 0 },
filters: { asset_type: "Composite Asset", docstatus: 0 },
};
});

View File

@@ -85,20 +85,24 @@
"taxes_and_charges_added",
"taxes_and_charges_deducted",
"total_taxes_and_charges",
"section_break_49",
"totals_section",
"grand_total",
"disable_rounded_total",
"rounding_adjustment",
"column_break8",
"use_company_roundoff_cost_center",
"in_words",
"rounded_total",
"base_totals_section",
"base_grand_total",
"base_rounding_adjustment",
"base_rounded_total",
"column_break_hcca",
"base_in_words",
"column_break8",
"grand_total",
"rounding_adjustment",
"use_company_roundoff_cost_center",
"rounded_total",
"in_words",
"base_rounded_total",
"section_break_ttrv",
"total_advance",
"column_break_peap",
"outstanding_amount",
"disable_rounded_total",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
@@ -606,6 +610,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
@@ -882,15 +887,10 @@
"options": "currency",
"print_hide": 1
},
{
"fieldname": "section_break_49",
"fieldtype": "Section Break",
"label": "Totals"
},
{
"fieldname": "base_grand_total",
"fieldtype": "Currency",
"label": "Grand Total (Company Currency)",
"label": "Grand Total",
"oldfieldname": "grand_total",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
@@ -901,7 +901,7 @@
"depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "base_rounding_adjustment",
"fieldtype": "Currency",
"label": "Rounding Adjustment (Company Currency)",
"label": "Rounding Adjustment",
"no_copy": 1,
"options": "Company:company:default_currency",
"print_hide": 1,
@@ -911,7 +911,7 @@
"depends_on": "eval:!doc.disable_rounded_total",
"fieldname": "base_rounded_total",
"fieldtype": "Currency",
"label": "Rounded Total (Company Currency)",
"label": "Rounded Total",
"no_copy": 1,
"options": "Company:company:default_currency",
"print_hide": 1,
@@ -920,7 +920,7 @@
{
"fieldname": "base_in_words",
"fieldtype": "Data",
"label": "In Words (Company Currency)",
"label": "In Words",
"length": 240,
"oldfieldname": "in_words",
"oldfieldtype": "Data",
@@ -1660,6 +1660,28 @@
"fieldname": "override_tax_withholding_entries",
"fieldtype": "Check",
"label": "Edit Tax Withholding Entries"
},
{
"fieldname": "column_break_hcca",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ttrv",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_peap",
"fieldtype": "Column Break"
},
{
"fieldname": "base_totals_section",
"fieldtype": "Section Break",
"label": "Totals (Company Currency)"
},
{
"fieldname": "totals_section",
"fieldtype": "Section Break",
"label": "Totals"
}
],
"grid_page_length": 50,
@@ -1667,7 +1689,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2025-12-15 06:41:38.237728",
"modified": "2026-02-05 20:45:16.964500",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -36,7 +36,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
update_billed_amount_based_on_po,
@@ -2005,9 +2005,17 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
args = json.loads(args)
def post_parent_process(source_parent, target_parent):
for row in target_parent.get("items"):
if row.get("qty") == 0:
target_parent.remove(row)
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
def remove_items_with_zero_qty(target_parent):
target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
def set_missing_values(source_parent, target_parent):
target_parent.run_method("set_missing_values")
if args and args.get("merge_taxes"):
merge_taxes(source_parent, target_parent)
target_parent.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
@@ -2059,7 +2067,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",
"reset_value": not (args and args.get("merge_taxes")),
"ignore": args.get("merge_taxes") if args else 0,
},
},
target_doc,
post_parent_process,

View File

@@ -52,6 +52,7 @@
"stock_uom_rate",
"is_free_item",
"apply_tds",
"allow_zero_valuation_rate",
"section_break_22",
"net_rate",
"net_amount",
@@ -97,7 +98,6 @@
"service_start_date",
"service_end_date",
"reference",
"allow_zero_valuation_rate",
"item_tax_rate",
"bom",
"include_exploded_items",
@@ -420,6 +420,7 @@
"options": "UOM"
},
{
"depends_on": "eval:parent.update_stock",
"fieldname": "warehouse_section",
"fieldtype": "Section Break",
"label": "Warehouse"
@@ -447,7 +448,6 @@
"print_hide": 1
},
{
"depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "batch_no",
"fieldtype": "Link",
"label": "Batch No",
@@ -459,14 +459,12 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "serial_no",
"fieldtype": "Text",
"label": "Serial No",
"no_copy": 1
},
{
"depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "rejected_serial_no",
"fieldtype": "Text",
"label": "Rejected Serial No",
@@ -577,6 +575,7 @@
},
{
"default": "0",
"depends_on": "eval:parent.update_stock",
"fieldname": "allow_zero_valuation_rate",
"fieldtype": "Check",
"label": "Allow Zero Valuation Rate",
@@ -800,7 +799,7 @@
"read_only": 1
},
{
"depends_on": "eval:parent.is_internal_supplier && parent.update_stock",
"depends_on": "eval:parent.is_internal_supplier",
"fieldname": "from_warehouse",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -896,7 +895,7 @@
"label": "Consider for Tax Withholding"
},
{
"depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)",
"depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1",
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
@@ -906,7 +905,7 @@
"search_index": 1
},
{
"depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)",
"depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1",
"fieldname": "rejected_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Rejected Serial and Batch Bundle",
@@ -922,7 +921,7 @@
"options": "Asset"
},
{
"depends_on": "eval:parent.update_stock === 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)",
"depends_on": "eval:doc.use_serial_batch_fields === 0 && doc.docstatus === 0",
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch No"
@@ -992,7 +991,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-12-13 14:10:02.379392",
"modified": "2026-02-15 21:07:49.455930",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -44,6 +44,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
"Unreconcile Payment Entries",
"Serial and Batch Bundle",
"Bank Transaction",
"Packing Slip",
];
if (!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
@@ -115,18 +116,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
}
if (cint(doc.update_stock) != 1) {
// show Make Delivery Note button only if Sales Invoice is not created from Delivery Note
var from_delivery_note = false;
from_delivery_note = this.frm.doc.items.some(function (item) {
return item.delivery_note ? true : false;
});
if (!from_delivery_note && !is_delivered_by_supplier) {
this.frm.add_custom_button(
__("Delivery"),
this.frm.cscript["Make Delivery Note"],
__("Create")
if (!is_delivered_by_supplier) {
const should_create_delivery_note = doc.items.some(
(item) =>
item.qty - item.delivered_qty > 0 &&
!item.scio_detail &&
!item.dn_detail &&
!item.delivered_by_supplier
);
if (should_create_delivery_note) {
this.frm.add_custom_button(
__("Delivery Note"),
this.frm.cscript["Make Delivery Note"],
__("Create")
);
}
}
}

View File

@@ -7,12 +7,12 @@
"engine": "InnoDB",
"field_order": [
"customer_section",
"company",
"company_tax_id",
"naming_series",
"customer",
"customer_name",
"tax_id",
"company",
"company_tax_id",
"column_break1",
"posting_date",
"posting_time",
@@ -77,34 +77,36 @@
"base_total_taxes_and_charges",
"column_break_47",
"total_taxes_and_charges",
"totals",
"base_grand_total",
"base_rounding_adjustment",
"base_rounded_total",
"base_in_words",
"column_break5",
"totals_section",
"grand_total",
"rounding_adjustment",
"use_company_roundoff_cost_center",
"rounded_total",
"in_words",
"column_break5",
"rounded_total",
"disable_rounded_total",
"total_advance",
"outstanding_amount",
"disable_rounded_total",
"use_company_roundoff_cost_center",
"base_totals_section",
"base_grand_total",
"base_rounding_adjustment",
"base_in_words",
"column_break_xjag",
"base_rounded_total",
"section_tax_withholding_entry",
"tax_withholding_group",
"ignore_tax_withholding_threshold",
"override_tax_withholding_entries",
"tax_withholding_entries",
"section_break_49",
"additional_discount_section",
"apply_discount_on",
"base_discount_amount",
"coupon_code",
"is_cash_or_non_trade_discount",
"additional_discount_account",
"column_break_51",
"additional_discount_percentage",
"discount_amount",
"is_cash_or_non_trade_discount",
"additional_discount_account",
"sec_tax_breakup",
"other_charges_calculation",
"item_wise_tax_details",
@@ -194,13 +196,13 @@
"column_break8",
"unrealized_profit_loss_account",
"against_income_account",
"sales_team_section_break",
"commission_section",
"sales_partner",
"amount_eligible_for_commission",
"column_break10",
"commission_rate",
"total_commission",
"section_break2",
"sales_team_section",
"sales_team",
"edit_printing_settings",
"letter_head",
@@ -215,20 +217,21 @@
"column_break_140",
"to_date",
"update_auto_repeat_reference",
"utm_analytics_section",
"utm_source",
"utm_medium",
"column_break_ixxw",
"utm_campaign",
"utm_content",
"more_information",
"status",
"inter_company_invoice_reference",
"represents_company",
"remarks",
"customer_group",
"column_break_imbx",
"utm_source",
"utm_campaign",
"utm_medium",
"utm_content",
"col_break23",
"is_internal_customer",
"represents_company",
"inter_company_invoice_reference",
"is_discounted",
"remarks",
"connections_tab"
],
"fields": [
@@ -703,6 +706,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
"fieldname": "update_stock",
"fieldtype": "Check",
"hide_days": 1,
@@ -793,7 +797,8 @@
"hide_seconds": 1,
"label": "Time Sheets",
"options": "Sales Invoice Timesheet",
"print_hide": 1
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
@@ -1072,14 +1077,6 @@
"no_copy": 1,
"options": "Cost Center"
},
{
"collapsible": 1,
"fieldname": "section_break_49",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Additional Discount"
},
{
"default": "Grand Total",
"fieldname": "apply_discount_on",
@@ -1124,22 +1121,12 @@
"options": "currency",
"print_hide": 1
},
{
"fieldname": "totals",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Totals",
"oldfieldtype": "Section Break",
"options": "fa fa-money",
"print_hide": 1
},
{
"fieldname": "base_grand_total",
"fieldtype": "Currency",
"hide_days": 1,
"hide_seconds": 1,
"label": "Grand Total (Company Currency)",
"label": "Grand Total",
"oldfieldname": "grand_total",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
@@ -1153,9 +1140,8 @@
"fieldtype": "Currency",
"hide_days": 1,
"hide_seconds": 1,
"label": "Rounding Adjustment (Company Currency)",
"label": "Rounding Adjustment",
"no_copy": 1,
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
@@ -1165,10 +1151,9 @@
"fieldtype": "Currency",
"hide_days": 1,
"hide_seconds": 1,
"label": "Rounded Total (Company Currency)",
"label": "Rounded Total",
"oldfieldname": "rounded_total",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
@@ -1178,7 +1163,7 @@
"fieldtype": "Small Text",
"hide_days": 1,
"hide_seconds": 1,
"label": "In Words (Company Currency)",
"label": "In Words",
"length": 240,
"oldfieldname": "in_words",
"oldfieldtype": "Data",
@@ -1271,7 +1256,6 @@
"read_only": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "advances",
"fieldname": "advances_section",
"fieldtype": "Section Break",
@@ -1647,13 +1631,6 @@
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "col_break23",
"fieldtype": "Column Break",
"hide_days": 1,
"hide_seconds": 1,
"width": "50%"
},
{
"default": "Draft",
"fieldname": "status",
@@ -1705,10 +1682,10 @@
"read_only": 1
},
{
"allow_on_submit": 1,
"default": "No",
"fieldname": "is_opening",
"fieldtype": "Select",
"hidden": 1,
"hide_days": 1,
"hide_seconds": 1,
"label": "Is Opening Entry",
@@ -1737,18 +1714,6 @@
"oldfieldtype": "Text",
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "sales_partner",
"fieldname": "sales_team_section_break",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Commission",
"oldfieldtype": "Section Break",
"options": "fa fa-group",
"print_hide": 1
},
{
"fieldname": "sales_partner",
"fieldtype": "Link",
@@ -1792,16 +1757,6 @@
"options": "Company:company:default_currency",
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "sales_team",
"fieldname": "section_break2",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Sales Team",
"print_hide": 1
},
{
"allow_on_submit": 1,
"fieldname": "sales_team",
@@ -2250,7 +2205,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
},
{
"default": "0",
@@ -2291,6 +2247,66 @@
"fieldname": "override_tax_withholding_entries",
"fieldtype": "Check",
"label": "Edit Tax Withholding Entries"
},
{
"fieldname": "totals_section",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Totals",
"oldfieldtype": "Section Break",
"options": "fa fa-money",
"print_hide": 1
},
{
"fieldname": "base_totals_section",
"fieldtype": "Section Break",
"label": "Totals (Company Currency)",
"options": "Company:company:default_currency"
},
{
"fieldname": "column_break_xjag",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "additional_discount_section",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Additional Discount"
},
{
"collapsible": 1,
"collapsible_depends_on": "sales_team",
"fieldname": "sales_team_section",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Sales Team",
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "sales_partner",
"fieldname": "commission_section",
"fieldtype": "Section Break",
"hide_days": 1,
"hide_seconds": 1,
"label": "Commission",
"oldfieldtype": "Section Break",
"options": "fa fa-group",
"print_hide": 1
},
{
"fieldname": "column_break_ixxw",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "UTM Analytics"
}
],
"grid_page_length": 50,
@@ -2304,7 +2320,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2025-12-24 18:29:50.242618",
"modified": "2026-02-10 11:59:07.819903",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -2470,7 +2470,10 @@ def make_delivery_note(source_name, target_doc=None):
"cost_center": "cost_center",
},
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail,
"condition": lambda doc: doc.delivered_by_supplier != 1
and not doc.scio_detail
and not doc.dn_detail
and doc.qty - doc.delivered_qty > 0,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {

View File

@@ -4739,6 +4739,66 @@ class TestSalesInvoice(ERPNextTestSuite):
doc.db_set("do_not_use_batchwise_valuation", original_value)
@change_settings("Selling Settings", {"set_zero_rate_for_expired_batch": True})
def test_zero_valuation_for_standalone_credit_note_with_expired_batch(self):
item_code = "_Test Item for Expiry Batch Zero Valuation"
make_item_for_si(
item_code,
{
"is_stock_item": 1,
"has_batch_no": 1,
"has_expiry_date": 1,
"shelf_life_in_days": 2,
"create_new_batch": 1,
"batch_number_series": "TBATCH-EBZV.####",
},
)
se = make_stock_entry(
item_code=item_code,
qty=10,
target="_Test Warehouse - _TC",
rate=100,
)
# fetch batch no from bundle
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
si = create_sales_invoice(
posting_date=add_days(nowdate(), 3),
item=item_code,
qty=-10,
rate=100,
is_return=1,
update_stock=1,
use_serial_batch_fields=1,
do_not_save=1,
do_not_submit=1,
)
si.items[0].batch_no = batch_no
si.save()
si.submit()
si.reload()
# check zero incoming rate in voucher
self.assertEqual(si.items[0].incoming_rate, 0.0)
# chekc zero incoming rate in stock ledger
stock_ledger_entry = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Sales Invoice",
"voucher_no": si.name,
"item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
},
["incoming_rate", "valuation_rate"],
as_dict=True,
)
self.assertEqual(stock_ledger_entry.incoming_rate, 0.0)
def make_item_for_si(item_code, properties=None):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -52,6 +52,7 @@
"is_free_item",
"apply_tds",
"grant_commission",
"allow_zero_valuation_rate",
"section_break_21",
"net_rate",
"net_amount",
@@ -88,7 +89,6 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"col_break5",
"allow_zero_valuation_rate",
"incoming_rate",
"item_tax_rate",
"actual_batch_qty",
@@ -580,6 +580,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.serial_no || doc.batch_no",
"depends_on": "eval:parent.update_stock",
"fieldname": "warehouse_and_reference",
"fieldtype": "Section Break",
"label": "Stock Details"
@@ -595,7 +596,7 @@
"print_hide": 1
},
{
"depends_on": "eval: parent.is_internal_customer && parent.update_stock",
"depends_on": "eval: parent.is_internal_customer",
"fieldname": "target_warehouse",
"fieldtype": "Link",
"hidden": 1,
@@ -613,7 +614,6 @@
"options": "Quality Inspection"
},
{
"depends_on": "eval: doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "batch_no",
"fieldtype": "Link",
"label": "Batch No",
@@ -626,6 +626,7 @@
},
{
"default": "0",
"depends_on": "eval:parent.update_stock",
"fieldname": "allow_zero_valuation_rate",
"fieldtype": "Check",
"label": "Allow Zero Valuation Rate",
@@ -633,7 +634,6 @@
"print_hide": 1
},
{
"depends_on": "eval: doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "serial_no",
"fieldtype": "Text",
"label": "Serial No",
@@ -906,7 +906,7 @@
"read_only": 1
},
{
"depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)",
"depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1",
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
@@ -916,7 +916,7 @@
"search_index": 1
},
{
"depends_on": "eval:parent.update_stock === 1",
"depends_on": "eval:doc.use_serial_batch_fields === 0 && doc.docstatus === 0",
"fieldname": "pick_serial_and_batch",
"fieldtype": "Button",
"label": "Pick Serial / Batch No"
@@ -1009,7 +1009,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-09-04 11:08:25.583561",
"modified": "2026-02-15 21:08:57.341638",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -26,7 +26,7 @@
},
{
"default": "0",
"depends_on": "eval:parent.doctype == 'Sales Invoice'",
"depends_on": "eval: [\"POS Invoice\", \"Sales Invoice\"].includes(parent.doctype)",
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
@@ -85,14 +85,15 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:36.427565",
"modified": "2026-02-16 20:46:34.592604",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Payment",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -43,16 +43,18 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:55.008837",
"modified": "2025-11-14 16:17:25.584675",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Transaction Deletion Record Details",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -17,7 +17,7 @@
</div>
<div class="col-xs-6">
<table>
<tr><td><strong>Date: </strong></td><td>{{ frappe.utils.format_date(doc.creation) }}</td></tr>
<tr><td><strong>Date: </strong></td><td>{{ frappe.utils.format_date(doc.posting_date) }}</td></tr>
</table>
</div>
</div>

View File

@@ -151,6 +151,8 @@ frappe.query_reports["Accounts Payable"] = {
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);

View File

@@ -108,6 +108,8 @@ frappe.query_reports["Accounts Payable Summary"] = {
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
onload: function (report) {
report.page.add_inner_button(__("Accounts Payable"), function () {

View File

@@ -178,6 +178,8 @@ frappe.query_reports["Accounts Receivable"] = {
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);

View File

@@ -131,6 +131,8 @@ frappe.query_reports["Accounts Receivable Summary"] = {
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
onload: function (report) {
report.page.add_inner_button(__("Accounts Receivable"), function () {

View File

@@ -232,11 +232,11 @@ def get_report_summary(
def get_chart_data(filters, columns, asset, liability, equity, currency):
labels = [d.get("label") for d in columns[2:]]
labels = [d.get("label") for d in columns[4:]]
asset_data, liability_data, equity_data = [], [], []
for p in columns[2:]:
for p in columns[4:]:
if asset:
asset_data.append(asset[-2].get(p.get("fieldname")))
if liability:

View File

@@ -8,7 +8,7 @@ from frappe.query_builder import Criterion, Tuple
from frappe.query_builder.functions import IfNull
from frappe.utils import getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
from pypika.terms import LiteralValue
from pypika.terms import Bracket, LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -84,10 +84,8 @@ class PartyLedgerSummaryReport:
from frappe.desk.reportview import build_match_conditions
match_conditions = build_match_conditions(party_type)
if match_conditions:
query = query.where(LiteralValue(match_conditions))
if match_conditions := build_match_conditions(party_type):
query = query.where(Bracket(LiteralValue(match_conditions)))
party_details = query.run(as_dict=True)

View File

@@ -11,7 +11,7 @@ import frappe
from frappe import _
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
from pypika.terms import Bracket, ExistsCriterion, LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -564,18 +564,15 @@ def get_accounting_entries(
account_filter_query = get_account_filter_query(root_lft, root_rgt, root_type, gl_entry)
query = query.where(ExistsCriterion(account_filter_query))
if group_by_account:
query = query.groupby("account")
from frappe.desk.reportview import build_match_conditions
query, params = query.walk()
match_conditions = build_match_conditions(doctype)
if match_conditions := build_match_conditions(doctype):
query = query.where(Bracket(LiteralValue(match_conditions)))
if match_conditions:
query += "and" + match_conditions
if group_by_account:
query += " GROUP BY `account`"
return frappe.db.sql(query, params, as_dict=True)
return query.run(as_dict=True)
def get_account_filter_query(root_lft, root_rgt, root_type, gl_entry):

View File

@@ -224,7 +224,7 @@ frappe.query_reports["General Ledger"] = {
},
],
collapsible_filters: true,
seperate_check_filters: true,
separate_check_filters: true,
};
erpnext.utils.add_dimensions("General Ledger", 15);

View File

@@ -324,10 +324,8 @@ def get_conditions(filters):
from frappe.desk.reportview import build_match_conditions
match_conditions = build_match_conditions("GL Entry")
if match_conditions:
conditions.append(match_conditions)
if match_conditions := build_match_conditions("GL Entry"):
conditions.append(f"({match_conditions})")
accounting_dimensions = get_accounting_dimensions(as_list=False)

View File

@@ -5,15 +5,16 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, scrub
from frappe.query_builder import Order
from frappe.query_builder import Case, Order
from frappe.query_builder.functions import Coalesce
from frappe.utils import cint, flt, formatdate
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_with_children,
)
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
from erpnext.controllers.queries import get_match_cond
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import get_incoming_rate
@@ -176,7 +177,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
column_names = get_column_names()
# to display item as Item Code: Item Name
columns[0] = "Sales Invoice:Link/Item:300"
columns[0]["fieldname"] = "sales_invoice"
columns[0]["options"] = "Item"
columns[0]["width"] = 300
# removing Item Code and Item Name columns
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
@@ -203,7 +206,11 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
data.append(row)
total_gross_profit = total_base_amount - total_buying_amount
total_gross_profit = flt(
total_base_amount + abs(total_buying_amount)
if total_buying_amount < 0
else total_base_amount - total_buying_amount,
)
data.append(
frappe._dict(
{
@@ -215,7 +222,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
"buying_amount": total_buying_amount,
"gross_profit": total_gross_profit,
"gross_profit_%": flt(
(total_gross_profit / total_base_amount) * 100.0,
(total_gross_profit / abs(total_base_amount)) * 100.0,
cint(frappe.db.get_default("currency_precision")) or 3,
)
if total_base_amount
@@ -248,9 +255,13 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
data.append(row)
total_gross_profit = total_base_amount - total_buying_amount
total_gross_profit = flt(
total_base_amount + abs(total_buying_amount)
if total_buying_amount < 0
else total_base_amount - total_buying_amount,
)
currency_precision = cint(frappe.db.get_default("currency_precision")) or 3
gross_profit_percent = (total_gross_profit / total_base_amount * 100.0) if total_base_amount else 0
gross_profit_percent = (total_gross_profit / abs(total_base_amount) * 100.0) if total_base_amount else 0
total_row = {
group_columns[0]: "Total",
@@ -581,10 +592,15 @@ class GrossProfitGenerator:
base_amount += row.base_amount
# calculate gross profit
row.gross_profit = flt(row.base_amount - row.buying_amount, self.currency_precision)
row.gross_profit = flt(
row.base_amount + abs(row.buying_amount)
if row.buying_amount < 0
else row.base_amount - row.buying_amount,
self.currency_precision,
)
if row.base_amount:
row.gross_profit_percent = flt(
(row.gross_profit / row.base_amount) * 100.0,
(row.gross_profit / abs(row.base_amount)) * 100.0,
self.currency_precision,
)
else:
@@ -673,9 +689,14 @@ class GrossProfitGenerator:
return new_row
def set_average_gross_profit(self, new_row):
new_row.gross_profit = flt(new_row.base_amount - new_row.buying_amount, self.currency_precision)
new_row.gross_profit = flt(
new_row.base_amount + abs(new_row.buying_amount)
if new_row.buying_amount < 0
else new_row.base_amount - new_row.buying_amount,
self.currency_precision,
)
new_row.gross_profit_percent = (
flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision)
flt(((new_row.gross_profit / abs(new_row.base_amount)) * 100.0), self.currency_precision)
if new_row.base_amount
else 0
)
@@ -851,129 +872,173 @@ class GrossProfitGenerator:
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
def load_invoice_items(self):
conditions = ""
if self.filters.company:
conditions += " and `tabSales Invoice`.company = %(company)s"
if self.filters.from_date:
conditions += " and posting_date >= %(from_date)s"
if self.filters.to_date:
conditions += " and posting_date <= %(to_date)s"
self.si_list = []
SalesInvoice = frappe.qb.DocType("Sales Invoice")
base_query = self.prepare_invoice_query()
if self.filters.include_returned_invoices:
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
invoice_query = base_query.where(
(SalesInvoice.is_return == 0)
| ((SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnull())
)
else:
conditions += " and is_return = 0"
invoice_query = base_query.where(SalesInvoice.is_return == 0)
if self.filters.item_group:
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
self.si_list += invoice_query.run(as_dict=True)
self.prepare_vouchers_to_ignore()
if self.filters.sales_person:
conditions += """
and exists(select 1
from `tabSales Team` st
where st.parent = `tabSales Invoice`.name
and st.sales_person = %(sales_person)s)
"""
ret_invoice_query = base_query.where(
(SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull()
)
if self.vouchers_to_ignore:
ret_invoice_query = ret_invoice_query.where(
SalesInvoice.return_against.notin(self.vouchers_to_ignore)
)
self.si_list += ret_invoice_query.run(as_dict=True)
def prepare_invoice_query(self):
SalesInvoice = frappe.qb.DocType("Sales Invoice")
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
Item = frappe.qb.DocType("Item")
SalesTeam = frappe.qb.DocType("Sales Team")
PaymentSchedule = frappe.qb.DocType("Payment Schedule")
query = (
frappe.qb.from_(SalesInvoice)
.join(SalesInvoiceItem)
.on(SalesInvoiceItem.parent == SalesInvoice.name)
.join(Item)
.on(Item.name == SalesInvoiceItem.item_code)
.where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes"))
)
query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item)
query = query.select(
SalesInvoiceItem.parenttype,
SalesInvoiceItem.parent,
SalesInvoice.posting_date,
SalesInvoice.posting_time,
SalesInvoice.project,
SalesInvoice.update_stock,
SalesInvoice.customer,
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
SalesInvoiceItem.description,
SalesInvoiceItem.warehouse,
SalesInvoiceItem.item_group,
SalesInvoiceItem.brand,
SalesInvoiceItem.so_detail,
SalesInvoiceItem.sales_order,
SalesInvoiceItem.dn_detail,
SalesInvoiceItem.delivery_note,
SalesInvoiceItem.stock_qty.as_("qty"),
SalesInvoiceItem.base_net_rate,
SalesInvoiceItem.base_net_amount,
SalesInvoiceItem.name.as_("item_row"),
SalesInvoice.is_return,
SalesInvoiceItem.cost_center,
SalesInvoiceItem.serial_and_batch_bundle,
)
if self.filters.group_by == "Sales Person":
sales_person_cols = """, sales.sales_person,
sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount,
sales.incentives
"""
sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name"
else:
sales_person_cols = ""
sales_team_table = ""
query = query.select(
SalesTeam.sales_person,
(SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_(
"allocated_amount"
),
SalesTeam.incentives,
)
query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name)
if self.filters.group_by == "Payment Term":
payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
'{}',
coalesce(schedule.payment_term, '{}')) as payment_term,
schedule.invoice_portion,
schedule.payment_amount """.format(_("Sales Return"), _("No Terms"))
payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
`tabSales Invoice`.is_return = 0 """
else:
payment_term_cols = ""
payment_term_table = ""
query = query.select(
Case()
.when(SalesInvoice.is_return == 1, _("Sales Return"))
.else_(Coalesce(PaymentSchedule.payment_term, _("No Terms")))
.as_("payment_term"),
PaymentSchedule.invoice_portion,
PaymentSchedule.payment_amount,
)
if self.filters.get("sales_invoice"):
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
query = query.left_join(PaymentSchedule).on(
(PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0)
)
if self.filters.get("item_code"):
conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s"
query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby(
SalesInvoice.posting_time, order=Order.desc
)
if self.filters.get("cost_center"):
return query
def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item):
if self.filters.company:
query = query.where(SalesInvoice.company == self.filters.company)
if self.filters.from_date:
query = query.where(SalesInvoice.posting_date >= self.filters.from_date)
if self.filters.to_date:
query = query.where(SalesInvoice.posting_date <= self.filters.to_date)
if self.filters.item_group:
query = query.where(get_item_group_condition(self.filters.item_group, Item))
if self.filters.sales_person:
query = query.where(
ExistsCriterion(
frappe.qb.from_(SalesTeam)
.select(1)
.where(
(SalesTeam.parent == SalesInvoice.name)
& (SalesTeam.sales_person == self.filters.sales_person)
)
)
)
if self.filters.sales_invoice:
query = query.where(SalesInvoice.name == self.filters.sales_invoice)
if self.filters.item_code:
query = query.where(SalesInvoiceItem.item_code == self.filters.item_code)
if self.filters.cost_center:
self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center"))
self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center)
conditions += " and `tabSales Invoice Item`.cost_center in %(cost_center)s"
query = query.where(SalesInvoiceItem.cost_center.isin(self.filters.cost_center))
if self.filters.get("project"):
if self.filters.project:
self.filters.project = frappe.parse_json(self.filters.get("project"))
conditions += " and `tabSales Invoice Item`.project in %(project)s"
query = query.where(SalesInvoiceItem.project.isin(self.filters.project))
accounting_dimensions = get_accounting_dimensions(as_list=False)
if accounting_dimensions:
for dimension in accounting_dimensions:
if self.filters.get(dimension.fieldname):
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
self.filters[dimension.fieldname] = get_dimension_with_children(
dimension.document_type, self.filters.get(dimension.fieldname)
)
conditions += (
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
)
else:
conditions += (
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
)
for dim in get_accounting_dimensions(as_list=False) or []:
if self.filters.get(dim.fieldname):
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
self.filters[dim.fieldname] = get_dimension_with_children(
dim.document_type, self.filters.get(dim.fieldname)
)
query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.fieldname]))
if self.filters.get("warehouse"):
warehouse_details = frappe.db.get_value(
"Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1
if self.filters.warehouse:
lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"])
WH = frappe.qb.DocType("Warehouse")
query = query.where(
SalesInvoiceItem.warehouse.isin(
frappe.qb.from_(WH).select(WH.name).where((WH.lft >= lft) & (WH.rgt <= rgt))
)
)
if warehouse_details:
conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)"
self.si_list = frappe.db.sql(
"""
select
`tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent,
`tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time,
`tabSales Invoice`.project, `tabSales Invoice`.update_stock,
`tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name,
`tabSales Invoice`.territory, `tabSales Invoice Item`.item_code,
`tabSales Invoice`.base_net_total as "invoice_base_net_total",
`tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description,
`tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group,
`tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail,
`tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail,
`tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty,
`tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount,
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
`tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle
{sales_person_cols}
{payment_term_cols}
from
`tabSales Invoice` inner join `tabSales Invoice Item`
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
join `tabItem` item on item.name = `tabSales Invoice Item`.item_code
{sales_team_table}
{payment_term_table}
where
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
order by
`tabSales Invoice`.posting_date desc, `tabSales Invoice`.posting_time desc""".format(
conditions=conditions,
sales_person_cols=sales_person_cols,
sales_team_table=sales_team_table,
payment_term_cols=payment_term_cols,
payment_term_table=payment_term_table,
match_cond=get_match_cond("Sales Invoice"),
),
self.filters,
as_dict=1,
)
return query
def prepare_vouchers_to_ignore(self):
self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list)
def get_delivery_notes(self):
self.delivery_notes = frappe._dict({})

View File

@@ -470,7 +470,7 @@ class TestGrossProfit(IntegrationTestCase):
"selling_amount": -100.0,
"buying_amount": 0.0,
"gross_profit": -100.0,
"gross_profit_%": 100.0,
"gross_profit_%": -100.0,
}
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
@@ -649,21 +649,24 @@ class TestGrossProfit(IntegrationTestCase):
def test_profit_for_later_period_return(self):
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
sales_inv_date = month_start_date
return_inv_date = add_days(month_end_date, 1)
# create sales invoice on month start date
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
sinv.set_posting_time = 1
sinv.posting_date = month_start_date
sinv.posting_date = sales_inv_date
sinv.save().submit()
# create credit note on next month start date
cr_note = make_sales_return(sinv.name)
cr_note.set_posting_time = 1
cr_note.posting_date = add_days(month_end_date, 1)
cr_note.posting_date = return_inv_date
cr_note.save().submit()
# apply filters for invoiced period
filters = frappe._dict(
company=self.company, from_date=month_start_date, to_date=month_end_date, group_by="Invoice"
company=self.company, from_date=month_start_date, to_date=month_start_date, group_by="Invoice"
)
_, data = execute(filters=filters)
@@ -675,7 +678,7 @@ class TestGrossProfit(IntegrationTestCase):
self.assertEqual(total.get("gross_profit_%"), 100.0)
# extend filters upto returned period
filters.update(to_date=add_days(month_end_date, 1))
filters.update({"to_date": return_inv_date})
_, data = execute(filters=filters)
total = data[-1]
@@ -684,3 +687,63 @@ class TestGrossProfit(IntegrationTestCase):
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.gross_profit, 0.0)
self.assertEqual(total.get("gross_profit_%"), 0.0)
# apply filters only on returned period
filters.update({"from_date": return_inv_date, "to_date": return_inv_date})
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, -100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.gross_profit, -100.0)
self.assertEqual(total.get("gross_profit_%"), -100.0)
def test_sales_person_wise_gross_profit(self):
sales_person = make_sales_person("_Test Sales Person")
posting_date = get_first_day(nowdate())
qty = 10
rate = 100
sinv = self.create_sales_invoice(qty=qty, rate=rate, do_not_save=True, do_not_submit=True)
sinv.set_posting_time = 1
sinv.posting_date = posting_date
sinv.append(
"sales_team",
{
"sales_person": sales_person.name,
"allocated_percentage": 100,
"allocated_amount": 1000.0,
"commission_rate": 5,
"incentives": 5,
},
)
sinv.save().submit()
filters = frappe._dict(
company=self.company, from_date=posting_date, to_date=posting_date, group_by="Sales Person"
)
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total[5], 1000.0)
self.assertEqual(total[6], 0.0)
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
sales_person_doc = frappe.get_doc(
{
"doctype": "Sales Person",
"is_group": 0,
"parent_sales_person": "Sales Team",
"sales_person_name": sales_person_name,
}
).insert(ignore_permissions=True)
else:
sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name})
return sales_person_doc

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.utils import flt
from pypika.terms import Bracket, LiteralValue
import erpnext
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
@@ -361,15 +362,12 @@ def get_items(filters, additional_table_columns):
from frappe.desk.reportview import build_match_conditions
query, params = query.walk()
match_conditions = build_match_conditions(doctype)
if match_conditions:
query += " and " + match_conditions
if match_conditions := build_match_conditions(doctype):
query = query.where(Bracket(LiteralValue(match_conditions)))
query = apply_order_by_conditions(doctype, query, filters)
return frappe.db.sql(query, params, as_dict=True)
return query.run(as_dict=True)
def get_aii_accounts():

View File

@@ -8,6 +8,7 @@ from frappe.query_builder import functions as fn
from frappe.utils import flt
from frappe.utils.nestedset import get_descendants_of
from frappe.utils.xlsxutils import handle_html
from pypika.terms import Bracket, LiteralValue, Order
from erpnext.accounts.report.sales_register.sales_register import get_mode_of_payments
from erpnext.accounts.report.utils import get_values_for_columns
@@ -390,20 +391,21 @@ def apply_conditions(query, si, sii, sip, filters, additional_conditions=None):
def apply_order_by_conditions(doctype, query, filters):
invoice = f"`tab{doctype}`"
invoice_item = f"`tab{doctype} Item`"
invoice = frappe.qb.DocType(doctype)
invoice_item = frappe.qb.DocType(f"{doctype} Item")
if not filters.get("group_by"):
query += f" order by {invoice}.posting_date desc, {invoice_item}.item_group desc"
query = query.orderby(invoice.posting_date, order=Order.desc)
query = query.orderby(invoice_item.item_group, order=Order.desc)
elif filters.get("group_by") == "Invoice":
query += f" order by {invoice_item}.parent desc"
query = query.orderby(invoice_item.parent, order=Order.desc)
elif filters.get("group_by") == "Item":
query += f" order by {invoice_item}.item_code"
query = query.orderby(invoice_item.item_code)
elif filters.get("group_by") == "Item Group":
query += f" order by {invoice_item}.item_group"
query = query.orderby(invoice_item.item_group)
elif filters.get("group_by") in ("Customer", "Customer Group", "Territory", "Supplier"):
filter_field = frappe.scrub(filters.get("group_by"))
query += f" order by {filter_field} desc"
query = query.orderby(filter_field, order=Order.desc)
return query
@@ -481,15 +483,12 @@ def get_items(filters, additional_query_columns, additional_conditions=None):
from frappe.desk.reportview import build_match_conditions
query, params = query.walk()
match_conditions = build_match_conditions(doctype)
if match_conditions:
query += " and " + match_conditions
if match_conditions := build_match_conditions(doctype):
query = query.where(Bracket(LiteralValue(match_conditions)))
query = apply_order_by_conditions(doctype, query, filters)
return frappe.db.sql(query, params, as_dict=True)
return query.run(as_dict=True)
def get_delivery_notes_against_sales_order(item_list):

View File

@@ -163,11 +163,11 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co
def get_chart_data(filters, columns, income, expense, net_profit_loss, currency):
labels = [d.get("label") for d in columns[2:]]
labels = [d.get("label") for d in columns[4:]]
income_data, expense_data, net_profit = [], [], []
for p in columns[2:]:
for p in columns[4:]:
if income:
income_data.append(income[-2].get(p.get("fieldname")))
if expense:

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import _
from frappe import _, qb
from frappe.query_builder import Criterion
from frappe.utils import cstr, flt
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
@@ -33,11 +34,19 @@ def execute(filters=None):
def get_accounts_data(based_on, company):
if based_on == "Cost Center":
return frappe.db.sql(
"""select name, parent_cost_center as parent_account, cost_center_name as account_name, lft, rgt
from `tabCost Center` where company=%s order by name""",
company,
as_dict=True,
cc = qb.DocType("Cost Center")
return (
qb.from_(cc)
.select(
cc.name,
cc.parent_cost_center.as_("parent_account"),
cc.cost_center_name.as_("account_name"),
cc.lft,
cc.rgt,
)
.where(cc.company.eq(company))
.orderby(cc.name)
.run(as_dict=True)
)
elif based_on == "Project":
return frappe.get_all("Project", fields=["name"], filters={"company": company}, order_by="name")
@@ -206,27 +215,38 @@ def set_gl_entries_by_account(
company, from_date, to_date, based_on, gl_entries_by_account, ignore_closing_entries=False
):
"""Returns a dict like { "account": [gl entries], ... }"""
additional_conditions = []
gl = qb.DocType("GL Entry")
acc = qb.DocType("Account")
conditions = []
conditions.append(gl.company.eq(company))
conditions.append(gl[based_on].notnull())
conditions.append(gl.is_cancelled.eq(0))
if from_date and to_date:
conditions.append(gl.posting_date.between(from_date, to_date))
elif from_date and not to_date:
conditions.append(gl.posting_date.gte(from_date))
elif not from_date and to_date:
conditions.append(gl.posting_date.lte(to_date))
if ignore_closing_entries:
additional_conditions.append("and voucher_type !='Period Closing Voucher'")
conditions.append(gl.voucher_type.ne("Period Closing Voucher"))
if from_date:
additional_conditions.append("and posting_date >= %(from_date)s")
gl_entries = frappe.db.sql(
"""select posting_date, {based_on} as based_on, debit, credit,
is_opening, (select root_type from `tabAccount` where name = account) as type
from `tabGL Entry` where company=%(company)s
{additional_conditions}
and posting_date <= %(to_date)s
and {based_on} is not null
and is_cancelled = 0
order by {based_on}, posting_date""".format(
additional_conditions="\n".join(additional_conditions), based_on=based_on
),
{"company": company, "from_date": from_date, "to_date": to_date},
as_dict=True,
root_subquery = qb.from_(acc).select(acc.root_type).where(acc.name.eq(gl.account))
gl_entries = (
qb.from_(gl)
.select(
gl.posting_date,
gl[based_on].as_("based_on"),
gl.debit,
gl.credit,
gl.is_opening,
root_subquery.as_("type"),
)
.where(Criterion.all(conditions))
.orderby(gl[based_on], gl.posting_date)
.run(as_dict=True)
)
for entry in gl_entries:

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _, msgprint
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, getdate
from pypika.terms import Bracket, LiteralValue, Order
from erpnext.accounts.party import get_party_account
from erpnext.accounts.report.utils import (
@@ -421,15 +422,13 @@ def get_invoices(filters, additional_query_columns):
from frappe.desk.reportview import build_match_conditions
query, params = query.walk()
match_conditions = build_match_conditions("Purchase Invoice")
if match_conditions := build_match_conditions("Purchase Invoice"):
query = query.where(Bracket(LiteralValue(match_conditions)))
if match_conditions:
query += " and " + match_conditions
query = query.orderby("posting_date", order=Order.desc)
query = query.orderby("name", order=Order.desc)
query += " order by posting_date desc, name desc"
return frappe.db.sql(query, params, as_dict=True)
return query.run(as_dict=True)
def get_conditions(filters, query, doctype):

View File

@@ -7,6 +7,7 @@ from frappe import _, msgprint
from frappe.model.meta import get_field_precision
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, getdate
from pypika.terms import Bracket, LiteralValue, Order
from erpnext.accounts.party import get_party_account
from erpnext.accounts.report.utils import (
@@ -457,15 +458,13 @@ def get_invoices(filters, additional_query_columns):
from frappe.desk.reportview import build_match_conditions
query, params = query.walk()
match_conditions = build_match_conditions("Sales Invoice")
if match_conditions := build_match_conditions("Sales Invoice"):
query = query.where(Bracket(LiteralValue(match_conditions)))
if match_conditions:
query += " and " + match_conditions
query = query.orderby("posting_date", order=Order.desc)
query = query.orderby("name", order=Order.desc)
query += " order by posting_date desc, name desc"
return frappe.db.sql(query, params, as_dict=True)
return query.run(as_dict=True)
def get_conditions(filters, query, doctype):

View File

@@ -154,17 +154,11 @@ def get_columns(filters):
"width": 60,
},
{
"label": _("Total Amount"),
"label": _("Taxable Amount"),
"fieldname": "total_amount",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Base Total"),
"fieldname": "base_total",
"fieldtype": "Currency",
"width": 120,
},
{
"label": _("Tax Amount"),
"fieldname": "tax_amount",
@@ -172,10 +166,16 @@ def get_columns(filters):
"width": 120,
},
{
"label": _("Grand Total"),
"label": _("Grand Total (Company Currency)"),
"fieldname": "base_total",
"fieldtype": "Currency",
"width": 150,
},
{
"label": _("Grand Total (Transaction Currency)"),
"fieldname": "grand_total",
"fieldtype": "Currency",
"width": 120,
"width": 170,
},
{
"label": _("Reference Date"),

View File

@@ -106,7 +106,7 @@ def get_columns(filters):
"width": 120,
},
{
"label": _("Total Amount"),
"label": _("Total Taxable Amount"),
"fieldname": "total_amount",
"fieldtype": "Float",
"width": 120,

View File

@@ -11,6 +11,7 @@ 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.model.naming import determine_consecutive_week_number
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
from frappe.query_builder.utils import DocType
@@ -25,6 +26,7 @@ from frappe.utils import (
get_number_format_info,
getdate,
now,
now_datetime,
nowdate,
)
from frappe.utils.caching import site_cache
@@ -66,6 +68,7 @@ def get_fiscal_year(
as_dict=False,
boolean=None,
raise_on_missing=True,
truncate=False,
):
if isinstance(raise_on_missing, str):
raise_on_missing = loads(raise_on_missing)
@@ -79,7 +82,14 @@ def get_fiscal_year(
fiscal_years = get_fiscal_years(
date, fiscal_year, label, verbose, company, as_dict=as_dict, raise_on_missing=raise_on_missing
)
return False if not fiscal_years else fiscal_years[0]
if fiscal_years:
fiscal_year = fiscal_years[0]
if truncate:
return ("-".join(y[-2:] for y in fiscal_year[0].split("-")), fiscal_year[1], fiscal_year[2])
return fiscal_year
return False
def get_fiscal_years(
@@ -547,6 +557,7 @@ def reconcile_against_document(
doc.make_advance_gl_entries(entry=row)
else:
_delete_pl_entries(voucher_type, voucher_no)
_delete_adv_pl_entries(voucher_type, voucher_no)
gl_map = doc.build_gl_map()
# Make sure there is no overallocation
from erpnext.accounts.general_ledger import process_debit_credit_difference
@@ -662,6 +673,7 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
d["allocated_amount"] = d["allocated_amount"] * -1
d["unadjusted_amount"] = d["unadjusted_amount"] * -1
insert_position = -1
if flt(d["unadjusted_amount"]) - flt(d["allocated_amount"]) != 0:
# adjust the unreconciled balance
amount_in_account_currency = flt(d["unadjusted_amount"]) - flt(d["allocated_amount"])
@@ -673,9 +685,10 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
)
else:
journal_entry.remove(jv_detail)
insert_position += jv_detail.idx
# new row with references
new_row = journal_entry.append("accounts")
new_row = journal_entry.append("accounts", position=insert_position)
# Copy field values into new row
[
@@ -1500,14 +1513,14 @@ def get_autoname_with_number(number_value, doc_title, company):
def parse_naming_series_variable(doc, variable):
if variable == "FY":
if variable in ["FY", "TFY"]:
if doc:
date = doc.get("posting_date") or doc.get("transaction_date") or getdate()
company = doc.get("company")
else:
date = getdate()
company = None
return get_fiscal_year(date=date, company=company)[0]
return get_fiscal_year(date=date, company=company, truncate=variable == "TFY")[0]
elif variable == "ABBR":
if doc:
@@ -1517,6 +1530,18 @@ def parse_naming_series_variable(doc, variable):
return frappe.db.get_value("Company", company, "abbr") if company else ""
else:
data = {"YY": "%y", "YYYY": "%Y", "MM": "%m", "DD": "%d", "JJJ": "%j"}
date = (
(
getdate(doc.get("posting_date") or doc.get("transaction_date") or doc.get("posting_datetime"))
or now_datetime()
)
if frappe.get_single_value("Global Defaults", "use_posting_datetime_for_naming_documents")
else now_datetime()
)
return date.strftime(data[variable]) if variable in data else determine_consecutive_week_number(date)
@frappe.whitelist()
def get_coa(doctype, parent, is_root=None, chart=None):
@@ -1946,6 +1971,7 @@ def get_payment_ledger_entries(gl_entries, cancel=0):
account=gle.account,
party_type=gle.party_type,
party=gle.party,
project=gle.project,
cost_center=gle.cost_center,
finance_book=gle.finance_book,
due_date=gle.due_date,

View File

@@ -14,10 +14,10 @@
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"idx": 3,
"idx": 4,
"indicator_color": "",
"is_hidden": 0,
"label": "Accounting",
"label": "Invoicing",
"links": [
{
"hidden": 0,
@@ -587,10 +587,10 @@
"type": "Link"
}
],
"modified": "2025-12-24 13:20:34.857205",
"modified": "2026-01-23 11:05:47.246213",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounting",
"name": "Invoicing",
"number_cards": [
{
"label": "Outgoing Bills",
@@ -617,6 +617,6 @@
"roles": [],
"sequence_id": 2.0,
"shortcuts": [],
"title": "Accounting",
"title": "Invoicing",
"type": "Workspace"
}

View File

@@ -6,11 +6,11 @@
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_date\":\"frappe.datetime.add_months(frappe.datetime.nowdate(), -12)\",\"to_date\":\"frappe.datetime.nowdate()\"}",
"filters_json": "{\"status\":\"In Location\",\"group_by\":\"Asset Category\",\"is_existing_asset\":0}",
"filters_json": "{\"status\":\"In Location\",\"group_by\":\"Asset Category\",\"asset_type\":[\"!=\",\"Existing Asset\"]}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2020-10-28 23:16:16.939070",
"modified": "2026-02-03 15:48:13.407835",
"modified_by": "Administrator",
"module": "Assets",
"name": "Category-wise Asset Value",

View File

@@ -6,11 +6,11 @@
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_date\":\"frappe.datetime.add_months(frappe.datetime.nowdate(), -12)\",\"to_date\":\"frappe.datetime.nowdate()\"}",
"filters_json": "{\"status\":\"In Location\",\"group_by\":\"Location\",\"is_existing_asset\":0}",
"filters_json": "{\"status\":\"In Location\",\"group_by\":\"Location\",\"asset_type\":[\"!=\",\"Existing Asset\"]}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2020-10-28 23:16:07.883312",
"modified": "2026-02-03 15:48:13.407835",
"modified_by": "Administrator",
"module": "Assets",
"name": "Location-wise Asset Value",

View File

@@ -100,7 +100,7 @@ def get_charts(fiscal_year, year_start_date, year_end_date):
"company": company,
"status": "In Location",
"group_by": "Asset Category",
"is_existing_asset": 0,
"asset_type": ["!=", "Existing Asset"],
}
),
"type": "Donut",
@@ -126,7 +126,12 @@ def get_charts(fiscal_year, year_start_date, year_end_date):
"x_field": "location",
"timeseries": 0,
"filters_json": json.dumps(
{"company": company, "status": "In Location", "group_by": "Location", "is_existing_asset": 0}
{
"company": company,
"status": "In Location",
"group_by": "Location",
"asset_type": ["!=", "Existing Asset"],
}
),
"type": "Donut",
"doctype": "Dashboard Chart",

View File

@@ -81,23 +81,79 @@ frappe.ui.form.on("Asset", {
},
before_submit: function (frm) {
if (frm.doc.is_composite_asset && !frm.has_active_capitalization) {
if (frm.doc.asset_type == "Composite Asset" && !frm.has_active_capitalization) {
frappe.throw(__("Please capitalize this asset before submitting."));
}
},
refresh: function (frm) {
frappe.ui.form.trigger("Asset", "is_existing_asset");
refresh: async function (frm) {
frappe.ui.form.trigger("Asset", "asset_type");
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
let has_create_buttons = false;
if (frm.doc.docstatus == 1) {
if (["Submitted", "Partially Depreciated"].includes(frm.doc.status)) {
frm.add_custom_button(
__("Asset Value Adjustment"),
function () {
frm.trigger("create_asset_value_adjustment");
},
__("Create")
);
frm.add_custom_button(
__("Asset Repair"),
function () {
frm.trigger("create_asset_repair");
},
__("Create")
);
has_create_buttons = true;
}
if (
!frm.doc.calculate_depreciation &&
["Submitted", "Partially Depreciated", "Fully Depreciated"].includes(frm.doc.status)
) {
frm.add_custom_button(
__("Depreciation Entry"),
function () {
frm.trigger("make_journal_entry");
},
__("Create")
);
has_create_buttons = true;
}
if (has_create_buttons) {
frm.page.set_inner_btn_group_as_primary(__("Create"));
}
if (["Submitted", "Partially Depreciated", "Fully Depreciated"].includes(frm.doc.status)) {
if (frm.doc.maintenance_required && !frm.doc.maintenance_schedule) {
frm.add_custom_button(
__("Maintain Asset"),
function () {
frm.trigger("create_asset_maintenance");
},
__("Actions")
);
}
frm.add_custom_button(
__("Split Asset"),
function () {
frm.trigger("split_asset");
},
__("Actions")
);
frm.add_custom_button(
__("Transfer Asset"),
function () {
erpnext.asset.transfer_asset(frm);
},
__("Manage")
__("Actions")
);
frm.add_custom_button(
@@ -105,7 +161,7 @@ frappe.ui.form.on("Asset", {
function () {
erpnext.asset.scrap_asset(frm);
},
__("Manage")
__("Actions")
);
frm.add_custom_button(
@@ -113,23 +169,7 @@ frappe.ui.form.on("Asset", {
function () {
frm.trigger("sell_asset");
},
__("Manage")
);
frm.add_custom_button(
__("Repair Asset"),
function () {
frm.trigger("create_asset_repair");
},
__("Manage")
);
frm.add_custom_button(
__("Split Asset"),
function () {
frm.trigger("split_asset");
},
__("Manage")
__("Actions")
);
} else if (frm.doc.status == "Scrapped") {
frm.add_custom_button(__("Restore Asset"), function () {
@@ -137,39 +177,9 @@ frappe.ui.form.on("Asset", {
}).addClass("btn-primary");
}
if (frm.doc.maintenance_required && !frm.doc.maintenance_schedule) {
if (await frm.events.should_show_accounting_ledger(frm)) {
frm.add_custom_button(
__("Maintain Asset"),
function () {
frm.trigger("create_asset_maintenance");
},
__("Manage")
);
}
if (["Submitted", "Partially Depreciated"].includes(frm.doc.status)) {
frm.add_custom_button(
__("Adjust Asset Value"),
function () {
frm.trigger("create_asset_value_adjustment");
},
__("Manage")
);
}
if (!frm.doc.calculate_depreciation) {
frm.add_custom_button(
__("Create Depreciation Entry"),
function () {
frm.trigger("make_journal_entry");
},
__("Manage")
);
}
if (frm.doc.purchase_receipt || !frm.doc.is_existing_asset) {
frm.add_custom_button(
__("View General Ledger"),
__("Accounting Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
@@ -179,7 +189,7 @@ frappe.ui.form.on("Asset", {
};
frappe.set_route("query-report", "General Ledger");
},
__("Manage")
__("View")
);
}
@@ -195,7 +205,7 @@ frappe.ui.form.on("Asset", {
if (frm.doc.docstatus == 0) {
frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation);
if (frm.doc.is_composite_asset) {
if (frm.doc.asset_type == "Composite Asset") {
frappe.call({
method: "erpnext.assets.doctype.asset.asset.has_active_capitalization",
args: {
@@ -217,6 +227,28 @@ frappe.ui.form.on("Asset", {
}
},
should_show_accounting_ledger: async function (frm) {
if (["Capitalized"].includes(frm.doc.status)) {
return false;
}
if (
!frm.doc.purchase_receipt &&
!frm.doc.purchase_invoice &&
["Existing Asset", "Composite Component"].includes(frm.doc.asset_type)
) {
return false;
}
const asset_category = await frappe.db.get_value(
"Asset Category",
frm.doc.asset_category,
"enable_cwip_accounting"
);
return !!asset_category.message?.enable_cwip_accounting;
},
set_depr_posting_failure_alert: function (frm) {
const alert = `
<div class="row">
@@ -232,7 +264,8 @@ frappe.ui.form.on("Asset", {
toggle_reference_doc: function (frm) {
const is_submitted = frm.doc.docstatus === 1;
const is_special_asset = frm.doc.is_existing_asset || frm.doc.is_composite_asset;
const is_special_asset =
frm.doc.asset_type == "Existing Asset" || frm.doc.asset_type == "Composite Asset";
const clear_field = (field) => {
if (frm.doc[field]) {
@@ -508,15 +541,13 @@ frappe.ui.form.on("Asset", {
});
},
is_existing_asset: function (frm) {
frm.trigger("toggle_reference_doc");
},
is_composite_asset: function (frm) {
if (frm.doc.is_composite_asset) {
frm.set_value("net_purchase_amount", 0);
} else {
frm.set_df_property("net_purchase_amount", "read_only", 0);
asset_type: function (frm) {
if (frm.doc.docstatus == 0) {
if (frm.doc.asset_type == "Composite Asset") {
frm.set_value("net_purchase_amount", 0);
} else {
frm.set_df_property("net_purchase_amount", "read_only", 0);
}
}
frm.trigger("toggle_reference_doc");
},

View File

@@ -9,20 +9,17 @@
"engine": "InnoDB",
"field_order": [
"naming_series",
"company",
"item_code",
"item_name",
"asset_name",
"asset_category",
"location",
"image",
"column_break_3",
"status",
"company",
"asset_owner",
"asset_owner_company",
"is_existing_asset",
"is_composite_asset",
"is_composite_component",
"location",
"asset_category",
"asset_type",
"maintenance_required",
"calculate_depreciation",
"purchase_details_section",
"purchase_receipt",
"purchase_receipt_item",
@@ -30,31 +27,44 @@
"purchase_invoice_item",
"purchase_date",
"available_for_use_date",
"disposal_date",
"column_break_23",
"net_purchase_amount",
"purchase_amount",
"asset_quantity",
"additional_asset_cost",
"section_break_uiyd",
"column_break_bbwr",
"column_break_bfkm",
"total_asset_cost",
"disposal_date",
"depreciation_tab",
"calculate_depreciation",
"column_break_33",
"column_break_wqzi",
"opening_accumulated_depreciation",
"opening_number_of_booked_depreciations",
"is_fully_depreciated",
"column_break_33",
"opening_number_of_booked_depreciations",
"section_break_36",
"finance_books",
"section_break_33",
"depreciation_method",
"value_after_depreciation",
"total_number_of_depreciations",
"column_break_24",
"frequency_of_depreciation",
"column_break_24",
"next_depreciation_date",
"total_number_of_depreciations",
"depreciation_schedule_sb",
"depreciation_schedule_view",
"insurance_details_tab",
"other_info_tab",
"accounting_dimensions_section",
"cost_center",
"column_break_rjyw",
"asset_owner_section",
"asset_owner",
"column_break_yeds",
"asset_owner_company",
"customer",
"supplier",
"insurance_section",
"policy_number",
"insurer",
"insured_value",
@@ -62,22 +72,17 @@
"insurance_start_date",
"insurance_end_date",
"comprehensive_insurance",
"other_info_tab",
"accounting_dimensions_section",
"cost_center",
"section_break_jtou",
"status",
"custodian",
"department",
"default_finance_book",
"depr_entry_posting_status",
"booked_fixed_asset",
"customer",
"supplier",
"column_break_51",
"department",
"split_from",
"journal_entry_for_scrap",
"split_from",
"amended_from",
"maintenance_required",
"booked_fixed_asset",
"connections_tab"
],
"fields": [
@@ -106,13 +111,6 @@
"options": "Item",
"reqd": 1
},
{
"depends_on": "item_code",
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Read Only",
"label": "Item Name"
},
{
"depends_on": "item_code",
"fetch_from": "item_code.asset_category",
@@ -207,7 +205,7 @@
"fieldname": "purchase_date",
"fieldtype": "Date",
"label": "Purchase Date",
"read_only_depends_on": "eval:!doc.is_existing_asset && !doc.is_composite_asset",
"read_only_depends_on": "eval:doc.asset_type != \"Existing Asset\" && doc.asset_type != \"Composite Asset\"",
"reqd": 1
},
{
@@ -229,25 +227,18 @@
{
"fieldname": "available_for_use_date",
"fieldtype": "Date",
"label": "Available-for-use Date",
"mandatory_depends_on": "eval:(!(doc.is_composite_component || doc.is_composite_asset) || doc.docstatus==1)"
"label": "Available for Use Date",
"mandatory_depends_on": "eval:(!(doc.asset_type == \"Composite Component\" || doc.asset_type == \"Composite Asset\") || doc.docstatus==1)"
},
{
"default": "0",
"fieldname": "calculate_depreciation",
"fieldtype": "Check",
"label": "Calculate Depreciation",
"read_only_depends_on": "eval:(doc.is_composite_asset && !doc.net_purchase_amount) || doc.is_composite_component"
"read_only_depends_on": "eval:(doc.asset_type == \"Composite Asset\" && !doc.net_purchase_amount) || doc.asset_type == \"Composite Component\""
},
{
"default": "0",
"depends_on": "eval:(!doc.is_composite_asset && !doc.is_composite_component)",
"fieldname": "is_existing_asset",
"fieldtype": "Check",
"label": "Is Existing Asset"
},
{
"depends_on": "eval:(doc.is_existing_asset)",
"depends_on": "eval:(doc.asset_type == \"Existing Asset\")",
"fieldname": "opening_accumulated_depreciation",
"fieldtype": "Currency",
"label": "Opening Accumulated Depreciation",
@@ -257,18 +248,20 @@
"columns": 10,
"fieldname": "finance_books",
"fieldtype": "Table",
"label": "Finance Books",
"options": "Asset Finance Book"
},
{
"fieldname": "section_break_33",
"fieldtype": "Section Break",
"hidden": 1
"hidden": 1,
"label": "Depreciation Details"
},
{
"fieldname": "depreciation_method",
"fieldtype": "Select",
"label": "Depreciation Method",
"options": "\nStraight Line\nDouble Declining Balance\nManual"
"options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual"
},
{
"fieldname": "value_after_depreciation",
@@ -295,6 +288,7 @@
{
"fieldname": "next_depreciation_date",
"fieldtype": "Date",
"hidden": 1,
"label": "Next Depreciation Date",
"no_copy": 1
},
@@ -364,7 +358,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.is_composite_asset && !doc.is_existing_asset",
"depends_on": "eval:doc.asset_type != \"Composite Asset\" && doc.asset_type != \"Existing Asset\"",
"fieldname": "purchase_receipt",
"fieldtype": "Link",
"label": "Purchase Receipt",
@@ -373,7 +367,7 @@
"print_hide": 1
},
{
"depends_on": "eval:!doc.is_composite_asset && !doc.is_existing_asset",
"depends_on": "eval:doc.asset_type != \"Composite Asset\" && doc.asset_type != \"Existing Asset\"",
"fieldname": "purchase_invoice",
"fieldtype": "Link",
"label": "Purchase Invoice",
@@ -399,7 +393,7 @@
"read_only": 1
},
{
"collapsible_depends_on": "is_existing_asset",
"collapsible_depends_on": "eval:doc.asset_type == \"Existing Asset\"",
"fieldname": "purchase_details_section",
"fieldtype": "Section Break",
"label": "Purchase Details"
@@ -413,10 +407,9 @@
"fieldtype": "Column Break"
},
{
"depends_on": "calculate_depreciation",
"depends_on": "eval: doc.calculate_depreciation",
"fieldname": "section_break_36",
"fieldtype": "Section Break",
"label": "Finance Books"
"fieldtype": "Section Break"
},
{
"fieldname": "split_from",
@@ -455,18 +448,11 @@
},
{
"default": "0",
"depends_on": "eval:(doc.is_existing_asset)",
"fieldname": "is_fully_depreciated",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Fully Depreciated"
},
{
"default": "0",
"depends_on": "eval:(!doc.is_existing_asset && !doc.is_composite_component)",
"fieldname": "is_composite_asset",
"fieldtype": "Check",
"label": "Is Composite Asset"
},
{
"depends_on": "eval:doc.docstatus > 0",
"fieldname": "total_asset_cost",
@@ -496,7 +482,7 @@
"read_only": 1
},
{
"depends_on": "eval:(doc.is_existing_asset)",
"depends_on": "eval:(doc.asset_type == \"Existing Asset\")",
"fieldname": "opening_number_of_booked_depreciations",
"fieldtype": "Int",
"label": "Opening Number of Booked Depreciations"
@@ -513,15 +499,10 @@
"hidden": 1,
"label": "Purchase Invoice Item"
},
{
"fieldname": "insurance_details_tab",
"fieldtype": "Tab Break",
"label": "Insurance"
},
{
"fieldname": "other_info_tab",
"fieldtype": "Tab Break",
"label": "Other Info"
"label": "More Info"
},
{
"fieldname": "connections_tab",
@@ -530,6 +511,7 @@
"show_dashboard": 1
},
{
"depends_on": "eval: doc.calculate_depreciation || doc.asset_type == \"Existing Asset\"",
"fieldname": "depreciation_tab",
"fieldtype": "Tab Break",
"label": "Depreciation"
@@ -544,20 +526,61 @@
"fieldtype": "Section Break",
"label": "Additional Info"
},
{
"default": "0",
"depends_on": "eval:(!doc.is_existing_asset && !doc.is_composite_asset)",
"fieldname": "is_composite_component",
"fieldtype": "Check",
"label": "Is Composite Component"
},
{
"fieldname": "net_purchase_amount",
"fieldtype": "Currency",
"label": "Net Purchase Amount",
"mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)",
"mandatory_depends_on": "eval:(doc.asset_type != \"Composite Asset\" || doc.docstatus==1)",
"options": "Company:company:default_currency",
"read_only_depends_on": "eval: doc.is_composite_asset"
"read_only_depends_on": "eval: doc.asset_type == \"Composite Asset\""
},
{
"fieldname": "asset_type",
"fieldtype": "Select",
"label": "Asset Type",
"options": "\nExisting Asset\nComposite Asset\nComposite Component"
},
{
"fieldname": "column_break_wqzi",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_rjyw",
"fieldtype": "Column Break"
},
{
"fieldname": "insurance_section",
"fieldtype": "Section Break",
"label": "Insurance"
},
{
"fieldname": "section_break_uiyd",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_bbwr",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_bfkm",
"fieldtype": "Column Break"
},
{
"fetch_from": "item_code.item_name",
"fetch_if_empty": 1,
"fieldname": "item_name",
"fieldtype": "Read Only",
"hidden": 1,
"label": "Item Name"
},
{
"fieldname": "asset_owner_section",
"fieldtype": "Section Break",
"label": "Ownership"
},
{
"fieldname": "column_break_yeds",
"fieldtype": "Column Break"
}
],
"idx": 72,
@@ -601,7 +624,7 @@
"link_fieldname": "target_asset"
}
],
"modified": "2025-12-18 16:36:40.904246",
"modified": "2026-02-05 12:42:45.350216",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",

View File

@@ -56,6 +56,7 @@ class Asset(AccountsController):
asset_owner: DF.Literal["", "Company", "Supplier", "Customer"]
asset_owner_company: DF.Link | None
asset_quantity: DF.Int
asset_type: DF.Literal["", "Existing Asset", "Composite Asset", "Composite Component"]
available_for_use_date: DF.Date | None
booked_fixed_asset: DF.Check
calculate_depreciation: DF.Check
@@ -67,7 +68,9 @@ class Asset(AccountsController):
default_finance_book: DF.Link | None
department: DF.Link | None
depr_entry_posting_status: DF.Literal["", "Successful", "Failed"]
depreciation_method: DF.Literal["", "Straight Line", "Double Declining Balance", "Manual"]
depreciation_method: DF.Literal[
"", "Straight Line", "Double Declining Balance", "Written Down Value", "Manual"
]
disposal_date: DF.Date | None
finance_books: DF.Table[AssetFinanceBook]
frequency_of_depreciation: DF.Int
@@ -76,9 +79,6 @@ class Asset(AccountsController):
insurance_start_date: DF.Date | None
insured_value: DF.Data | None
insurer: DF.Data | None
is_composite_asset: DF.Check
is_composite_component: DF.Check
is_existing_asset: DF.Check
is_fully_depreciated: DF.Check
item_code: DF.Link
item_name: DF.ReadOnly | None
@@ -243,14 +243,20 @@ class Asset(AccountsController):
self.set_total_booked_depreciations()
def before_submit(self):
if self.is_composite_asset and not has_active_capitalization(self.name):
if self.asset_type == "Composite Asset" and not has_active_capitalization(self.name):
if self.split_from and has_active_capitalization(self.split_from):
return
frappe.throw(_("Please capitalize this asset before submitting."))
def on_submit(self):
self.validate_in_use_date()
self.make_asset_movement()
self.reload()
if not self.booked_fixed_asset and not self.is_composite_component and self.validate_make_gl_entry():
if (
not self.booked_fixed_asset
and self.asset_type != "Composite Component"
and self.validate_make_gl_entry()
):
self.make_gl_entries()
if self.calculate_depreciation and not self.split_from:
convert_draft_asset_depr_schedules_into_active(self)
@@ -265,7 +271,7 @@ class Asset(AccountsController):
cancel_asset_depr_schedules(self)
self.set_status()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
if not self.is_composite_component:
if self.asset_type != "Composite Component":
make_reverse_gl_entries(voucher_type="Asset", voucher_no=self.name)
self.db_set("booked_fixed_asset", 0)
add_asset_activity(self.name, _("Asset cancelled"))
@@ -283,7 +289,7 @@ class Asset(AccountsController):
add_asset_activity(self.name, _("Asset deleted"))
def set_purchase_doc_row_item(self):
if self.is_existing_asset or self.is_composite_asset:
if self.asset_type == "Existing Asset" or self.asset_type == "Composite Asset":
return
self.purchase_amount = self.net_purchase_amount
@@ -326,7 +332,7 @@ class Asset(AccountsController):
)
)
if self.is_existing_asset and self.purchase_invoice:
if self.asset_type == "Existing Asset" and self.purchase_invoice:
frappe.throw(_("Purchase Invoice cannot be made against an existing asset {0}").format(self.name))
def validate_item(self):
@@ -372,7 +378,7 @@ class Asset(AccountsController):
)
def validate_in_use_date(self):
if not self.available_for_use_date and not self.is_composite_component:
if not self.available_for_use_date and self.asset_type != "Composite Component":
frappe.throw(_("Available for use date is required"))
for d in self.finance_books:
@@ -420,12 +426,15 @@ class Asset(AccountsController):
non_depreciable_category = frappe.db.get_value(
"Asset Category", self.asset_category, "non_depreciable_category"
)
if self.calculate_depreciation and non_depreciable_category:
frappe.throw(
_(
"This asset category is marked as non-depreciable. Please disable depreciation calculation or choose a different category."
if self.calculate_depreciation:
if non_depreciable_category:
frappe.throw(
_(
"This asset category is marked as non-depreciable. Please disable depreciation calculation or choose a different category."
)
)
)
# validate accounts required for asset depreciation
get_depreciation_accounts(self.asset_category, self.company)
def validate_precision(self):
if self.net_purchase_amount:
@@ -440,13 +449,13 @@ class Asset(AccountsController):
if not self.asset_category:
self.asset_category = frappe.get_cached_value("Item", self.item_code, "asset_category")
if not flt(self.net_purchase_amount) and not self.is_composite_asset:
if not flt(self.net_purchase_amount) and self.asset_type != "Composite Asset":
frappe.throw(_("Net Purchase Amount is mandatory"), frappe.MandatoryError)
if is_cwip_accounting_enabled(self.asset_category):
if (
not self.is_existing_asset
and not self.is_composite_asset
not self.asset_type == "Existing Asset"
and not self.asset_type == "Composite Asset"
and not self.purchase_receipt
and not self.purchase_invoice
):
@@ -475,7 +484,7 @@ class Asset(AccountsController):
if self.is_fully_depreciated:
frappe.throw(_("Depreciation cannot be calculated for fully depreciated assets"))
if self.is_existing_asset:
if self.asset_type == "Existing Asset":
return
if self.available_for_use_date and getdate(self.available_for_use_date) < getdate(self.purchase_date):
@@ -547,7 +556,7 @@ class Asset(AccountsController):
)
def validate_gross_and_purchase_amount(self):
if self.is_existing_asset:
if self.asset_type == "Existing Asset":
return
if self.net_purchase_amount and self.net_purchase_amount != self.purchase_amount:
@@ -613,7 +622,7 @@ class Asset(AccountsController):
self.validate_depreciation_start_date(row)
self.validate_total_number_of_depreciations_and_frequency(row)
if not self.is_existing_asset:
if self.asset_type != "Existing Asset":
self.opening_accumulated_depreciation = 0
self.opening_number_of_booked_depreciations = 0
else:
@@ -766,7 +775,7 @@ class Asset(AccountsController):
def get_status(self):
"""Returns status based on whether it is draft, submitted, scrapped or depreciated"""
if self.docstatus == 0:
if self.is_composite_asset:
if self.asset_type == "Composite Asset":
status = "Work In Progress"
else:
status = "Draft"
@@ -786,13 +795,12 @@ class Asset(AccountsController):
].expected_value_after_useful_life
value_after_depreciation = self.finance_books[idx].value_after_depreciation
if (
flt(value_after_depreciation) <= expected_value_after_useful_life
or self.is_fully_depreciated
):
if flt(value_after_depreciation) <= expected_value_after_useful_life:
status = "Fully Depreciated"
elif flt(value_after_depreciation) < flt(self.net_purchase_amount):
status = "Partially Depreciated"
elif self.is_fully_depreciated:
status = "Fully Depreciated"
elif self.docstatus == 2:
status = "Cancelled"
return status
@@ -839,7 +847,7 @@ class Asset(AccountsController):
return records
def validate_make_gl_entry(self):
if self.is_composite_asset:
if self.asset_type == "Composite Asset":
return True
purchase_document = self.get_purchase_document()
@@ -920,7 +928,7 @@ class Asset(AccountsController):
purchase_document = self.get_purchase_document()
fixed_asset_account, cwip_account = self.get_fixed_asset_account(), self.get_cwip_account()
if (self.is_composite_asset or (purchase_document and self.purchase_amount)) and getdate(
if (self.asset_type == "Composite Asset" or (purchase_document and self.purchase_amount)) and getdate(
self.available_for_use_date
) <= getdate():
gl_entries.append(
@@ -960,7 +968,7 @@ class Asset(AccountsController):
self.db_set("booked_fixed_asset", 1)
def check_asset_capitalization_gl_entries(self):
if self.is_composite_asset:
if self.asset_type == "Composite Asset":
result = frappe.db.get_value(
"Asset Capitalization",
{"target_asset": self.name, "docstatus": 1},
@@ -1084,7 +1092,7 @@ def get_asset_naming_series():
@frappe.whitelist()
def make_sales_invoice(asset, item_code, company, sell_qty, serial_no=None):
def make_sales_invoice(asset: str, item_code: str, company: str, sell_qty: int, serial_no: str | None = None):
asset_doc = frappe.get_doc("Asset", asset)
si = frappe.new_doc("Sales Invoice")
si.company = company
@@ -1117,7 +1125,13 @@ def make_sales_invoice(asset, item_code, company, sell_qty, serial_no=None):
@frappe.whitelist()
def create_asset_maintenance(asset, item_code, item_name, asset_category, company):
def create_asset_maintenance(
asset: str,
item_code: str,
item_name: str,
asset_category: str,
company: str,
):
asset_maintenance = frappe.new_doc("Asset Maintenance")
asset_maintenance.update(
{
@@ -1132,14 +1146,23 @@ def create_asset_maintenance(asset, item_code, item_name, asset_category, compan
@frappe.whitelist()
def create_asset_repair(company, asset, asset_name):
def create_asset_repair(
company: str,
asset: str,
asset_name: str,
):
asset_repair = frappe.new_doc("Asset Repair")
asset_repair.update({"company": company, "asset": asset, "asset_name": asset_name})
return asset_repair
@frappe.whitelist()
def create_asset_capitalization(company, asset, asset_name, item_code):
def create_asset_capitalization(
company: str,
asset: str,
asset_name: str,
item_code: str,
):
asset_capitalization = frappe.new_doc("Asset Capitalization")
asset_capitalization.update(
{
@@ -1153,35 +1176,22 @@ def create_asset_capitalization(company, asset, asset_name, item_code):
@frappe.whitelist()
def create_asset_value_adjustment(asset, asset_category, company):
def create_asset_value_adjustment(
asset: str,
asset_category: str,
company: str,
):
asset_value_adjustment = frappe.new_doc("Asset Value Adjustment")
asset_value_adjustment.update({"asset": asset, "company": company, "asset_category": asset_category})
return asset_value_adjustment
@frappe.whitelist()
def transfer_asset(args):
args = json.loads(args)
if args.get("serial_no"):
args["quantity"] = len(args.get("serial_no").split("\n"))
movement_entry = frappe.new_doc("Asset Movement")
movement_entry.update(args)
movement_entry.insert()
movement_entry.submit()
frappe.db.commit()
frappe.msgprint(
_("Asset Movement record {0} created")
.format("<a href='/app/Form/Asset Movement/{0}'>{0}</a>")
.format(movement_entry.name)
)
@frappe.whitelist()
def get_item_details(item_code, asset_category, net_purchase_amount):
def get_item_details(
item_code: str,
asset_category: str,
net_purchase_amount: float,
):
asset_category_doc = frappe.get_cached_doc("Asset Category", asset_category)
books = []
for d in asset_category_doc.finance_books:
@@ -1231,7 +1241,7 @@ def get_asset_account(account_name, asset=None, asset_category=None, company=Non
@frappe.whitelist()
def make_journal_entry(asset_name):
def make_journal_entry(asset_name: str):
asset = frappe.get_doc("Asset", asset_name)
(
fixed_asset_account,
@@ -1273,7 +1283,10 @@ def make_journal_entry(asset_name):
@frappe.whitelist()
def make_asset_movement(assets, purpose=None):
def make_asset_movement(
assets: list[dict] | str,
purpose: str = "Transfer",
):
import json
if isinstance(assets, str):
@@ -1283,7 +1296,7 @@ def make_asset_movement(assets, purpose=None):
frappe.throw(_("At least one asset has to be selected."))
asset_movement = frappe.new_doc("Asset Movement")
asset_movement.quantity = len(assets)
asset_movement.purpose = purpose
for asset in assets:
asset = frappe.get_doc("Asset", asset.get("name"))
asset_movement.company = asset.get("company")
@@ -1305,7 +1318,10 @@ def is_cwip_accounting_enabled(asset_category):
@frappe.whitelist()
def get_asset_value_after_depreciation(asset_name, finance_book=None):
def get_asset_value_after_depreciation(
asset_name: str,
finance_book: str | None = None,
):
asset = frappe.get_doc("Asset", asset_name)
if not asset.calculate_depreciation:
return flt(asset.value_after_depreciation)
@@ -1314,7 +1330,7 @@ def get_asset_value_after_depreciation(asset_name, finance_book=None):
@frappe.whitelist()
def has_active_capitalization(asset):
def has_active_capitalization(asset: str):
active_capitalizations = frappe.db.count(
"Asset Capitalization", filters={"target_asset": asset, "docstatus": 1}
)
@@ -1322,7 +1338,11 @@ def has_active_capitalization(asset):
@frappe.whitelist()
def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
def get_values_from_purchase_doc(
purchase_doc_name: str,
item_code: str,
doctype: str,
):
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
@@ -1344,7 +1364,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
@frappe.whitelist()
def split_asset(asset_name, split_qty):
def split_asset(asset_name: str, split_qty: int):
"""Split an asset into two based on the given quantity."""
existing_asset = frappe.get_doc("Asset", asset_name)
split_qty = cint(split_qty)
@@ -1391,7 +1411,7 @@ def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_a
def set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset):
asset_doc.net_purchase_amount = existing_asset.net_purchase_amount * scaling_factor
asset_doc.purchase_amount = existing_asset.net_purchase_amount
asset_doc.purchase_amount = existing_asset.net_purchase_amount * scaling_factor
asset_doc.additional_asset_cost = existing_asset.additional_asset_cost * scaling_factor
asset_doc.total_asset_cost = asset_doc.net_purchase_amount + asset_doc.additional_asset_cost
asset_doc.opening_accumulated_depreciation = (

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.query_builder import Order
from frappe.query_builder.functions import Max, Min
from frappe.utils import (
DateTimeLikeObject,
add_months,
cint,
flt,
@@ -161,11 +162,11 @@ def get_depr_cost_center_and_series():
@frappe.whitelist()
def make_depreciation_entry(
depr_schedule_name,
date=None,
sch_start_idx=None,
sch_end_idx=None,
accounting_dimensions=None,
depr_schedule_name: str,
date: DateTimeLikeObject | None = None,
sch_start_idx: int | None = None,
sch_end_idx: int | None = None,
accounting_dimensions: list[dict] | None = None,
):
frappe.has_permission("Journal Entry", throw=True)
date = date or today()
@@ -246,7 +247,9 @@ def _make_journal_entry_for_depreciation(
def setup_journal_entry_metadata(je, depr_schedule_doc, depr_series, depr_schedule, asset):
je.voucher_type = "Depreciation Entry"
je.naming_series = depr_series
if depr_series:
je.naming_series = depr_series
je.posting_date = depr_schedule.schedule_date
je.company = asset.company
je.finance_book = depr_schedule_doc.finance_book
@@ -354,7 +357,7 @@ def get_message_for_depr_entry_posting_error(asset_links, error_log_links):
@frappe.whitelist()
def scrap_asset(asset_name, scrap_date=None):
def scrap_asset(asset_name: str, scrap_date: DateTimeLikeObject | None = None):
asset = frappe.get_doc("Asset", asset_name)
scrap_date = getdate(scrap_date) or getdate(today())
asset.db_set("disposal_date", scrap_date)
@@ -443,7 +446,7 @@ def create_journal_entry_for_scrap(asset, scrap_date):
@frappe.whitelist()
def restore_asset(asset_name):
def restore_asset(asset_name: str):
asset = frappe.get_doc("Asset", asset_name)
reverse_depreciation_entry_made_on_disposal(asset)
reset_depreciation_schedule(asset, get_note_for_restore(asset))
@@ -770,7 +773,7 @@ def get_profit_gl_entries(
@frappe.whitelist()
def get_disposal_account_and_cost_center(company):
def get_disposal_account_and_cost_center(company: str):
disposal_account, depreciation_cost_center = frappe.get_cached_value(
"Company", company, ["disposal_account", "depreciation_cost_center"]
)
@@ -784,10 +787,14 @@ def get_disposal_account_and_cost_center(company):
@frappe.whitelist()
def get_value_after_depreciation_on_disposal_date(asset, disposal_date, finance_book=None):
def get_value_after_depreciation_on_disposal_date(
asset: str,
disposal_date: DateTimeLikeObject,
finance_book: str | None = None,
):
asset_doc = frappe.get_doc("Asset", asset)
if asset_doc.is_composite_component:
if asset_doc.asset_type == "Composite Component":
validate_disposal_date(asset_doc.purchase_date, getdate(disposal_date), "purchase")
return flt(asset_doc.value_after_depreciation)

View File

@@ -70,16 +70,16 @@ class TestAsset(AssetSetup):
self.assertRaises(frappe.MandatoryError, asset.save)
def test_pr_or_pi_mandatory_if_not_existing_asset(self):
"""Tests if either PI or PR is present if CWIP is enabled and is_existing_asset=0."""
"""Tests if either PI or PR is present if CWIP is enabled and asset_type == Existing Asset."""
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
asset.is_existing_asset = 0
asset.asset_type = ""
self.assertRaises(frappe.ValidationError, asset.save)
def test_available_for_use_date_is_after_purchase_date(self):
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=1, do_not_save=1)
asset.is_existing_asset = 0
asset.asset_type = ""
asset.purchase_date = getdate("2021-10-10")
asset.available_for_use_date = getdate("2021-10-1")
@@ -182,7 +182,7 @@ class TestAsset(AssetSetup):
asset.submit()
def test_is_fixed_asset_set(self):
asset = create_asset(is_existing_asset=1)
asset = create_asset(asset_type="Existing Asset")
doc = frappe.new_doc("Purchase Invoice")
doc.company = "_Test Company"
doc.supplier = "_Test Supplier"
@@ -709,7 +709,7 @@ class TestAsset(AssetSetup):
# create an asset
asset = create_asset(
item_code="Macbook Pro",
is_existing_asset=1,
asset_type="Existing Asset",
calculate_depreciation=1,
available_for_use_date=purchase_date,
purchase_date=purchase_date,
@@ -823,6 +823,92 @@ class TestAsset(AssetSetup):
frappe.db.set_value("Item", asset_item, "is_grouped_asset", 0)
def test_is_fully_depreciated_asset_status(self):
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
asset.is_fully_depreciated = 1
asset.save().submit()
self.assertEqual(asset.status, "Fully Depreciated")
def test_depreciation_accounts_is_set_for_depreciable_assets(self):
company_depreciation_accounts = frappe.db.get_value(
"Company",
"_Test Company",
[
"accumulated_depreciation_account",
"depreciation_expense_account",
],
as_dict=True,
)
frappe.db.set_value(
"Company",
"_Test Company",
{
"accumulated_depreciation_account": "",
"depreciation_expense_account": "",
},
)
asset_category_name = "Computers"
asset_category_account = None
if frappe.db.exists("Asset Category", asset_category_name):
filters = {
"parent": asset_category_name,
"company_name": "_Test Company",
}
fieldname = [
"name",
"accumulated_depreciation_account",
"depreciation_expense_account",
]
asset_category_account = frappe.db.get_value(
"Asset Category Account",
filters=filters,
fieldname=fieldname,
as_dict=True,
)
if asset_category_account and (
asset_category_account.accumulated_depreciation_account
or asset_category_account.depreciation_expense_account
):
frappe.db.set_value(
"Asset Category Account",
asset_category_account.name,
{
"accumulated_depreciation_account": "",
"depreciation_expense_account": "",
},
)
else:
asset_category = frappe.new_doc("Asset Category")
asset_category.asset_category_name = asset_category_name
asset_category.append(
"accounts",
{
"company_name": "_Test Company",
"fixed_asset_account": "_Test Fixed Asset - _TC",
},
)
asset_category.insert()
try:
asset = create_asset(asset_category=asset_category_name, calculate_depreciation=1, do_not_save=1)
with self.assertRaises(frappe.ValidationError) as err:
asset.save()
self.assertTrue(
"Please set Depreciation related Accounts in Asset Category Computers or Company"
in str(err.exception)
)
finally:
frappe.db.set_value("Company", "_Test Company", company_depreciation_accounts)
if asset_category_account:
frappe.db.set_value(
"Asset Category Account",
asset_category_account.name,
{
"accumulated_depreciation_account": asset_category_account.accumulated_depreciation_account,
"depreciation_expense_account": asset_category_account.depreciation_expense_account,
},
)
class TestDepreciationMethods(AssetSetup):
@classmethod
@@ -901,7 +987,7 @@ class TestDepreciationMethods(AssetSetup):
asset = create_asset(
calculate_depreciation=1,
available_for_use_date="2030-06-06",
is_existing_asset=1,
asset_type="Existing Asset",
opening_number_of_booked_depreciations=2,
opening_accumulated_depreciation=47178.08,
expected_value_after_useful_life=10000,
@@ -950,7 +1036,7 @@ class TestDepreciationMethods(AssetSetup):
asset = create_asset(
calculate_depreciation=1,
available_for_use_date="2030-01-01",
is_existing_asset=1,
asset_type="Existing Asset",
depreciation_method="Double Declining Balance",
opening_number_of_booked_depreciations=1,
opening_accumulated_depreciation=50000,
@@ -1691,7 +1777,7 @@ class TestDepreciationBasics(AssetSetup):
self.assertEqual(asset.finance_books[0].value_after_depreciation, 100000.0)
def test_asset_cost_center(self):
asset = create_asset(is_existing_asset=1, do_not_save=1)
asset = create_asset(asset_type="Existing Asset", do_not_save=1)
asset.cost_center = "Main - WP"
self.assertRaises(frappe.ValidationError, asset.submit)
@@ -1728,7 +1814,7 @@ class TestDepreciationBasics(AssetSetup):
def test_manual_depreciation_for_existing_asset(self):
asset = create_asset(
item_code="Macbook Pro",
is_existing_asset=1,
asset_type="Existing Asset",
purchase_date="2020-01-30",
available_for_use_date="2020-01-30",
submit=1,
@@ -1828,6 +1914,71 @@ class TestDepreciationBasics(AssetSetup):
pr.submit()
self.assertTrue(get_gl_entries("Purchase Receipt", pr.name))
def test_split_asset_created_via_capitalization(self):
"""Test that assets created via Asset Capitalization can be split without capitalization error"""
from erpnext.assets.doctype.asset_capitalization.test_asset_capitalization import (
create_asset_capitalization,
create_asset_capitalization_data,
)
# Ensure test data exists
create_asset_capitalization_data()
company = "_Test Company with perpetual inventory"
set_depreciation_settings_in_company(company=company)
name = frappe.db.get_value(
"Asset Category Account",
filters={"parent": "Computers", "company_name": company},
fieldname=["name"],
)
frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", "")
stock_rate = 1000
stock_qty = 2
total_amount = 2000
# Create composite asset
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset for Split",
asset_type="Composite Asset",
warehouse="Stores - TCP1",
company=company,
asset_quantity=2, # Set quantity > 1 to allow splitting
)
# Create and submit Asset Capitalization
asset_capitalization = create_asset_capitalization(
target_asset=wip_composite_asset.name,
stock_qty=stock_qty,
stock_rate=stock_rate,
company=company,
submit=1,
)
# Verify asset was capitalized
target_asset = frappe.get_doc("Asset", asset_capitalization.target_asset)
self.assertEqual(target_asset.net_purchase_amount, total_amount)
self.assertEqual(target_asset.status, "Work In Progress")
# Submit the capitalized asset
target_asset.submit()
self.assertEqual(target_asset.status, "Submitted")
# Split the asset - this should work without capitalization error
split_qty = 1
splitted_asset = split_asset(target_asset.name, split_qty)
# Verify split asset was created and submitted successfully
self.assertIsNotNone(splitted_asset)
self.assertEqual(splitted_asset.asset_quantity, split_qty)
self.assertEqual(splitted_asset.split_from, target_asset.name)
self.assertEqual(splitted_asset.docstatus, 1) # Should be submitted
self.assertEqual(splitted_asset.status, "Submitted")
# Verify original asset was updated
target_asset.reload()
self.assertEqual(target_asset.asset_quantity, 1) # Remaining quantity
def get_gl_entries(doctype, docname):
gl_entry = frappe.qb.DocType("GL Entry")
@@ -1883,9 +2034,7 @@ def create_asset(**args):
"available_for_use_date": args.available_for_use_date or "2020-06-06",
"location": args.location or "Test Location",
"asset_owner": args.asset_owner or "Company",
"is_existing_asset": args.is_existing_asset or 1,
"is_composite_asset": args.is_composite_asset or 0,
"is_composite_component": args.is_composite_component or 0,
"asset_type": args.asset_type or "Existing Asset",
"asset_quantity": args.get("asset_quantity") or 1,
"depr_entry_posting_status": args.depr_entry_posting_status or "",
}
@@ -1907,7 +2056,7 @@ def create_asset(**args):
},
)
if asset.is_composite_asset:
if asset.asset_type == "Composite Asset":
asset.net_purchase_amount = 0
asset.purchase_amount = 0

View File

@@ -17,10 +17,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
refresh() {
this.show_general_ledger();
if (
(this.frm.doc.stock_items && this.frm.doc.stock_items.length) ||
!this.frm.doc.target_is_fixed_asset
) {
if (this.frm.doc.stock_items && this.frm.doc.stock_items.length) {
this.show_stock_ledger();
}
@@ -41,7 +38,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
me.frm.set_query("target_asset", function () {
return {
filters: { is_composite_asset: 1, docstatus: 0 },
filters: { asset_type: "Composite Asset", docstatus: 0 },
};
});
@@ -240,10 +237,6 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
this.calculate_totals();
}
target_qty() {
this.calculate_totals();
}
rate() {
this.calculate_totals();
}
@@ -403,7 +396,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_warehouse_details",
child: item,
args: {
args: {
ctx: {
item_code: item.item_code,
warehouse: cstr(item.warehouse),
qty: -1 * flt(item.stock_qty),
@@ -485,10 +478,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
me.frm.doc.stock_items_total + me.frm.doc.asset_items_total + me.frm.doc.service_items_total;
me.frm.doc.total_value = flt(me.frm.doc.total_value, precision("total_value"));
me.frm.doc.target_qty = flt(me.frm.doc.target_qty, precision("target_qty"));
me.frm.doc.target_incoming_rate = me.frm.doc.target_qty
? me.frm.doc.total_value / flt(me.frm.doc.target_qty)
: me.frm.doc.total_value;
me.frm.doc.target_incoming_rate = me.frm.doc.total_value;
me.frm.refresh_fields();
}

View File

@@ -9,30 +9,33 @@
"field_order": [
"title",
"naming_series",
"company",
"target_asset",
"target_asset_name",
"target_item_code",
"finance_book",
"target_qty",
"column_break_9",
"company",
"finance_book",
"posting_date",
"posting_time",
"set_posting_time",
"target_batch_no",
"target_serial_no",
"target_item_code",
"amended_from",
"target_is_fixed_asset",
"target_has_batch_no",
"target_has_serial_no",
"section_break_16",
"stock_items",
"section_break_urtz",
"column_break_gqep",
"column_break_yvlx",
"stock_items_total",
"section_break_26",
"asset_items",
"section_break_arbh",
"column_break_boeu",
"column_break_qecy",
"asset_items_total",
"service_expenses_section",
"service_items",
"section_break_ptna",
"column_break_szvh",
"column_break_katv",
"service_items_total",
"totals_section",
"total_value",
@@ -55,20 +58,12 @@
"depends_on": "eval:(doc.target_item_code && !doc.__islocal)",
"fieldname": "target_item_code",
"fieldtype": "Link",
"hidden": 1,
"in_standard_filter": 1,
"label": "Target Item Code",
"options": "Item",
"read_only": 1
},
{
"default": "0",
"fetch_from": "target_item_code.is_fixed_asset",
"fieldname": "target_is_fixed_asset",
"fieldtype": "Check",
"hidden": 1,
"label": "Target Is Fixed Asset",
"read_only": 1
},
{
"fieldname": "target_asset",
"fieldtype": "Link",
@@ -143,6 +138,7 @@
"depends_on": "eval:doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length)",
"fieldname": "section_break_16",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Consumed Stock Items"
},
{
@@ -151,49 +147,11 @@
"label": "Stock Items",
"options": "Asset Capitalization Stock Item"
},
{
"depends_on": "target_has_batch_no",
"fieldname": "target_batch_no",
"fieldtype": "Link",
"label": "Target Batch No",
"options": "Batch"
},
{
"default": "1",
"fieldname": "target_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Target Qty",
"read_only": 1
},
{
"default": "0",
"fetch_from": "target_item_code.has_batch_no",
"fieldname": "target_has_batch_no",
"fieldtype": "Check",
"hidden": 1,
"label": "Target Has Batch No",
"read_only": 1
},
{
"default": "0",
"fetch_from": "target_item_code.has_serial_no",
"fieldname": "target_has_serial_no",
"fieldtype": "Check",
"hidden": 1,
"label": "Target Has Serial No",
"read_only": 1
},
{
"depends_on": "target_has_serial_no",
"fieldname": "target_serial_no",
"fieldtype": "Small Text",
"label": "Target Serial No"
},
{
"depends_on": "eval:doc.docstatus == 0 || (doc.asset_items && doc.asset_items.length)",
"fieldname": "section_break_26",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Consumed Assets"
},
{
@@ -203,6 +161,7 @@
"options": "Asset Capitalization Asset Item"
},
{
"depends_on": "eval: doc.stock_items_total",
"fieldname": "stock_items_total",
"fieldtype": "Currency",
"label": "Consumed Stock Total Value",
@@ -210,6 +169,7 @@
"read_only": 1
},
{
"depends_on": "eval: doc.asset_items_total",
"fieldname": "asset_items_total",
"fieldtype": "Currency",
"label": "Consumed Asset Total Value",
@@ -226,6 +186,7 @@
"depends_on": "eval:doc.docstatus == 0 || (doc.service_items && doc.service_items.length)",
"fieldname": "service_expenses_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Service Expenses"
},
{
@@ -235,6 +196,7 @@
"options": "Asset Capitalization Service Item"
},
{
"depends_on": "eval: doc.service_items_total",
"fieldname": "service_items_total",
"fieldtype": "Currency",
"label": "Service Expense Total Amount",
@@ -277,10 +239,10 @@
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "dimension_col_break",
@@ -292,12 +254,48 @@
"label": "Target Fixed Asset Account",
"options": "Account",
"read_only": 1
},
{
"fieldname": "section_break_urtz",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_gqep",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_yvlx",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_arbh",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_boeu",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_qecy",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ptna",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_szvh",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_katv",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-05-20 15:15:12.110035",
"modified": "2026-02-06 01:52:41.890713",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Capitalization",

View File

@@ -2,6 +2,7 @@
# For license information, please see license.txt
import json
from typing import Any
import frappe
@@ -38,9 +39,6 @@ force_fields = [
"target_asset_name",
"item_name",
"asset_name",
"target_is_fixed_asset",
"target_has_serial_no",
"target_has_batch_no",
"stock_uom",
"fixed_asset_account",
"valuation_rate",
@@ -75,6 +73,7 @@ class AssetCapitalization(StockController):
naming_series: DF.Literal["ACC-ASC-.YYYY.-"]
posting_date: DF.Date
posting_time: DF.Time
project: DF.Link | None
service_items: DF.Table[AssetCapitalizationServiceItem]
service_items_total: DF.Currency
set_posting_time: DF.Check
@@ -82,15 +81,9 @@ class AssetCapitalization(StockController):
stock_items_total: DF.Currency
target_asset: DF.Link | None
target_asset_name: DF.Data | None
target_batch_no: DF.Link | None
target_fixed_asset_account: DF.Link | None
target_has_batch_no: DF.Check
target_has_serial_no: DF.Check
target_incoming_rate: DF.Currency
target_is_fixed_asset: DF.Check
target_item_code: DF.Link | None
target_qty: DF.Float
target_serial_no: DF.SmallText | None
title: DF.Data | None
total_value: DF.Currency
# end: auto-generated types
@@ -189,22 +182,13 @@ class AssetCapitalization(StockController):
if not target_item.is_fixed_asset:
frappe.throw(_("Target Item {0} must be a Fixed Asset item").format(target_item.name))
if target_item.is_fixed_asset:
self.target_qty = 1
if flt(self.target_qty) <= 0:
frappe.throw(_("Target Qty must be a positive number"))
if not target_item.has_batch_no:
self.target_batch_no = None
if not target_item.has_serial_no:
self.target_serial_no = ""
self.validate_item(target_item)
def validate_target_asset(self):
if self.target_asset:
target_asset = self.get_asset_for_validation(self.target_asset)
if not target_asset.is_composite_asset:
if not target_asset.asset_type == "Composite Asset":
frappe.throw(_("Target Asset {0} needs to be composite asset").format(target_asset.name))
if target_asset.item_code != self.target_item_code:
@@ -313,7 +297,7 @@ class AssetCapitalization(StockController):
return frappe.db.get_value(
"Asset",
asset,
["name", "item_code", "company", "status", "docstatus", "is_composite_asset"],
["name", "item_code", "company", "status", "docstatus", "asset_type"],
as_dict=1,
)
@@ -379,8 +363,7 @@ class AssetCapitalization(StockController):
self.total_value = self.stock_items_total + self.asset_items_total + self.service_items_total
self.total_value = flt(self.total_value, self.precision("total_value"))
self.target_qty = flt(self.target_qty, self.precision("target_qty"))
self.target_incoming_rate = self.total_value / self.target_qty
self.target_incoming_rate = self.total_value
def update_stock_ledger(self):
sl_entries = []
@@ -488,7 +471,7 @@ class AssetCapitalization(StockController):
for item in self.asset_items:
asset = frappe.get_doc("Asset", item.asset)
if not asset.is_composite_component:
if asset.asset_type != "Composite Component":
if asset.calculate_depreciation:
notes = _(
"This schedule was created when Asset {0} was consumed through Asset Capitalization {1}."
@@ -541,30 +524,29 @@ class AssetCapitalization(StockController):
def get_composite_component_value(self):
composite_component_value = 0
for item in self.asset_items:
asset = frappe.db.get_value("Asset", item.asset, ["is_composite_component"], as_dict=True)
if asset and asset.is_composite_component:
asset = frappe.db.get_value("Asset", item.asset, ["asset_type"], as_dict=True)
if asset and asset.asset_type == "Composite Component":
composite_component_value += flt(item.asset_value, item.precision("asset_value"))
return composite_component_value
def get_gl_entries_for_target_item(
self, gl_entries, target_account, target_against, precision, composite_component_value
):
if self.target_is_fixed_asset:
total_value = flt(self.total_value - composite_component_value, precision)
if total_value:
# Capitalization
gl_entries.append(
self.get_gl_dict(
{
"account": target_account,
"against": ", ".join(target_against),
"remarks": self.get("remarks") or _("Accounting Entry for Asset"),
"debit": total_value,
"cost_center": self.get("cost_center"),
},
item=self,
)
total_value = flt(self.total_value - composite_component_value, precision)
if total_value:
# Capitalization
gl_entries.append(
self.get_gl_dict(
{
"account": target_account,
"against": ", ".join(target_against),
"remarks": self.get("remarks") or _("Accounting Entry for Asset"),
"debit": total_value,
"cost_center": self.get("cost_center"),
},
item=self,
)
)
def update_target_asset(self):
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
@@ -573,13 +555,19 @@ class AssetCapitalization(StockController):
if self.docstatus == 2:
net_purchase_amount = asset_doc.net_purchase_amount - total_target_asset_value
purchase_amount = asset_doc.purchase_amount - total_target_asset_value
asset_doc.db_set("total_asset_cost", asset_doc.total_asset_cost - total_target_asset_value)
total_asset_cost = asset_doc.total_asset_cost - total_target_asset_value
else:
net_purchase_amount = asset_doc.net_purchase_amount + total_target_asset_value
purchase_amount = asset_doc.purchase_amount + total_target_asset_value
total_asset_cost = asset_doc.total_asset_cost + total_target_asset_value
asset_doc.db_set("net_purchase_amount", net_purchase_amount)
asset_doc.db_set("purchase_amount", purchase_amount)
asset_doc.db_set(
{
"net_purchase_amount": net_purchase_amount,
"purchase_amount": purchase_amount,
"total_asset_cost": total_asset_cost,
}
)
frappe.msgprint(
_("Asset {0} has been updated. Please set the depreciation details if any and submit it.").format(
@@ -604,14 +592,13 @@ class AssetCapitalization(StockController):
def set_consumed_asset_status(self, asset):
if self.docstatus == 1:
if self.target_is_fixed_asset:
asset.set_status("Capitalized")
add_asset_activity(
asset.name,
_("Asset capitalized after Asset Capitalization {0} was submitted").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
asset.set_status("Capitalized")
add_asset_activity(
asset.name,
_("Asset capitalized after Asset Capitalization {0} was submitted").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
else:
asset.set_status()
add_asset_activity(
@@ -623,7 +610,7 @@ class AssetCapitalization(StockController):
@frappe.whitelist()
def get_target_item_details(item_code=None, company=None):
def get_target_item_details(item_code: str | None = None, company: str | None = None):
out = frappe._dict()
# Get Item Details
@@ -633,17 +620,6 @@ def get_target_item_details(item_code=None, company=None):
# Set Item Details
out.target_item_name = item.item_name
out.target_is_fixed_asset = cint(item.is_fixed_asset)
out.target_has_batch_no = cint(item.has_batch_no)
out.target_has_serial_no = cint(item.has_serial_no)
if out.target_is_fixed_asset:
out.target_qty = 1
if not out.target_has_batch_no:
out.target_batch_no = None
if not out.target_has_serial_no:
out.target_serial_no = ""
# Cost Center
item_defaults = get_item_defaults(item.name, company)
@@ -660,7 +636,7 @@ def get_target_item_details(item_code=None, company=None):
@frappe.whitelist()
def get_target_asset_details(asset=None, company=None):
def get_target_asset_details(asset: str | None = None, company: str | None = None):
out = frappe._dict()
# Get Asset Details
@@ -735,24 +711,22 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
@frappe.whitelist()
def get_warehouse_details(args):
if isinstance(args, str):
args = json.loads(args)
args = frappe._dict(args)
out = {}
if args.warehouse and args.item_code:
out = {
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),
}
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_warehouse_details(ctx: ItemDetailsCtx) -> frappe._dict:
out = frappe._dict()
if ctx.warehouse and ctx.item_code:
out = frappe._dict(
{
"actual_qty": get_previous_sle(ctx).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(ctx, raise_error_if_no_rate=False),
}
)
return out
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_consumed_asset_details(ctx):
def get_consumed_asset_details(ctx: ItemDetailsCtx) -> frappe._dict:
out = frappe._dict()
asset_details = frappe._dict()
@@ -798,7 +772,7 @@ def get_consumed_asset_details(ctx):
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_service_item_details(ctx):
def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
out = frappe._dict()
item = frappe._dict()
@@ -820,7 +794,7 @@ def get_service_item_details(ctx):
@frappe.whitelist()
def get_items_tagged_to_wip_composite_asset(params):
def get_items_tagged_to_wip_composite_asset(params: dict | str):
if isinstance(params, str):
params = json.loads(params)

View File

@@ -9,9 +9,11 @@ from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import (
create_asset,
create_asset_data,
create_fixed_asset_item,
set_depreciation_settings_in_company,
)
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
make_serial_batch_bundle,
)
@@ -57,7 +59,7 @@ class TestAssetCapitalization(IntegrationTestCase):
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset",
is_composite_asset=1,
asset_type="Composite Asset",
warehouse="Stores - TCP1",
company=company,
)
@@ -77,7 +79,6 @@ class TestAssetCapitalization(IntegrationTestCase):
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.target_qty, 1)
self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount)
@@ -152,7 +153,7 @@ class TestAssetCapitalization(IntegrationTestCase):
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset",
is_composite_asset=1,
asset_type="Composite Asset",
warehouse="Stores - TCP1",
company=company,
)
@@ -172,8 +173,6 @@ class TestAssetCapitalization(IntegrationTestCase):
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.target_qty, 1)
self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount)
self.assertEqual(asset_capitalization.stock_items_total, stock_amount)
@@ -241,7 +240,7 @@ class TestAssetCapitalization(IntegrationTestCase):
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset",
is_composite_asset=1,
asset_type="Composite Asset",
warehouse="Stores - TCP1",
company=company,
)
@@ -258,8 +257,6 @@ class TestAssetCapitalization(IntegrationTestCase):
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.target_qty, 1)
self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate)
self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount)
self.assertEqual(asset_capitalization.stock_items_total, stock_amount)
@@ -309,7 +306,7 @@ class TestAssetCapitalization(IntegrationTestCase):
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset",
is_composite_asset=1,
asset_type="Composite Asset",
warehouse="Stores - TCP1",
company=company,
)
@@ -357,33 +354,45 @@ class TestAssetCapitalization(IntegrationTestCase):
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset",
is_composite_asset=1,
asset_type="Composite Asset",
warehouse="Stores - TCP1",
company=company,
)
consumed_asset_value = 100000
consumed_asset = create_asset(
asset_name="Asset Capitalization Consumable Asset",
asset_value=consumed_asset_value,
submit=1,
warehouse="Stores - _TC",
is_composite_component=1,
item = create_fixed_asset_item("Asset Capitalization Consumable Asset")
pr = make_purchase_receipt(
item_code=item.item_code,
qty=1,
rate=consumed_asset_value,
company=company,
warehouse="Stores - TCP1",
)
consumed_asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name")
consumed_asset_doc = frappe.get_doc("Asset", consumed_asset_name)
consumed_asset_doc.update(
{
"asset_type": "Composite Component",
"purchase_date": pr.posting_date,
"available_for_use_date": pr.posting_date,
}
)
consumed_asset_doc.save()
consumed_asset_doc.submit()
# Create and submit Asset Captitalization
asset_capitalization = create_asset_capitalization(
target_asset=wip_composite_asset.name,
target_asset_location="Test Location",
consumed_asset=consumed_asset.name,
consumed_asset=consumed_asset_doc.name,
company=company,
submit=1,
)
# Test Asset Capitalization values
self.assertEqual(asset_capitalization.target_qty, 1)
self.assertEqual(asset_capitalization.asset_items[0].asset_value, consumed_asset_value)
actual_gle = get_actual_gle_dict(asset_capitalization.name)
@@ -417,9 +426,6 @@ def create_asset_capitalization(**args):
"target_item_code": target_item_code,
"target_asset": target_asset.name,
"target_asset_location": "Test Location",
"target_qty": flt(args.target_qty) or 1,
"target_batch_no": args.target_batch_no,
"target_serial_no": args.target_serial_no,
"finance_book": args.finance_book,
}
)
@@ -512,7 +518,7 @@ def create_depreciation_asset(**args):
args = frappe._dict(args)
asset = frappe.new_doc("Asset")
asset.is_existing_asset = 1
asset.asset_type = args.asset_type or "Existing Asset"
asset.calculate_depreciation = 1
asset.asset_owner = "Company"

View File

@@ -31,7 +31,7 @@ class AssetCategory(Document):
self.validate_finance_books()
self.validate_account_types()
self.validate_account_currency()
self.valide_cwip_account()
self.validate_accounts()
def validate_finance_books(self):
for d in self.finance_books:
@@ -97,11 +97,21 @@ class AssetCategory(Document):
title=_("Invalid Account"),
)
def valide_cwip_account(self):
def validate_accounts(self):
self.validate_duplicate_rows()
self.validate_cwip_accounts()
self.validate_depreciation_accounts()
def validate_duplicate_rows(self):
companies = {row.company_name for row in self.accounts}
if len(companies) != len(self.accounts):
frappe.throw(_("Cannot set multiple account rows for the same company"))
def validate_cwip_accounts(self):
if self.enable_cwip_accounting:
missing_cwip_accounts_for_company = []
for d in self.accounts:
if not d.capital_work_in_progress_account and not frappe.db.get_value(
if not d.capital_work_in_progress_account and not frappe.get_cached_value(
"Company", d.company_name, "capital_work_in_progress_account"
):
missing_cwip_accounts_for_company.append(get_link_to_form("Company", d.company_name))
@@ -115,6 +125,71 @@ class AssetCategory(Document):
)
frappe.throw(msg, title=_("Missing Account"))
def validate_depreciation_accounts(self):
depreciation_account_map = {
"accumulated_depreciation_account": "Accumulated Depreciation Account",
"depreciation_expense_account": "Depreciation Expense Account",
}
error_msg = []
companies_with_accounts = set()
def validate_company_accounts(company, acc_row=None):
default_accounts = frappe.get_cached_value(
"Company",
company,
["accumulated_depreciation_account", "depreciation_expense_account"],
as_dict=True,
)
for fieldname, label in depreciation_account_map.items():
row_value = acc_row.get(fieldname) if acc_row else None
if not row_value and not default_accounts.get(fieldname):
if acc_row:
error_msg.append(
_("Row #{0}: Missing <b>{1}</b> for company <b>{2}</b>.").format(
acc_row.idx,
label,
get_link_to_form("Company", company),
)
)
else:
msg = _("Missing account configuration for company <b>{0}</b>.").format(
get_link_to_form("Company", company),
)
if msg not in error_msg:
error_msg.append(msg)
companies_with_assets = frappe.db.get_all(
"Asset",
{
"calculate_depreciation": 1,
"asset_category": self.name,
"status": ["in", ("Submitted", "Partially Depreciated")],
},
pluck="company",
distinct=True,
)
for acc_row in self.accounts:
companies_with_accounts.add(acc_row.company_name)
if acc_row.company_name in companies_with_assets:
validate_company_accounts(acc_row.company_name, acc_row)
for company in companies_with_assets:
if company not in companies_with_accounts:
validate_company_accounts(company)
if error_msg:
msg = _(
"Since there are active depreciable assets under this category, the following accounts are required. <br><br>"
)
msg += _(
"You can either configure default depreciation accounts in the Company or set the required accounts in the following rows: <br><br>"
)
msg += "<br>".join(error_msg)
frappe.throw(msg, title=_("Missing Accounts"))
def get_asset_category_account(
fieldname, item=None, asset=None, account=None, asset_category=None, company=None

View File

@@ -4,6 +4,8 @@
import frappe
from frappe.tests import IntegrationTestCase
from erpnext.assets.doctype.asset.test_asset import create_asset
class TestAssetCategory(IntegrationTestCase):
def test_mandatory_fields(self):
@@ -50,3 +52,67 @@ class TestAssetCategory(IntegrationTestCase):
)
self.assertRaises(frappe.ValidationError, asset_category.insert)
def test_duplicate_company_accounts(self):
asset_category = frappe.get_doc(
{
"doctype": "Asset Category",
"asset_category_name": "Computers",
"accounts": [
{
"company_name": "_Test Company",
"fixed_asset_account": "_Test Fixed Asset - _TC",
},
{
"company_name": "_Test Company",
"fixed_asset_account": "_Test Fixed Asset - _TC",
},
],
}
)
with self.assertRaises(frappe.ValidationError) as err:
asset_category.save()
self.assertTrue("Cannot set multiple account rows for the same company" in str(err.exception))
def test_depreciation_accounts_required_for_existing_depreciable_assets(self):
asset = create_asset(
asset_category="Computers",
calculate_depreciation=1,
company="_Test Company",
submit=1,
)
company_acccount_depreciation = frappe.db.get_value(
"Company",
asset.company,
[
"accumulated_depreciation_account",
"depreciation_expense_account",
],
as_dict=True,
)
frappe.db.set_value(
"Company",
asset.company,
{
"accumulated_depreciation_account": "",
"depreciation_expense_account": "",
},
)
try:
asset_category = frappe.get_doc("Asset Category", asset.asset_category)
asset_category.enable_cwip_accounting = 0
for row in asset_category.accounts:
if row.company_name == asset.company and (
row.accumulated_depreciation_account or row.depreciation_expense_account
):
row.accumulated_depreciation_account = None
row.depreciation_expense_account = None
with self.assertRaises(frappe.ValidationError) as err:
asset_category.save()
self.assertTrue(
"Since there are active depreciable assets under this category, the following accounts are required."
in str(err.exception)
)
finally:
frappe.db.set_value("Company", asset.company, company_acccount_depreciation)

View File

@@ -271,7 +271,7 @@ def get_asset_shift_factors_map():
@frappe.whitelist()
def get_depr_schedule(asset_name, status, finance_book=None):
def get_depr_schedule(asset_name: str, status: str, finance_book: str | None = None):
asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_name, status, finance_book)
if not asset_depr_schedule_doc:
@@ -281,13 +281,13 @@ def get_depr_schedule(asset_name, status, finance_book=None):
@frappe.whitelist()
def get_asset_depr_schedule_doc(asset_name, status=None, finance_book=None):
def get_asset_depr_schedule_doc(asset_name: str, status: str | None = None, finance_book: str | None = None):
asset_depr_schedule = get_asset_depr_schedule_name(asset_name, status, finance_book)
if not asset_depr_schedule:
return
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule[0].name)
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule)
return asset_depr_schedule_doc
@@ -299,21 +299,23 @@ def get_asset_depr_schedule_name(asset_name, status=None, finance_book=None):
]
if status:
if isinstance(status, str):
status = [status]
filters.append(["status", "in", status])
status_list = [status] if isinstance(status, str) else status
filters.append(["status", "in", status_list])
if finance_book:
filters.append(["finance_book", "=", finance_book])
else:
filters.append(["finance_book", "is", "not set"])
finance_book_filter = (
["finance_book", "=", finance_book] if finance_book else ["finance_book", "is", "not set"]
)
filters.append(finance_book_filter)
return frappe.get_all(
depreciation_schedules = frappe.get_all(
doctype="Asset Depreciation Schedule",
filters=filters,
fields=["name"],
limit=1,
)
return depreciation_schedules[0].name if depreciation_schedules else None
def is_first_day_of_the_month(date):
first_day_of_the_month = get_first_day(date)

View File

@@ -87,7 +87,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
calculate_depreciation=1,
depreciation_method="Straight Line",
available_for_use_date="2023-10-10",
is_existing_asset=1,
asset_type="Existing Asset",
opening_number_of_booked_depreciations=9,
opening_accumulated_depreciation=265,
depreciation_start_date="2024-07-31",
@@ -127,7 +127,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
calculate_depreciation=1,
depreciation_method="Straight Line",
available_for_use_date="2023-10-10",
is_existing_asset=1,
asset_type="Existing Asset",
opening_number_of_booked_depreciations=9,
opening_accumulated_depreciation=265.30,
depreciation_start_date="2024-07-31",
@@ -165,7 +165,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
calculate_depreciation=1,
depreciation_method="Straight Line",
available_for_use_date="2023-11-01",
is_existing_asset=1,
asset_type="Existing Asset",
opening_number_of_booked_depreciations=4,
opening_accumulated_depreciation=223.15,
depreciation_start_date="2024-12-31",
@@ -529,7 +529,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
depreciation_start_date="2023-03-31",
frequency_of_depreciation=1,
total_number_of_depreciations=12,
is_existing_asset=1,
asset_type="Existing Asset",
opening_accumulated_depreciation=64.52,
opening_number_of_booked_depreciations=2,
submit=1,
@@ -851,7 +851,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
depreciation_start_date="2023-03-31",
frequency_of_depreciation=1,
total_number_of_depreciations=12,
is_existing_asset=1,
asset_type="Existing Asset",
opening_accumulated_depreciation=64.52,
opening_number_of_booked_depreciations=2,
submit=1,
@@ -925,7 +925,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
depreciation_start_date="2021-12-31",
frequency_of_depreciation=12,
total_number_of_depreciations=3,
is_existing_asset=1,
asset_type="Existing Asset",
submit=1,
)
post_depreciation_entries(date="2021-12-31")
@@ -1014,7 +1014,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
depreciation_start_date="2021-12-31",
frequency_of_depreciation=12,
total_number_of_depreciations=3,
is_existing_asset=1,
asset_type="Existing Asset",
submit=1,
)
post_depreciation_entries(date="2021-12-31")
@@ -1093,7 +1093,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
rate_of_depreciation=50,
frequency_of_depreciation=12,
total_number_of_depreciations=3,
is_existing_asset=1,
asset_type="Existing Asset",
submit=1,
)
post_depreciation_entries(date="2021-12-31")

View File

@@ -2,11 +2,13 @@
# For license information, please see license.txt
from typing import Any
import frappe
from frappe import _, throw
from frappe.desk.form import assign_to
from frappe.model.document import Document
from frappe.utils import add_days, add_months, add_years, getdate, nowdate
from frappe.utils import DateTimeLikeObject, add_days, add_months, add_years, getdate, nowdate
class AssetMaintenance(Document):
@@ -90,7 +92,11 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex
@frappe.whitelist()
def calculate_next_due_date(
periodicity, start_date=None, end_date=None, last_completion_date=None, next_due_date=None
periodicity: str,
start_date: DateTimeLikeObject | None = None,
end_date: DateTimeLikeObject | None = None,
last_completion_date: DateTimeLikeObject | None = None,
next_due_date: DateTimeLikeObject | None = None,
):
if not start_date and not last_completion_date:
start_date = frappe.utils.now()
@@ -164,19 +170,30 @@ def update_maintenance_log(asset_maintenance, item_code, item_name, task):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_team_members(doctype, txt, searchfield, start, page_len, filters):
def get_team_members(
doctype: str,
txt: str,
searchfield: str,
start: int,
page_len: int,
filters: dict[str, Any],
) -> list[tuple[str]]:
return frappe.db.get_values(
"Maintenance Team Member", {"parent": filters.get("maintenance_team")}, "team_member"
"Maintenance Team Member",
{"parent": filters.get("maintenance_team")},
"team_member",
)
@frappe.whitelist()
def get_maintenance_log(asset_name):
def get_maintenance_log(asset_name: str):
return frappe.db.sql(
"""
select maintenance_status, count(asset_name) as count, asset_name
from `tabAsset Maintenance Log`
where asset_name=%s group by maintenance_status""",
(asset_name),
where asset_name=%s
group by maintenance_status
""",
(asset_name,),
as_dict=1,
)

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr, get_link_to_form
from frappe.utils import cstr, get_datetime, get_link_to_form
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
@@ -34,6 +34,7 @@ class AssetMovement(Document):
for d in self.assets:
self.validate_asset(d)
self.validate_movement(d)
self.validate_transaction_date(d)
def validate_asset(self, d):
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
@@ -51,6 +52,18 @@ class AssetMovement(Document):
else:
self.validate_employee(d)
def validate_transaction_date(self, d):
previous_movement_date = frappe.db.get_value(
"Asset Movement",
[["Asset Movement Item", "asset", "=", d.asset], ["docstatus", "=", 1]],
"transaction_date",
order_by="transaction_date desc",
)
if previous_movement_date and get_datetime(previous_movement_date) > get_datetime(
self.transaction_date
):
frappe.throw(_("Transaction date can't be earlier than previous movement date"))
def validate_location_and_employee(self, d):
self.validate_location(d)
self.validate_employee(d)

View File

@@ -3,9 +3,9 @@
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import now
from frappe.utils import add_days, now
from erpnext.assets.doctype.asset.test_asset import create_asset_data
from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -146,6 +146,33 @@ class TestAssetMovement(IntegrationTestCase):
movement1.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
def test_movement_transaction_date(self):
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
asset.save().submit()
if not frappe.db.exists("Location", "Test Location 2"):
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
asset_creation_date = frappe.db.get_value(
"Asset Movement",
[["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]],
"transaction_date",
)
asset_movement = create_asset_movement(
purpose="Transfer",
company=asset.company,
assets=[
{
"asset": asset.name,
"source_location": "Test Location",
"target_location": "Test Location 2",
}
],
transaction_date=add_days(asset_creation_date, -1),
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, asset_movement.save)
def create_asset_movement(**args):
args = frappe._dict(args)
@@ -164,9 +191,10 @@ def create_asset_movement(**args):
"reference_name": args.reference_name,
}
)
movement.insert()
movement.submit()
if not args.do_not_save:
movement.insert(ignore_if_duplicate=True)
if not args.do_not_submit:
movement.submit()
return movement

View File

@@ -9,9 +9,9 @@
"engine": "InnoDB",
"field_order": [
"naming_series",
"company",
"asset",
"asset_name",
"company",
"column_break_2",
"repair_status",
"failure_date",
@@ -28,10 +28,6 @@
"column_break_ajbh",
"column_break_hkem",
"repair_cost",
"accounting_dimensions_section",
"cost_center",
"column_break_14",
"project",
"stock_consumption_details_section",
"stock_items",
"section_break_ltbb",
@@ -43,7 +39,12 @@
"capitalize_repair_cost",
"increase_in_asset_life",
"column_break_xebe",
"total_repair_cost"
"total_repair_cost",
"accounting_dimensions_section",
"cost_center",
"column_break_14",
"project",
"connection_tab"
],
"fields": [
{
@@ -149,8 +150,7 @@
{
"fieldname": "accounting_details",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Repair Purchase Invoices"
"hide_border": 1
},
{
"fieldname": "stock_items",
@@ -206,6 +206,7 @@
{
"fieldname": "invoices",
"fieldtype": "Table",
"label": "Repair Purchase Invoices",
"mandatory_depends_on": "eval: doc.repair_status == 'Completed' && doc.repair_cost > 0;",
"no_copy": 1,
"options": "Asset Repair Purchase Invoice"
@@ -244,6 +245,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval: doc.consumed_items_cost",
"fieldname": "consumed_items_cost",
"fieldtype": "Currency",
"label": "Consumed Items Cost"
@@ -256,7 +258,13 @@
"depends_on": "capitalize_repair_cost",
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
"label": "Accounting Dimension"
},
{
"fieldname": "connection_tab",
"fieldtype": "Tab Break",
"label": "Connection",
"show_dashboard": 1
}
],
"index_web_pages_for_search": 1,
@@ -267,7 +275,7 @@
"link_fieldname": "asset_repair"
}
],
"modified": "2026-01-06 15:48:13.862505",
"modified": "2026-02-06 14:57:54.257572",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",

View File

@@ -5,7 +5,7 @@ import frappe
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, time_diff_in_hours
from frappe.utils import DateTimeLikeObject, cint, flt, get_link_to_form, getdate, time_diff_in_hours
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -448,14 +448,21 @@ class AssetRepair(AccountsController):
@frappe.whitelist()
def get_downtime(failure_date, completion_date):
def get_downtime(failure_date: DateTimeLikeObject, completion_date: DateTimeLikeObject):
downtime = time_diff_in_hours(completion_date, failure_date)
return round(downtime, 2)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_invoice(doctype, txt, searchfield, start, page_len, filters):
def get_purchase_invoice(
doctype: str,
txt: str,
searchfield: str,
start: int,
page_len: int,
filters: dict,
):
"""
Get Purchase Invoices that have expense accounts for non-stock items.
Only returns invoices with at least one non-stock, non-fixed-asset item with an expense account.
@@ -490,7 +497,14 @@ def get_purchase_invoice(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_expense_accounts(doctype, txt, searchfield, start, page_len, filters):
def get_expense_accounts(
doctype: str,
txt: str,
searchfield: str,
start: int,
page_len: int,
filters: dict,
):
"""
Get expense accounts for non-stock (service) items from the purchase invoice.
Used as a query function for link fields.
@@ -548,7 +562,7 @@ def _get_expense_accounts_for_purchase_invoice(purchase_invoice: str) -> list[st
@frappe.whitelist()
def get_unallocated_repair_cost(
purchase_invoice: str, expense_account: str, exclude_asset_repair: str | None = None
) -> float:
):
"""
Calculate the unused repair cost for a purchase invoice and expense account.
"""

View File

@@ -210,26 +210,29 @@ class TestAssetRepair(IntegrationTestCase):
self.assertRaises(frappe.ValidationError, asset_repair2.save)
def test_gl_entries_with_perpetual_inventory(self):
set_depreciation_settings_in_company(company="_Test Company with perpetual inventory")
company = "_Test Company with perpetual inventory"
set_depreciation_settings_in_company(company)
asset_category = frappe.get_doc("Asset Category", "Computers")
asset_category.append(
"accounts",
{
"company_name": "_Test Company with perpetual inventory",
"fixed_asset_account": "_Test Fixed Asset - TCP1",
"accumulated_depreciation_account": "_Test Accumulated Depreciations - TCP1",
"depreciation_expense_account": "_Test Depreciations - TCP1",
"capital_work_in_progress_account": "CWIP Account - TCP1",
},
)
asset_category.save()
if not any(row.company_name == company for row in asset_category.accounts):
asset_category.append(
"accounts",
{
"company_name": company,
"fixed_asset_account": "_Test Fixed Asset - TCP1",
"accumulated_depreciation_account": "_Test Accumulated Depreciations - TCP1",
"depreciation_expense_account": "_Test Depreciations - TCP1",
"capital_work_in_progress_account": "CWIP Account - TCP1",
},
)
asset_category.save()
asset_repair = create_asset_repair(
capitalize_repair_cost=1,
stock_consumption=1,
warehouse="Stores - TCP1",
company="_Test Company with perpetual inventory",
company=company,
pi_expense_account1="Administrative Expenses - TCP1",
pi_expense_account2="Legal Expenses - TCP1",
item="_Test Non Stock Item",
@@ -359,7 +362,7 @@ class TestAssetRepair(IntegrationTestCase):
self.assertEqual(stock_entry.asset_repair, asset_repair.name)
def test_gl_entries_with_capitalized_asset_repair(self):
asset = create_asset(is_existing_asset=1, calculate_depreciation=1, submit=1)
asset = create_asset(asset_type="Existing Asset", calculate_depreciation=1, submit=1)
asset_repair = create_asset_repair(
asset=asset, capitalize_repair_cost=1, item="_Test Non Stock Item", submit=1
)
@@ -399,7 +402,7 @@ def create_asset_repair(**args):
if args.asset:
asset = args.asset
else:
asset = create_asset(is_existing_asset=1, submit=1, company=args.company)
asset = create_asset(asset_type=args.asset_type or "Existing Asset", submit=1, company=args.company)
asset_repair = frappe.new_doc("Asset Repair")
asset_repair.update(
{

View File

@@ -227,6 +227,6 @@ class AssetValueAdjustment(Document):
@frappe.whitelist()
def get_value_of_accounting_dimensions(asset_name):
def get_value_of_accounting_dimensions(asset_name: str):
dimension_fields = [*frappe.get_list("Accounting Dimension", pluck="fieldname"), "cost_center"]
return frappe.db.get_value("Asset", asset_name, fieldname=dimension_fields, as_dict=True)

View File

@@ -211,7 +211,7 @@ def _ring_area(coords):
@frappe.whitelist()
def get_children(doctype, parent=None, location=None, is_root=False):
def get_children(doctype: str, parent: str | None = None, location: str | None = None, is_root: bool = False):
if parent is None or parent == "All Locations":
parent = ""

View File

@@ -143,7 +143,7 @@ def get_conditions(filters):
conditions[date_field] = ["between", [filters.year_start_date, filters.year_end_date]]
if filters.get("only_existing_assets"):
conditions["is_existing_asset"] = filters.get("only_existing_assets")
conditions["asset_type"] = "Existing Asset"
if filters.get("asset_category"):
conditions["asset_category"] = filters.get("asset_category")
if filters.get("cost_center"):
@@ -273,7 +273,7 @@ def get_asset_depreciation_amount_map(filters, finance_book):
)
if filters.only_existing_assets:
query = query.where(asset.is_existing_asset == 1)
query = query.where(asset.asset_type == "Existing Asset")
if filters.asset_category:
query = query.where(asset.asset_category == filters.asset_category)
if filters.cost_center:
@@ -324,7 +324,7 @@ def get_asset_value_adjustment_map(filters, finance_book):
)
if filters.only_existing_assets:
query = query.where(asset.is_existing_asset == 1)
query = query.where(asset.asset_type == "Existing Asset")
if filters.asset_category:
query = query.where(asset.asset_category == filters.asset_category)
if filters.cost_center:

View File

@@ -461,27 +461,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
}
}
get_items_from_open_material_requests() {
erpnext.utils.map_current_doc({
method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order_based_on_supplier",
args: {
supplier: this.frm.doc.supplier,
},
source_doctype: "Material Request",
source_name: this.frm.doc.supplier,
target: this.frm,
setters: {
company: this.frm.doc.company,
},
get_query_filters: {
docstatus: ["!=", 2],
supplier: this.frm.doc.supplier,
},
get_query_method:
"erpnext.stock.doctype.material_request.material_request.get_material_requests_based_on_supplier",
});
}
validate() {
set_schedule_date(this.frm);
}
@@ -803,7 +782,7 @@ frappe.ui.form.on("Purchase Order", "is_subcontracted", function (frm) {
function prevent_past_schedule_dates(frm) {
if (frm.doc.transaction_date) {
frm.fields_dict["schedule_date"].datepicker.update({
frm.fields_dict["schedule_date"].datepicker?.update({
minDate: new Date(frm.doc.transaction_date),
});
}

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