diff --git a/.github/workflows/initiate_release.yml b/.github/workflows/initiate_release.yml
index 5b6536844fc..f57d46b354d 100644
--- a/.github/workflows/initiate_release.yml
+++ b/.github/workflows/initiate_release.yml
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- version: ["14", "15"]
+ version: ["14", "15", "16"]
steps:
- uses: octokit/request-action@v2.x
diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml
index fac11c071db..96816e59dee 100644
--- a/.github/workflows/patch.yml
+++ b/.github/workflows/patch.yml
@@ -113,8 +113,8 @@ jobs:
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
- wget https://erpnext.com/files/v13-erpnext.sql.gz
- bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
+ wget https://frappe.io/files/erpnext-v14.sql.gz
+ bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
@@ -142,8 +142,8 @@ jobs:
bench --site test_site migrate
}
- update_to_version 14 3.11
update_to_version 15 3.13
+ update_to_version 16 3.14
echo "Updating to latest version"
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml
index 036f587dbf9..2bfe5e7065d 100644
--- a/.github/workflows/server-tests-mariadb.yml
+++ b/.github/workflows/server-tests-mariadb.yml
@@ -7,6 +7,7 @@ on:
paths-ignore:
- '**.js'
- '**.css'
+ - '**.svg'
- '**.md'
- '**.html'
- 'crowdin.yml'
diff --git a/.mergify.yml b/.mergify.yml
index 54e1bce46f2..5e558062048 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -50,13 +50,13 @@ pull_request_rules:
- version-15-hotfix
assignees:
- "{{ author }}"
- - name: backport to version-16-beta
+ - name: backport to version-16-hotfix
conditions:
- - label="backport version-16-beta"
+ - label="backport version-16-hotfix"
actions:
backport:
branches:
- - version-16-beta
+ - version-16-hotfix
assignees:
- "{{ author }}"
- name: Automatic merge on CI success and review
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index 6ecfb8df807..7e72b2b585d 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
-__version__ = "16.0.0-dev"
+__version__ = "17.0.0-dev"
def get_default_company(user=None):
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index 55db06f8ca7..2b741136f96 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -64,10 +64,6 @@
"role_allowed_to_over_bill",
"credit_controller",
"make_payment_via_journal_entry",
- "pos_tab",
- "pos_setting_section",
- "post_change_gl_entries",
- "column_break_xrnd",
"assets_tab",
"asset_settings_section",
"calculate_depr_using_total_days",
@@ -79,11 +75,6 @@
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"column_break_25",
- "tab_break_dpet",
- "show_balance_in_coa",
- "banking_tab",
- "enable_party_matching",
- "enable_fuzzy_matching",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
@@ -96,9 +87,15 @@
"drop_ar_procedures",
"legacy_section",
"ignore_is_opening_check_for_reporting",
- "payment_request_settings",
+ "tab_break_dpet",
+ "chart_of_accounts_section",
+ "show_balance_in_coa",
+ "banking_section",
+ "enable_party_matching",
+ "enable_fuzzy_matching",
+ "payment_request_section",
"create_pr_in_draft_status",
- "budget_settings",
+ "budget_section",
"use_legacy_budget_controller"
],
"fields": [
@@ -282,13 +279,6 @@
"fieldname": "column_break_19",
"fieldtype": "Column Break"
},
- {
- "default": "1",
- "description": "If enabled, ledger entries will be posted for change amount in POS transactions",
- "fieldname": "post_change_gl_entries",
- "fieldtype": "Check",
- "label": "Create Ledger Entries for Change Amount"
- },
{
"default": "0",
"description": "Learn about Common Party",
@@ -329,11 +319,6 @@
"fieldtype": "Tab Break",
"label": "Accounts Closing"
},
- {
- "fieldname": "pos_setting_section",
- "fieldtype": "Section Break",
- "label": "POS Setting"
- },
{
"fieldname": "invoice_and_billing_tab",
"fieldtype": "Tab Break",
@@ -348,11 +333,6 @@
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
- {
- "fieldname": "pos_tab",
- "fieldtype": "Tab Break",
- "label": "POS"
- },
{
"default": "0",
"description": "Enabling this will allow creation of multi-currency invoices against single party account in company currency",
@@ -363,7 +343,7 @@
{
"fieldname": "tab_break_dpet",
"fieldtype": "Tab Break",
- "label": "Chart Of Accounts"
+ "label": "Others"
},
{
"default": "1",
@@ -407,11 +387,6 @@
"fieldtype": "Check",
"label": "Show Taxes as Table in Print"
},
- {
- "fieldname": "banking_tab",
- "fieldtype": "Tab Break",
- "label": "Banking"
- },
{
"default": "0",
"description": "Auto match and set the Party in Bank Transactions",
@@ -487,14 +462,9 @@
"fieldtype": "Check",
"label": "Calculate daily depreciation using total days in depreciation period"
},
- {
- "description": "Payment Request created from Sales Order or Purchase Order will be in Draft status. When disabled document will be in unsaved state.",
- "fieldname": "payment_request_settings",
- "fieldtype": "Tab Break",
- "label": "Payment Request"
- },
{
"default": "1",
+ "description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
"fieldname": "create_pr_in_draft_status",
"fieldtype": "Check",
"label": "Create in Draft Status"
@@ -536,10 +506,6 @@
"label": "Posting Date Inheritance for Exchange Gain / Loss",
"options": "Invoice\nPayment\nReconciliation Date"
},
- {
- "fieldname": "column_break_xrnd",
- "fieldtype": "Column Break"
- },
{
"default": "Buffered Cursor",
"fieldname": "receivable_payable_fetch_method",
@@ -579,11 +545,6 @@
"label": "Role Allowed to Override Stop Action",
"options": "Role"
},
- {
- "fieldname": "budget_settings",
- "fieldtype": "Tab Break",
- "label": "Budget"
- },
{
"default": "1",
"description": "If enabled, user will be alerted before resetting posting date to current date in relevant transactions",
@@ -656,6 +617,26 @@
"fieldname": "default_ageing_range",
"fieldtype": "Data",
"label": "Default Ageing Range"
+ },
+ {
+ "fieldname": "chart_of_accounts_section",
+ "fieldtype": "Section Break",
+ "label": "Chart Of Accounts"
+ },
+ {
+ "fieldname": "banking_section",
+ "fieldtype": "Section Break",
+ "label": "Banking"
+ },
+ {
+ "fieldname": "payment_request_section",
+ "fieldtype": "Section Break",
+ "label": "Payment Request"
+ },
+ {
+ "fieldname": "budget_section",
+ "fieldtype": "Section Break",
+ "label": "Budget"
}
],
"grid_page_length": 50,
@@ -665,7 +646,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-01-02 18:17:18.994348",
+ "modified": "2026-01-11 18:30:45.968531",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
index 73d51000a5b..dbe86f6d7b2 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
@@ -57,7 +57,6 @@ class AccountsSettings(Document):
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
- post_change_gl_entries: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index 93b95d7c02e..ce435482ae5 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -184,6 +184,9 @@ class JournalEntry(AccountsController):
else:
return self._submit()
+ def before_cancel(self):
+ self.has_asset_adjustment_entry()
+
def cancel(self):
if len(self.accounts) > 100:
queue_submission(self, "_cancel")
@@ -554,12 +557,27 @@ class JournalEntry(AccountsController):
)
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
- def unlink_asset_adjustment_entry(self):
- frappe.db.sql(
- """ update `tabAsset Value Adjustment`
- set journal_entry = null where journal_entry = %s""",
- self.name,
+ def has_asset_adjustment_entry(self):
+ if self.flags.get("via_asset_value_adjustment"):
+ return
+
+ asset_value_adjustment = frappe.db.get_value(
+ "Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
)
+ if asset_value_adjustment:
+ frappe.throw(
+ _(
+ "Cannot cancel this document as it is linked with the submitted Asset Value Adjustment {0}. Please cancel the Asset Value Adjustment to continue."
+ ).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
+ )
+
+ def unlink_asset_adjustment_entry(self):
+ AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
+ (
+ frappe.qb.update(AssetValueAdjustment)
+ .set(AssetValueAdjustment.journal_entry, None)
+ .where(AssetValueAdjustment.journal_entry == self.name)
+ ).run()
def validate_party(self):
for d in self.get("accounts"):
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index eb1673a8798..350e8b700a9 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -1285,8 +1285,11 @@ class PaymentEntry(AccountsController):
def make_gl_entries(self, cancel=0, adv_adj=0):
gl_entries = self.build_gl_map()
- gl_entries = process_gl_map(gl_entries)
- make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
+
+ merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
+
+ gl_entries = process_gl_map(gl_entries, merge_entries=merge_entries)
+ make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj, merge_entries=merge_entries)
if cancel:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
else:
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 355aa46ea05..f6c240c0714 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -1045,6 +1045,7 @@ class TestPaymentEntry(IntegrationTestCase):
)
def test_gl_of_multi_currency_payment_with_taxes(self):
+ frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
payment_entry = create_payment_entry(
party="_Test Supplier USD", paid_to="_Test Payable USD - _TC", save=True
)
@@ -1606,6 +1607,96 @@ class TestPaymentEntry(IntegrationTestCase):
self.voucher_no = pe.name
self.check_gl_entries()
+ def test_payment_entry_merges_gl_entries_with_same_account_head(self):
+ """
+ Test that Payment Entry merges GL entries with same account head
+ when 'Merge Similar Account Heads' setting is enabled.
+ """
+ frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
+
+ pe = create_payment_entry(
+ party_type="Supplier",
+ party="_Test Supplier",
+ paid_from="_Test Bank - _TC",
+ paid_to="Creditors - _TC",
+ )
+
+ pe.append(
+ "deductions",
+ {
+ "account": "Write Off - _TC",
+ "cost_center": "_Test Cost Center - _TC",
+ "amount": 50,
+ },
+ )
+
+ pe.append(
+ "deductions",
+ {
+ "account": "Write Off - _TC",
+ "cost_center": "_Test Cost Center - _TC",
+ "amount": 30,
+ },
+ )
+
+ pe.save()
+ pe.submit()
+
+ gl_entries = frappe.db.get_all(
+ "GL Entry",
+ filters={"voucher_no": pe.name, "account": "Write Off - _TC", "is_cancelled": 0},
+ fields=["debit", "credit"],
+ )
+
+ self.assertEqual(len(gl_entries), 1)
+ self.assertEqual(gl_entries[0].debit, 80)
+
+ def test_payment_entry_does_not_merge_gl_entries_when_setting_disabled(self):
+ """
+ Test that Payment Entry does NOT merge GL entries
+ when 'Merge Similar Account Heads' is disabled.
+ """
+
+ frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
+
+ pe = create_payment_entry(
+ party_type="Supplier",
+ party="_Test Supplier",
+ paid_from="_Test Bank - _TC",
+ paid_to="Creditors - _TC",
+ )
+
+ pe.append(
+ "deductions",
+ {
+ "account": "Write Off - _TC",
+ "cost_center": "_Test Cost Center - _TC",
+ "amount": 50,
+ },
+ )
+
+ pe.append(
+ "deductions",
+ {
+ "account": "Write Off - _TC",
+ "cost_center": "_Test Cost Center - _TC",
+ "amount": 30,
+ },
+ )
+
+ pe.save()
+ pe.submit()
+
+ gl_entries = frappe.db.get_all(
+ "GL Entry",
+ filters={"voucher_no": pe.name, "account": "Write Off - _TC", "is_cancelled": 0},
+ fields=["debit", "credit"],
+ )
+
+ self.assertEqual(len(gl_entries), 2)
+
+ frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
+
def check_pl_entries(self):
ple = frappe.qb.DocType("Payment Ledger Entry")
pl_entries = (
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index e03db121473..b574941721f 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -6,7 +6,7 @@ import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
-from frappe.query_builder import Criterion
+from frappe.query_builder import Case, Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
@@ -393,6 +393,9 @@ class PaymentReconciliation(Document):
inv.outstanding_amount = flt(entry.get("outstanding_amount"))
def get_difference_amount(self, payment_entry, invoice, allocated_amount):
+ party_account_defaults = frappe.get_cached_value(
+ "Account", self.receivable_payable_account, ["account_type", "account_currency"], as_dict=True
+ )
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
@@ -400,9 +403,9 @@ class PaymentReconciliation(Document):
frappe.get_meta("Payment Reconciliation Allocation").get_field("difference_amount")
)
difference_amount = 0
- if frappe.get_cached_value(
- "Account", self.receivable_payable_account, "account_currency"
- ) != frappe.get_cached_value("Company", self.company, "default_currency"):
+ if party_account_defaults.get("account_currency") != frappe.get_cached_value(
+ "Company", self.company, "default_currency"
+ ):
if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get(
"exchange_rate", 1
):
@@ -414,7 +417,14 @@ class PaymentReconciliation(Document):
invoice.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision),
difference_amount_precision,
)
- difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
+
+ # Added If clause to handle return Adhoc payments for account type holders ("Payable")
+ if party_account_defaults.get("account_type") in ("Payable") and invoice.get(
+ "invoice_type"
+ ) in ["Payment Entry", "Journal Entry"]:
+ difference_amount = allocated_amount_in_inv_rate - allocated_amount_in_ref_rate
+ else:
+ difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
return difference_amount
@@ -677,6 +687,28 @@ class PaymentReconciliation(Document):
)
invoice_exchange_map.update(journals_map)
+ payment_entries = [
+ d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Payment Entry"
+ ]
+ payment_entries.extend(
+ [d.get("reference_name") for d in payments if d.get("reference_type") == "Payment Entry"]
+ )
+ if payment_entries:
+ pe = frappe.qb.DocType("Payment Entry")
+ query = (
+ frappe.qb.from_(pe)
+ .select(
+ pe.name,
+ Case()
+ .when(pe.payment_type == "Receive", pe.source_exchange_rate)
+ .else_(pe.target_exchange_rate)
+ .as_("exchange_rate"),
+ )
+ .where(pe.name.isin(payment_entries))
+ )
+ payment_entries = query.run(as_list=1)
+ invoice_exchange_map.update(payment_entries)
+
return invoice_exchange_map
def validate_allocation(self):
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index b11f20ec90b..3682e7c63a9 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -2340,6 +2340,210 @@ class TestPaymentReconciliation(IntegrationTestCase):
frappe.db.set_value("Company", self.company, default_settings)
+ def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_customer(self):
+ transaction_date = nowdate()
+ customer = self.customer3
+ amount = 1000
+ exchange_rate_at_payment = 100
+ exchange_rate_at_reverse_payment = 95
+
+ # Receive amount from customer - 1,00,000
+ pe = self.create_payment_entry(amount=amount, posting_date=transaction_date, customer=customer)
+ pe.payment_type = "Receive"
+ pe.paid_from = self.debtors_eur
+ pe.paid_from_account_currency = "EUR"
+ pe.source_exchange_rate = exchange_rate_at_payment
+ pe.paid_amount = amount
+ pe.received_amount = exchange_rate_at_payment * amount
+ pe.paid_to = self.cash
+ pe.paid_to_account_currency = "INR"
+ pe = pe.save().submit()
+
+ # Pay amount to customer - 95,000
+ reverse_pe = self.create_payment_entry(
+ amount=amount, posting_date=transaction_date, customer=customer
+ )
+ reverse_pe.payment_type = "Pay"
+ reverse_pe.paid_from = self.cash
+ reverse_pe.paid_from_account_currency = "INR"
+ reverse_pe.target_exchange_rate = exchange_rate_at_reverse_payment
+ reverse_pe.paid_amount = exchange_rate_at_reverse_payment * amount
+ reverse_pe.received_amount = amount
+ reverse_pe.paid_to = self.debtors_eur
+ reverse_pe.paid_to_account_currency = "EUR"
+ reverse_pe.save().submit()
+
+ # Reconcile payments
+ pr = self.create_payment_reconciliation()
+ pr.party = customer
+ pr.receivable_payable_account = self.debtors_eur
+ pr.get_unreconciled_entries()
+ invoices = [invoice.as_dict() for invoice in pr.invoices]
+ payments = [payment.as_dict() for payment in pr.payments]
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(len(pr.get("payments")), 1)
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ # Check the difference_amount is a gain of 5000
+ self.assertEqual(flt(pr.allocation[0].get("difference_amount")), 5000.0)
+ pr.reconcile()
+
+ def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_supplier(self):
+ transaction_date = nowdate()
+ self.supplier = "_Test Supplier USD"
+ amount = 1000
+ exchange_rate_at_payment = 100
+ exchange_rate_at_reverse_payment = 95
+
+ # Pay amount to supplier - 1,00,000
+ pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
+ pe.payment_type = "Pay"
+ pe.party_type = "Supplier"
+ pe.party = self.supplier
+ pe.paid_from = self.cash
+ pe.paid_from_account_currency = "INR"
+ pe.target_exchange_rate = exchange_rate_at_payment
+ pe.paid_amount = exchange_rate_at_payment * amount
+ pe.received_amount = amount
+ pe.paid_to = self.creditors_usd
+ pe.paid_to_account_currency = "USD"
+ pe.save().submit()
+
+ # Receive amount from supplier - 95,000
+ reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
+ reverse_pe.payment_type = "Receive"
+ reverse_pe.party_type = "Supplier"
+ reverse_pe.party = self.supplier
+ reverse_pe.paid_from = self.creditors_usd
+ reverse_pe.paid_from_account_currency = "USD"
+ reverse_pe.source_exchange_rate = exchange_rate_at_reverse_payment
+ reverse_pe.paid_amount = amount
+ reverse_pe.received_amount = exchange_rate_at_reverse_payment * amount
+ reverse_pe.paid_to = self.cash
+ reverse_pe.paid_to_account_currency = "INR"
+ reverse_pe = reverse_pe.save().submit()
+
+ # Reconcile payments
+ pr = self.create_payment_reconciliation(party_is_customer=False)
+ pr.party = self.supplier
+ pr.receivable_payable_account = self.creditors_usd
+ pr.get_unreconciled_entries()
+ invoices = [invoice.as_dict() for invoice in pr.invoices]
+ payments = [payment.as_dict() for payment in pr.payments]
+
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(len(pr.get("payments")), 1)
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ # Check the difference_amount is a loss of 5000
+ self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
+ pr.reconcile()
+
+ def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
+ transaction_date = nowdate()
+ customer = self.customer3
+ amount = 1000
+ exchange_rate_at_payment = 95
+ exchange_rate_at_reverse_payment = 100
+
+ # Receive amount from customer - 95,000
+ je1 = self.create_journal_entry(self.cash, self.debtors_eur, amount, transaction_date)
+ je1.multi_currency = 1
+ je1.accounts[0].exchange_rate = 1
+ je1.accounts[0].debit_in_account_currency = exchange_rate_at_payment * amount
+ je1.accounts[0].debit = exchange_rate_at_payment * amount
+ je1.accounts[1].party_type = "Customer"
+ je1.accounts[1].party = customer
+ je1.accounts[1].exchange_rate = exchange_rate_at_payment
+ je1.accounts[1].credit_in_account_currency = amount
+ je1.accounts[1].credit = exchange_rate_at_payment * amount
+ je1.save()
+ je1.submit()
+
+ # Pay amount to customer - 1,00,000
+ je2 = self.create_journal_entry(self.debtors_eur, self.cash, amount, transaction_date)
+ je2.multi_currency = 1
+ je2.accounts[0].party_type = "Customer"
+ je2.accounts[0].party = customer
+ je2.accounts[0].exchange_rate = exchange_rate_at_reverse_payment
+ je2.accounts[0].debit_in_account_currency = amount
+ je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
+ je2.accounts[1].exchange_rate = 1
+ je2.accounts[1].credit_in_account_currency = exchange_rate_at_reverse_payment * amount
+ je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
+ je2.save()
+ je2.submit()
+
+ # Reconcile payments
+ pr = self.create_payment_reconciliation()
+ pr.party = customer
+ pr.receivable_payable_account = self.debtors_eur
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+
+ invoices = [invoice.as_dict() for invoice in pr.invoices]
+ payments = [payment.as_dict() for payment in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ # Check the difference_amount is a loss of 5000
+ self.assertEqual(flt(pr.allocation[0].difference_amount), -5000.0)
+ pr.reconcile()
+
+ def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_supplier(self):
+ transaction_date = nowdate()
+ self.supplier = "_Test Supplier USD"
+ amount = 1000
+ exchange_rate_at_payment = 95
+ exchange_rate_at_reverse_payment = 100
+
+ # Pay amount to supplier - 95,000
+ je1 = self.create_journal_entry(self.creditors_usd, self.cash, amount, transaction_date)
+ je1.multi_currency = 1
+ je1.accounts[0].party_type = "Supplier"
+ je1.accounts[0].party = self.supplier
+ je1.accounts[0].exchange_rate = exchange_rate_at_payment
+ je1.accounts[0].debit_in_account_currency = amount
+ je1.accounts[0].debit = exchange_rate_at_payment * amount
+ je1.accounts[1].exchange_rate = 1
+ je1.accounts[1].credit = exchange_rate_at_payment * amount
+ je1.accounts[1].credit_in_account_currency = exchange_rate_at_payment * amount
+ je1.save()
+ je1.submit()
+
+ # Receive amount from supplier - 1,00,000
+ je2 = self.create_journal_entry(self.cash, self.creditors_usd, amount, transaction_date)
+ je2.multi_currency = 1
+ je2.accounts[0].exchange_rate = 1
+ je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
+ je2.accounts[0].debit_in_account_currency = exchange_rate_at_reverse_payment * amount
+ je2.accounts[1].party_type = "Supplier"
+ je2.accounts[1].party = self.supplier
+ je2.accounts[1].exchange_rate = exchange_rate_at_reverse_payment
+ je2.accounts[1].credit_in_account_currency = amount
+ je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
+ je2.save()
+ je2.submit()
+
+ # Reconcile payments
+ pr = self.create_payment_reconciliation()
+ pr.party_type = "Supplier"
+ pr.party = self.supplier
+ pr.receivable_payable_account = self.creditors_usd
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+
+ invoices = [invoice.as_dict() for invoice in pr.invoices]
+ payments = [payment.as_dict() for payment in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ # Check the difference_amount is a gain of 5000
+ self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
+ pr.reconcile()
+
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index 58985195867..59d47dce726 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -539,6 +539,7 @@ class TestPOSInvoice(IntegrationTestCase):
rate=1000,
serial_no=[serial_nos[0]],
do_not_save=1,
+ ignore_sabb_validation=True,
)
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
@@ -1016,6 +1017,7 @@ class TestPOSInvoice(IntegrationTestCase):
qty=1,
rate=100,
do_not_submit=True,
+ ignore_sabb_validation=True,
)
self.assertRaises(frappe.ValidationError, pos_inv.submit)
@@ -1157,6 +1159,7 @@ def create_pos_invoice(**args):
"posting_time": pos_inv.posting_time,
"type_of_transaction": type_of_transaction,
"do_not_submit": True,
+ "ignore_sabb_validation": args.ignore_sabb_validation,
}
)
).name
diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.json b/erpnext/accounts/doctype/pos_settings/pos_settings.json
index ac0b884b3d7..7afc19423d8 100644
--- a/erpnext/accounts/doctype/pos_settings/pos_settings.json
+++ b/erpnext/accounts/doctype/pos_settings/pos_settings.json
@@ -6,6 +6,8 @@
"engine": "InnoDB",
"field_order": [
"invoice_type",
+ "column_break_vwwt",
+ "post_change_gl_entries",
"section_break_gyos",
"invoice_fields",
"pos_search_fields"
@@ -34,12 +36,24 @@
{
"fieldname": "section_break_gyos",
"fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_vwwt",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "description": "If enabled, ledger entries will be posted for change amount in POS transactions",
+ "fieldname": "post_change_gl_entries",
+ "fieldtype": "Check",
+ "label": "Create Ledger Entries for Change Amount",
+ "options": "1"
}
],
"hide_toolbar": 1,
"issingle": 1,
"links": [],
- "modified": "2026-01-02 18:18:17.586225",
+ "modified": "2026-01-09 17:30:41.476806",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Settings",
diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.py b/erpnext/accounts/doctype/pos_settings/pos_settings.py
index 4865262b83a..4f57326a528 100644
--- a/erpnext/accounts/doctype/pos_settings/pos_settings.py
+++ b/erpnext/accounts/doctype/pos_settings/pos_settings.py
@@ -23,6 +23,7 @@ class POSSettings(Document):
invoice_fields: DF.Table[POSField]
invoice_type: DF.Literal["Sales Invoice", "POS Invoice"]
pos_search_fields: DF.Table[POSSearchFields]
+ post_change_gl_entries: DF.Check
# end: auto-generated types
def validate(self):
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index adb11f8d615..cb771bdc35a 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -1498,6 +1498,8 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+ frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 1)
+
company = "_Test Company"
tds_account_args = {
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index ebf2d2f4b00..2d32832d25c 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -778,8 +778,7 @@
},
{
"collapsible": 1,
- "collapsible_depends_on": "eval:doc.total_billing_amount > 0",
- "depends_on": "eval:!doc.is_return",
+ "collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "time_sheet_list",
"fieldtype": "Section Break",
"hide_border": 1,
@@ -793,7 +792,6 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Time Sheets",
- "no_copy": 1,
"options": "Sales Invoice Timesheet",
"print_hide": 1
},
@@ -2092,7 +2090,7 @@
"fieldtype": "Column Break"
},
{
- "depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
+ "depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "section_break_104",
"fieldtype": "Section Break"
},
@@ -2306,7 +2304,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2025-10-09 14:48:59.472826",
+ "modified": "2025-12-24 18:29:50.242618",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 18a86434ef9..646d0418bc9 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -33,6 +33,7 @@ from erpnext.accounts.utils import (
get_account_currency,
update_voucher_outstanding,
)
+from erpnext.assets.doctype.asset.asset import split_asset
from erpnext.assets.doctype.asset.depreciation import (
depreciate_asset,
get_gl_entries_on_asset_disposal,
@@ -352,10 +353,22 @@ class SalesInvoice(SellingController):
self.is_opening = "No"
self.set_against_income_account()
- self.validate_time_sheets_are_submitted()
+
+ if self.is_return and not self.return_against and self.timesheets:
+ frappe.throw(_("Direct return is not allowed for Timesheet."))
+
+ if not self.is_return:
+ self.validate_time_sheets_are_submitted()
+
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
- if self.is_return:
- self.timesheets = []
+
+ if self.is_return and self.return_against:
+ for row in self.timesheets:
+ if row.billing_hours:
+ row.billing_hours = -abs(row.billing_hours)
+ if row.billing_amount:
+ row.billing_amount = -abs(row.billing_amount)
+
self.update_packing_list()
self.set_billing_hours_and_amount()
self.update_timesheet_billing_for_project()
@@ -468,6 +481,8 @@ class SalesInvoice(SellingController):
self.update_stock_reservation_entries()
self.update_stock_ledger()
+ self.split_asset_based_on_sale_qty()
+
self.process_asset_depreciation()
# this sequence because outstanding may get -ve
@@ -484,7 +499,7 @@ class SalesInvoice(SellingController):
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
- self.update_time_sheet(self.name)
+ self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
update_company_current_month_sales(self.company)
@@ -564,7 +579,7 @@ class SalesInvoice(SellingController):
self.check_if_consolidated_invoice()
super().before_cancel()
- self.update_time_sheet(None)
+ self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
@@ -804,8 +819,20 @@ class SalesInvoice(SellingController):
for data in timesheet.time_logs:
if (
(self.project and args.timesheet_detail == data.name)
- or (not self.project and not data.sales_invoice)
- or (not sales_invoice and data.sales_invoice == self.name)
+ or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
+ or (
+ not sales_invoice
+ and data.sales_invoice == self.name
+ and args.timesheet_detail == data.name
+ )
+ or (
+ self.is_return
+ and self.return_against
+ and data.sales_invoice
+ and data.sales_invoice == self.return_against
+ and not sales_invoice
+ and args.timesheet_detail == data.name
+ )
):
data.sales_invoice = sales_invoice
@@ -845,11 +872,26 @@ class SalesInvoice(SellingController):
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
def validate_time_sheets_are_submitted(self):
+ # Note: This validation is skipped for return invoices
+ # to allow returns to reference already-billed timesheet details
for data in self.timesheets:
+ # Handle invoice duplication
+ if data.time_sheet and data.timesheet_detail:
+ if sales_invoice := frappe.db.get_value(
+ "Timesheet Detail", data.timesheet_detail, "sales_invoice"
+ ):
+ frappe.throw(
+ _("Row {0}: Sales Invoice {1} is already created for {2}").format(
+ data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
+ )
+ )
+
if data.time_sheet:
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
- if status not in ["Submitted", "Payslip"]:
- frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet))
+ if status not in ["Submitted", "Payslip", "Partially Billed"]:
+ frappe.throw(
+ _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
+ )
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
@@ -1283,7 +1325,12 @@ class SalesInvoice(SellingController):
timesheet.billing_amount = ts_doc.total_billable_amount
def update_timesheet_billing_for_project(self):
- if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
+ if (
+ not self.is_return
+ and not self.timesheets
+ and self.project
+ and self.is_auto_fetch_timesheet_enabled()
+ ):
self.add_timesheet_data()
else:
self.calculate_billing_amount_for_timesheet()
@@ -1358,6 +1405,51 @@ class SalesInvoice(SellingController):
):
throw(_("Delivery Note {0} is not submitted").format(d.delivery_note))
+ def split_asset_based_on_sale_qty(self):
+ asset_qty_map = self.get_asset_qty()
+ for asset, qty in asset_qty_map.items():
+ if qty["actual_qty"] < qty["sale_qty"]:
+ frappe.throw(
+ _(
+ "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)."
+ ).format(asset, qty["actual_qty"])
+ )
+
+ remaining_qty = qty["actual_qty"] - qty["sale_qty"]
+ if remaining_qty > 0:
+ split_asset(asset, remaining_qty)
+
+ def get_asset_qty(self):
+ asset_qty_map = {}
+
+ assets = {row.asset for row in self.items if row.is_fixed_asset and row.asset}
+ if not assets or self.is_return:
+ return asset_qty_map
+
+ asset_actual_qty = dict(
+ frappe.db.get_all(
+ "Asset",
+ {"name": ["in", list(assets)]},
+ ["name", "asset_quantity"],
+ as_list=True,
+ )
+ )
+ for row in self.items:
+ if row.is_fixed_asset and row.asset:
+ actual_qty = asset_actual_qty.get(row.asset)
+ if row.asset in asset_qty_map.keys():
+ asset_qty_map[row.asset]["sale_qty"] += flt(row.qty)
+ else:
+ asset_qty_map.setdefault(
+ row.asset,
+ {
+ "sale_qty": flt(row.qty),
+ "actual_qty": flt(actual_qty),
+ },
+ )
+
+ return asset_qty_map
+
def process_asset_depreciation(self):
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
self.depreciate_asset_on_sale()
@@ -1726,7 +1818,7 @@ class SalesInvoice(SellingController):
def make_pos_gl_entries(self, gl_entries):
if cint(self.is_pos):
skip_change_gl_entries = not cint(
- frappe.get_single_value("Accounts Settings", "post_change_gl_entries")
+ frappe.get_single_value("POS Settings", "post_change_gl_entries")
)
for payment_mode in self.payments:
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 67dae79d083..13eb0f6ae18 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -1173,7 +1173,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected, res)
def test_pos_with_no_gl_entry_for_change_amount(self):
- frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0)
+ frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 0)
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -1221,7 +1221,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.validate_pos_gl_entry(pos, pos, 60, validate_without_change_gle=True)
- frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
+ frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
diff --git a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json
index 1302fd38b26..cf9a90062b2 100644
--- a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json
+++ b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json
@@ -52,7 +52,6 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Timesheet Detail",
- "no_copy": 1,
"print_hide": 1,
"read_only": 1
},
@@ -117,15 +116,16 @@
],
"istable": 1,
"links": [],
- "modified": "2024-03-27 13:10:36.562795",
+ "modified": "2025-12-23 13:54:17.677187",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Timesheet",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
+ "row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
index 782f5a06cfb..a9773ceae60 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
@@ -415,7 +415,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
"cost_center": "Main - _TC",
"tax_amount": 500,
"description": "Test",
- "add_deduct_tax": "Add",
},
)
pi.save()
@@ -506,7 +505,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
"cost_center": "Main - _TC",
"tax_amount": 200,
"description": "Test Gross Tax",
- "add_deduct_tax": "Add",
},
)
si.save()
@@ -541,10 +539,10 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
"cost_center": "Main - _TC",
"tax_amount": 400,
"description": "Test Gross Tax",
- "add_deduct_tax": "Add",
},
)
si.save()
+ si.reload()
si.submit()
invoices.append(si)
# For amount before threshold (first 8000 + VAT): TCS entry with amount zero
@@ -594,7 +592,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
"cost_center": "Main - _TC",
"tax_amount": 500,
"description": "VAT added to test TDS calculation on gross amount",
- "add_deduct_tax": "Add",
},
)
si.save()
@@ -1024,7 +1021,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
"cost_center": "Main - _TC",
"tax_amount": 1000,
"description": "VAT added to test TDS calculation on gross amount",
- "add_deduct_tax": "Add",
},
)
pi.save()
@@ -1162,7 +1158,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
"cost_center": "Main - _TC",
"tax_amount": 8000,
"description": "Test",
- "add_deduct_tax": "Add",
},
)
diff --git a/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py
index 4abbd2c28e4..6aff1116935 100644
--- a/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py
+++ b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py
@@ -708,6 +708,10 @@ class TaxWithholdingController:
existing_taxes = {row.account_head: row for row in self.doc.taxes if row.is_tax_withholding_account}
precision = self.doc.precision("tax_amount", "taxes")
conversion_rate = self.get_conversion_rate()
+ add_deduct_tax = "Deduct"
+
+ if self.party_type == "Customer":
+ add_deduct_tax = "Add"
for account_head, base_amount in account_amount_map.items():
tax_amount = flt(base_amount / conversion_rate, precision)
@@ -724,6 +728,7 @@ class TaxWithholdingController:
tax_row = self._create_tax_row(account_head, tax_amount)
for_update = False
+ tax_row.add_deduct_tax = add_deduct_tax
# Set item-wise tax breakup for this tax row
self._set_item_wise_tax_for_tds(
tax_row, account_head, category_withholding_map, for_update=for_update
@@ -743,7 +748,6 @@ class TaxWithholdingController:
"account_head": account_head,
"description": account_head,
"cost_center": cost_center,
- "add_deduct_tax": "Deduct",
"tax_amount": tax_amount,
"dont_recompute_tax": 1,
},
@@ -807,12 +811,14 @@ class TaxWithholdingController:
else:
item_tax_amount = 0
+ multiplier = -1 if tax_row.add_deduct_tax == "Deduct" else 1
+
self.doc._item_wise_tax_details.append(
frappe._dict(
item=item,
tax=tax_row,
rate=category.tax_rate,
- amount=item_tax_amount * -1, # Negative because it's a deduction
+ amount=item_tax_amount * multiplier,
taxable_amount=item_base_taxable,
)
)
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index 75e81d3b04c..0b4f59d6aae 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -877,11 +877,15 @@ class ReceivablePayableReport:
else:
entry_date = row.posting_date
+ row.range0 = 0.0
+
self.get_ageing_data(entry_date, row)
- # ageing buckets should not have amounts if due date is not reached
if getdate(entry_date) > getdate(self.age_as_on):
+ row.range0 = row.outstanding
[setattr(row, f"range{i}", 0.0) for i in self.range_numbers]
+ row.total_due = 0
+ return
row.total_due = sum(row[f"range{i}"] for i in self.range_numbers)
@@ -1281,6 +1285,8 @@ class ReceivablePayableReport:
ranges = [*self.ranges, _("Above")]
prev_range_value = 0
+ self.add_column(label=_("<0"), fieldname="range0", fieldtype="Currency")
+ self.ageing_column_labels.append(_("<0"))
for idx, curr_range_value in enumerate(ranges):
label = f"{prev_range_value}-{curr_range_value}"
self.add_column(label=label, fieldname="range" + str(idx + 1))
@@ -1296,7 +1302,9 @@ class ReceivablePayableReport:
for row in self.data:
row = frappe._dict(row)
if not cint(row.bold):
- values = [flt(row.get(f"range{i}", None), precision) for i in self.range_numbers]
+ values = [flt(row.get("range0", 0), precision)] + [
+ flt(row.get(f"range{i}", 0), precision) for i in self.range_numbers
+ ]
rows.append({"values": values})
self.chart = {
diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
index b4280ca3067..c7585d9efd8 100644
--- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
+++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
@@ -123,9 +123,7 @@ def get_report_summary(
return [
{"value": net_income, "label": income_label, "datatype": "Currency", "currency": currency},
- {"type": "separator", "value": "-"},
{"value": net_expense, "label": expense_label, "datatype": "Currency", "currency": currency},
- {"type": "separator", "value": "=", "color": "blue"},
{
"value": net_profit,
"indicator": "Green" if net_profit > 0 else "Red",
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 557eb21441b..7dfe9041506 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -1146,7 +1146,7 @@ def get_company_default(company, fieldname, ignore_validation=False):
if not ignore_validation and not value:
throw(
_("Please set default {0} in Company {1}").format(
- frappe.get_meta("Company").get_label(fieldname), company
+ _(frappe.get_meta("Company").get_label(fieldname)), company
)
)
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index e0b4593416a..f3a310a101d 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -111,7 +111,7 @@ frappe.ui.form.on("Asset", {
frm.add_custom_button(
__("Sell Asset"),
function () {
- frm.trigger("make_sales_invoice");
+ frm.trigger("sell_asset");
},
__("Manage")
);
@@ -231,26 +231,64 @@ frappe.ui.form.on("Asset", {
},
toggle_reference_doc: function (frm) {
- if (frm.doc.purchase_receipt && frm.doc.purchase_invoice && frm.doc.docstatus === 1) {
- frm.set_df_property("purchase_invoice", "read_only", 1);
- frm.set_df_property("purchase_receipt", "read_only", 1);
- } else if (frm.doc.is_existing_asset || frm.doc.is_composite_asset) {
- frm.toggle_reqd("purchase_receipt", 0);
- frm.toggle_reqd("purchase_invoice", 0);
- } else if (frm.doc.purchase_receipt) {
- // if purchase receipt link is set then set PI disabled
- frm.toggle_reqd("purchase_invoice", 0);
- frm.set_df_property("purchase_invoice", "read_only", 1);
- } else if (frm.doc.purchase_invoice) {
- // if purchase invoice link is set then set PR disabled
- frm.toggle_reqd("purchase_receipt", 0);
- frm.set_df_property("purchase_receipt", "read_only", 1);
- } else {
- frm.toggle_reqd("purchase_receipt", 1);
- frm.set_df_property("purchase_receipt", "read_only", 0);
- frm.toggle_reqd("purchase_invoice", 1);
- frm.set_df_property("purchase_invoice", "read_only", 0);
+ const is_submitted = frm.doc.docstatus === 1;
+ const is_special_asset = frm.doc.is_existing_asset || frm.doc.is_composite_asset;
+
+ const clear_field = (field) => {
+ if (frm.doc[field]) {
+ frm.set_value(field, "");
+ }
+ };
+
+ ["purchase_receipt", "purchase_receipt_item", "purchase_invoice", "purchase_invoice_item"].forEach(
+ (field) => {
+ frm.toggle_reqd(field, 0);
+ frm.set_df_property(field, "read_only", 0);
+ }
+ );
+
+ if (is_submitted) {
+ [
+ "purchase_receipt",
+ "purchase_receipt_item",
+ "purchase_invoice",
+ "purchase_invoice_item",
+ ].forEach((field) => {
+ frm.set_df_property(field, "read_only", 1);
+ });
+ return;
}
+
+ if (is_special_asset) {
+ clear_field("purchase_receipt");
+ clear_field("purchase_receipt_item");
+ clear_field("purchase_invoice");
+ clear_field("purchase_invoice_item");
+ return;
+ }
+
+ if (frm.doc.purchase_receipt) {
+ frm.toggle_reqd("purchase_receipt_item", 1);
+
+ ["purchase_invoice", "purchase_invoice_item"].forEach((field) => {
+ clear_field(field);
+ frm.set_df_property(field, "read_only", 1);
+ });
+ return;
+ }
+
+ if (frm.doc.purchase_invoice) {
+ frm.toggle_reqd("purchase_invoice_item", 1);
+
+ ["purchase_receipt", "purchase_receipt_item"].forEach((field) => {
+ clear_field(field);
+ frm.set_df_property(field, "read_only", 1);
+ });
+ return;
+ }
+
+ frm.toggle_reqd("purchase_receipt", 1);
+ frm.toggle_reqd("purchase_invoice", 1);
},
make_journal_entry: function (frm) {
@@ -480,26 +518,9 @@ frappe.ui.form.on("Asset", {
} else {
frm.set_df_property("net_purchase_amount", "read_only", 0);
}
-
frm.trigger("toggle_reference_doc");
},
- make_sales_invoice: function (frm) {
- frappe.call({
- args: {
- asset: frm.doc.name,
- item_code: frm.doc.item_code,
- company: frm.doc.company,
- serial_no: frm.doc.serial_no,
- },
- method: "erpnext.assets.doctype.asset.asset.make_sales_invoice",
- callback: function (r) {
- var doclist = frappe.model.sync(r.message);
- frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
- },
- });
- },
-
create_asset_maintenance: function (frm) {
frappe.call({
args: {
@@ -548,6 +569,69 @@ frappe.ui.form.on("Asset", {
});
},
+ sell_asset: function (frm) {
+ const make_sales_invoice = (sell_qty) => {
+ frappe.call({
+ method: "erpnext.assets.doctype.asset.asset.make_sales_invoice",
+ args: {
+ asset: frm.doc.name,
+ item_code: frm.doc.item_code,
+ company: frm.doc.company,
+ serial_no: frm.doc.serial_no,
+ sell_qty: sell_qty,
+ },
+ callback: function (r) {
+ var doclist = frappe.model.sync(r.message);
+ frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
+ },
+ });
+ };
+
+ let dialog = new frappe.ui.Dialog({
+ title: __("Sell Asset"),
+ fields: [
+ {
+ fieldname: "sell_qty",
+ fieldtype: "Int",
+ label: __("Sell Qty"),
+ reqd: 1,
+ },
+ ],
+ });
+
+ dialog.set_primary_action(__("Sell"), function () {
+ const dialog_data = dialog.get_values();
+ const sell_qty = cint(dialog_data.sell_qty);
+ const asset_qty = cint(frm.doc.asset_quantity);
+
+ if (sell_qty <= 0) {
+ frappe.throw(__("Sell quantity must be greater than zero"));
+ }
+
+ if (sell_qty > asset_qty) {
+ frappe.throw(__("Sell quantity cannot exceed the asset quantity"));
+ }
+
+ if (sell_qty < asset_qty) {
+ frappe.confirm(
+ __(
+ "The sell quantity is less than the total asset quantity. The remaining quantity will be split into a new asset. This action cannot be undone.
Do you want to continue?"
+ ),
+ () => {
+ make_sales_invoice(sell_qty);
+ dialog.hide();
+ }
+ );
+ return;
+ }
+
+ make_sales_invoice(sell_qty);
+ dialog.hide();
+ });
+
+ dialog.show();
+ },
+
split_asset: function (frm) {
const title = __("Split Asset");
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 701be98a0d6..6bf76cdb70f 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -482,6 +482,9 @@ class Asset(AccountsController):
frappe.throw(_("Available-for-use Date should be after purchase date"))
def validate_linked_purchase_documents(self):
+ if self.flags.is_split_asset:
+ return
+
for fieldname, doctype in [
("purchase_receipt", "Purchase Receipt"),
("purchase_invoice", "Purchase Invoice"),
@@ -589,9 +592,7 @@ class Asset(AccountsController):
def set_depreciation_rate(self):
for d in self.get("finance_books"):
self.validate_asset_finance_books(d)
- d.rate_of_depreciation = flt(
- self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation")
- )
+ d.rate_of_depreciation = self.get_depreciation_rate(d, on_validate=True)
def validate_asset_finance_books(self, row):
row.expected_value_after_useful_life = flt(
@@ -981,7 +982,7 @@ class Asset(AccountsController):
if isinstance(args, str):
args = json.loads(args)
- rate_field_precision = frappe.get_precision(args.doctype, "rate_of_depreciation") or 2
+ rate_field_precision = frappe.get_single_value("System Settings", "float_precision") or 2
if args.get("depreciation_method") == "Double Declining Balance":
return self.get_double_declining_balance_rate(args, rate_field_precision)
@@ -1083,7 +1084,7 @@ def get_asset_naming_series():
@frappe.whitelist()
-def make_sales_invoice(asset, item_code, company, serial_no=None, posting_date=None):
+def make_sales_invoice(asset, item_code, company, sell_qty, serial_no=None):
asset_doc = frappe.get_doc("Asset", asset)
si = frappe.new_doc("Sales Invoice")
si.company = company
@@ -1098,7 +1099,7 @@ def make_sales_invoice(asset, item_code, company, serial_no=None, posting_date=N
"income_account": disposal_account,
"serial_no": serial_no,
"cost_center": depreciation_cost_center,
- "qty": 1,
+ "qty": sell_qty,
},
)
@@ -1378,6 +1379,7 @@ def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_a
scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity)
new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset
asset_doc = new_asset if is_new_asset else existing_asset
+ asset_doc.flags.is_split_asset = True
set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset)
log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset)
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index c54c39ab7c8..4a27d15f94b 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -330,7 +330,9 @@ class TestAsset(AssetSetup):
post_depreciation_entries(date=add_months(purchase_date, 2))
- si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
+ si = make_sales_invoice(
+ asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity
+ )
si.customer = "_Test Customer"
si.due_date = date
si.get("items")[0].rate = 25000
@@ -458,7 +460,9 @@ class TestAsset(AssetSetup):
post_depreciation_entries(date="2021-01-01")
- si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
+ si = make_sales_invoice(
+ asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity
+ )
si.customer = "_Test Customer"
si.due_date = nowdate()
si.get("items")[0].rate = 25000
@@ -698,8 +702,142 @@ class TestAsset(AssetSetup):
frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", cwip_acc)
frappe.db.get_value("Company", "_Test Company", "capital_work_in_progress_account", cwip_acc)
+ def test_partial_asset_sale(self):
+ date = nowdate()
+ purchase_date = add_months(get_first_day(date), -2)
+ depreciation_start_date = add_months(get_last_day(date), -2)
+
+ # create an asset
+ asset = create_asset(
+ item_code="Macbook Pro",
+ is_existing_asset=1,
+ calculate_depreciation=1,
+ available_for_use_date=purchase_date,
+ purchase_date=purchase_date,
+ depreciation_start_date=depreciation_start_date,
+ net_purchase_amount=1000000.0,
+ purchase_amount=1000000.0,
+ asset_quantity=10,
+ total_number_of_depreciations=12,
+ frequency_of_depreciation=1,
+ submit=1,
+ )
+ asset_depr_schedule_before_sale = get_asset_depr_schedule_doc(asset.name, "Active")
+ post_depreciation_entries(date)
+ asset.reload()
+
+ # check asset values before sale
+ self.assertEqual(asset.asset_quantity, 10)
+ self.assertEqual(asset.net_purchase_amount, 1000000)
+ self.assertEqual(asset.status, "Partially Depreciated")
+ self.assertEqual(
+ asset_depr_schedule_before_sale.depreciation_schedule[0].get("depreciation_amount"), 83333.33
+ )
+
+ # make a partial sales against the asset
+ si = make_sales_invoice(
+ asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=5
+ )
+ si.customer = "_Test Customer"
+ si.due_date = date
+ si.get("items")[0].rate = 25000
+ si.insert()
+ si.submit()
+
+ asset.reload()
+ asset_depr_schedule_after_sale = get_asset_depr_schedule_doc(asset.name, "Active")
+
+ # check asset values after sales
+ self.assertEqual(asset.asset_quantity, 5)
+ self.assertEqual(asset.net_purchase_amount, 500000)
+ self.assertEqual(asset.status, "Sold")
+ self.assertEqual(
+ asset_depr_schedule_after_sale.depreciation_schedule[0].get("depreciation_amount"), 41666.66
+ )
+
+ def test_asset_splitting_for_non_existing_asset(self):
+ date = nowdate()
+ purchase_date = add_months(get_first_day(date), -2)
+ depreciation_start_date = add_months(get_last_day(date), -2)
+
+ asset_qty = 10
+ asset_rate = 100000.0
+ asset_item = "Macbook Pro"
+ asset_location = "Test Location"
+
+ frappe.db.set_value("Item", asset_item, "is_grouped_asset", 1)
+
+ # Inward asset via Purchase Receipt
+ pr = make_purchase_receipt(
+ item_code="Macbook Pro",
+ posting_date=purchase_date,
+ qty=asset_qty,
+ rate=asset_rate,
+ location=asset_location,
+ supplier="_Test Supplier",
+ )
+
+ asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name")
+ asset_doc = frappe.get_doc("Asset", asset)
+ asset_doc.calculate_depreciation = 1
+ asset_doc.available_for_use_date = purchase_date
+ asset_doc.location = asset_location
+ asset_doc.append(
+ "finance_books",
+ {
+ "expected_value_after_useful_life": 0,
+ "depreciation_method": "Straight Line",
+ "total_number_of_depreciations": 12,
+ "frequency_of_depreciation": 1,
+ "depreciation_start_date": depreciation_start_date,
+ },
+ )
+ asset_doc.submit()
+
+ # check asset values before splitting
+ asset_depr_schedule_before_splitting = get_asset_depr_schedule_doc(asset_doc.name, "Active")
+ self.assertEqual(asset_doc.asset_quantity, 10)
+ self.assertEqual(asset_doc.net_purchase_amount, 1000000)
+ self.assertEqual(
+ asset_depr_schedule_before_splitting.depreciation_schedule[0].get("depreciation_amount"), 83333.33
+ )
+
+ # initate asset split
+ new_asset = split_asset(asset_doc.name, 5)
+ asset_doc.reload()
+ asset_depr_schedule_after_sale = get_asset_depr_schedule_doc(asset_doc.name, "Active")
+ new_asset_depr_schedule = get_asset_depr_schedule_doc(new_asset.name, "Active")
+
+ # check asset values after splitting
+ self.assertEqual(asset_doc.asset_quantity, 5)
+ self.assertEqual(asset_doc.net_purchase_amount, 500000)
+ self.assertEqual(
+ asset_depr_schedule_after_sale.depreciation_schedule[0].get("depreciation_amount"), 41666.66
+ )
+
+ # check new asset values after splitting
+ self.assertEqual(new_asset.asset_quantity, 5)
+ self.assertEqual(new_asset.net_purchase_amount, 500000)
+ self.assertEqual(
+ new_asset_depr_schedule.depreciation_schedule[0].get("depreciation_amount"), 41666.66
+ )
+
+ frappe.db.set_value("Item", asset_item, "is_grouped_asset", 0)
+
class TestDepreciationMethods(AssetSetup):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls._old_float_precision = frappe.db.get_single_value("System Settings", "float_precision")
+ frappe.db.set_single_value("System Settings", "float_precision", 2)
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.set_single_value("System Settings", "float_precision", cls._old_float_precision)
+ super().tearDownClass()
+
def test_schedule_for_straight_line_method(self):
asset = create_asset(
calculate_depreciation=1,
@@ -797,9 +935,9 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(asset.status, "Draft")
expected_schedules = [
- ["2030-12-31", 66667.00, 66667.00],
- ["2031-12-31", 22222.11, 88889.11],
- ["2032-12-31", 1110.89, 90000.0],
+ ["2030-12-31", 66670.0, 66670.0],
+ ["2031-12-31", 22221.11, 88891.11],
+ ["2032-12-31", 1108.89, 90000.0],
]
schedules = [
@@ -825,7 +963,7 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(asset.status, "Draft")
- expected_schedules = [["2031-12-31", 33333.50, 83333.50], ["2032-12-31", 6666.50, 90000.0]]
+ expected_schedules = [["2031-12-31", 33335.0, 83335.0], ["2032-12-31", 6665.0, 90000.0]]
schedules = [
[cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount]
@@ -943,12 +1081,12 @@ class TestDepreciationMethods(AssetSetup):
)
expected_schedules = [
- ["2022-02-28", 337.72, 337.72],
- ["2022-03-31", 675.45, 1013.17],
- ["2022-04-30", 675.45, 1688.62],
- ["2022-05-31", 675.45, 2364.07],
- ["2022-06-30", 675.45, 3039.52],
- ["2022-07-15", 1960.48, 5000.0],
+ ["2022-02-28", 337.71, 337.71],
+ ["2022-03-31", 675.42, 1013.13],
+ ["2022-04-30", 675.42, 1688.55],
+ ["2022-05-31", 675.42, 2363.97],
+ ["2022-06-30", 675.42, 3039.39],
+ ["2022-07-15", 1960.61, 5000.0],
]
schedules = [
diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
index 15ceb51648b..d085a4c6e4b 100644
--- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py
+++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
@@ -51,7 +51,9 @@ class TestAssetRepair(IntegrationTestCase):
submit=1,
)
- si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
+ si = make_sales_invoice(
+ asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity
+ )
si.customer = "_Test Customer"
si.due_date = date
si.get("items")[0].rate = 25000
diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
index 982c6f4fc7d..c033cda05b5 100644
--- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
+++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
@@ -74,7 +74,7 @@ class AssetValueAdjustment(Document):
)
def on_cancel(self):
- frappe.get_doc("Journal Entry", self.journal_entry).cancel()
+ self.cancel_asset_revaluation_entry()
self.update_asset()
add_asset_activity(
self.asset,
@@ -167,6 +167,17 @@ class AssetValueAdjustment(Document):
if dimension.get("mandatory_for_pl"):
debit_entry.update({dimension["fieldname"]: dimension_value})
+ def cancel_asset_revaluation_entry(self):
+ if not self.journal_entry:
+ return
+
+ revaluation_entry = frappe.get_doc("Journal Entry", self.journal_entry)
+ if revaluation_entry.docstatus == 1:
+ # Ignore permissions to match Journal Entry submission behavior
+ revaluation_entry.flags.ignore_permissions = True
+ revaluation_entry.flags.via_asset_value_adjustment = True
+ revaluation_entry.cancel()
+
def update_asset(self):
asset = self.update_asset_value_after_depreciation()
note = self.get_adjustment_note()
diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json
index 125a8b6adb1..a801a2a601d 100644
--- a/erpnext/buying/doctype/supplier/supplier.json
+++ b/erpnext/buying/doctype/supplier/supplier.json
@@ -383,7 +383,7 @@
},
{
"fieldname": "primary_address",
- "fieldtype": "Text",
+ "fieldtype": "Text Editor",
"label": "Primary Address",
"read_only": 1
},
@@ -500,7 +500,7 @@
"link_fieldname": "party"
}
],
- "modified": "2025-06-29 05:30:50.398653",
+ "modified": "2026-01-16 15:56:31.139206",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index f5005fbc12a..543b3726089 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -62,7 +62,7 @@ class Supplier(TransactionBase):
portal_users: DF.Table[PortalUser]
prevent_pos: DF.Check
prevent_rfqs: DF.Check
- primary_address: DF.Text | None
+ primary_address: DF.TextEditor | None
release_date: DF.Date | None
represents_company: DF.Link | None
supplier_details: DF.Text | None
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
index 93f52d6ec42..4beb17d7cf6 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
@@ -16,6 +16,14 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : "";
});
+ this.frm.set_query("warehouse", "items", (doc, cdt, cdn) => {
+ return {
+ filters: {
+ company: doc.company,
+ is_group: 0,
+ },
+ };
+ });
super.setup();
}
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 59810544c0c..73cc888fc4a 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -3748,9 +3748,9 @@ def validate_child_on_delete(row, parent, ordered_item=None):
)
if flt(row.ordered_qty):
frappe.throw(
- _("Row #{0}: Cannot delete item {1} which is assigned to customer's purchase order.").format(
- row.idx, row.item_code
- )
+ _(
+ "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
+ ).format(row.idx, row.item_code)
)
if parent.doctype == "Purchase Order" and flt(row.received_qty):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index fce149e0e84..916d9865662 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -921,6 +921,10 @@ class StockController(AccountsController):
"Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1}
)
+ frappe.db.set_value(
+ "Serial and Batch Entry", {"parent": row.serial_and_batch_bundle}, {"is_cancelled": 1}
+ )
+
if update_values:
row.db_set(update_values)
@@ -929,6 +933,12 @@ class StockController(AccountsController):
"Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, {"is_cancelled": 1}
)
+ frappe.db.set_value(
+ "Serial and Batch Entry",
+ {"parent": row.rejected_serial_and_batch_bundle},
+ {"is_cancelled": 1},
+ )
+
row.db_set("rejected_serial_and_batch_bundle", None)
if row.get("current_serial_and_batch_bundle"):
@@ -2310,6 +2320,7 @@ def make_bundle_for_material_transfer(**kwargs):
row.voucher_no = bundle_doc.voucher_no
row.voucher_detail_no = bundle_doc.voucher_detail_no
row.type_of_transaction = bundle_doc.type_of_transaction
+ row.item_code = bundle_doc.item_code
bundle_doc.set_incoming_rate()
bundle_doc.calculate_qty_and_amount()
diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py
index bd6afdf56a7..b6c21fa0b45 100644
--- a/erpnext/controllers/tests/test_subcontracting_controller.py
+++ b/erpnext/controllers/tests/test_subcontracting_controller.py
@@ -778,9 +778,8 @@ class TestSubcontractingController(IntegrationTestCase):
row.serial_no = "ABC"
break
- bundle.save()
+ self.assertRaises(frappe.ValidationError, bundle.save)
- self.assertRaises(frappe.ValidationError, scr1.save)
bundle.load_from_db()
for row in bundle.entries:
if row.idx == 1:
diff --git a/erpnext/desktop_icon/banking.json b/erpnext/desktop_icon/banking.json
index 71a36f21da4..ad2c366716c 100644
--- a/erpnext/desktop_icon/banking.json
+++ b/erpnext/desktop_icon/banking.json
@@ -9,8 +9,8 @@
"idx": 5,
"label": "Banking",
"link_to": "Banking",
- "link_type": "Workspace",
- "modified": "2026-01-02 13:03:29.270503",
+ "link_type": "Workspace Sidebar",
+ "modified": "2026-01-12 12:29:48.687545",
"modified_by": "Administrator",
"name": "Banking",
"owner": "Administrator",
diff --git a/erpnext/desktop_icon/settings.json b/erpnext/desktop_icon/erpnext_settings.json
similarity index 58%
rename from erpnext/desktop_icon/settings.json
rename to erpnext/desktop_icon/erpnext_settings.json
index 484fd57cd38..247238ee502 100644
--- a/erpnext/desktop_icon/settings.json
+++ b/erpnext/desktop_icon/erpnext_settings.json
@@ -1,19 +1,19 @@
{
"app": "erpnext",
- "creation": "2025-11-17 13:19:04.260916",
+ "creation": "2026-01-09 12:48:25.524807",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "setting",
"icon_type": "Link",
"idx": 10,
- "label": "Settings",
- "link_to": "Settings",
+ "label": "ERPNext Settings",
+ "link_to": "ERPNext Settings",
"link_type": "Workspace Sidebar",
- "logo_url": "/assets/erpnext/desktop_icons/settings.svg",
- "modified": "2026-01-01 20:07:01.330786",
+ "logo_url": "",
+ "modified": "2026-01-09 14:59:56.044037",
"modified_by": "Administrator",
- "name": "Settings",
+ "name": "ERPNext Settings",
"owner": "Administrator",
"parent_icon": "",
"restrict_removal": 0,
diff --git a/erpnext/desktop_icon/opening_&_closing.json b/erpnext/desktop_icon/share_management.json
similarity index 56%
rename from erpnext/desktop_icon/opening_&_closing.json
rename to erpnext/desktop_icon/share_management.json
index 9c1cc0dbeb1..5f251ada959 100644
--- a/erpnext/desktop_icon/opening_&_closing.json
+++ b/erpnext/desktop_icon/share_management.json
@@ -1,18 +1,17 @@
{
"app": "erpnext",
- "creation": "2025-11-12 15:15:15.824801",
+ "creation": "2026-01-12 12:31:53.444807",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
- "icon": "panel-top-open",
"icon_type": "Link",
- "idx": 2,
- "label": "Opening & Closing",
- "link_to": "Opening & Closing",
+ "idx": 8,
+ "label": "Share Management",
+ "link_to": "Share Management",
"link_type": "Workspace Sidebar",
- "modified": "2026-01-01 20:07:01.344481",
+ "modified": "2026-01-12 12:31:53.444807",
"modified_by": "Administrator",
- "name": "Opening & Closing",
+ "name": "Share Management",
"owner": "Administrator",
"parent_icon": "Accounts",
"restrict_removal": 0,
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 9d7b7a5ed70..6a4fcc1d619 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -8,7 +8,7 @@ app_email = "hello@frappe.io"
app_license = "GNU General Public License (v3)"
source_link = "https://github.com/frappe/erpnext"
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
-app_home = "/app/home"
+app_home = "/desk"
add_to_apps_screen = [
{
@@ -20,7 +20,7 @@ add_to_apps_screen = [
}
]
-develop_version = "15.x.x-develop"
+develop_version = "17.x.x-develop"
app_include_js = "erpnext.bundle.js"
app_include_css = "erpnext.bundle.css"
diff --git a/erpnext/locale/id.po b/erpnext/locale/id.po
index 53d7951bdf7..78eb9b9a015 100644
--- a/erpnext/locale/id.po
+++ b/erpnext/locale/id.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2025-12-21 09:37+0000\n"
-"PO-Revision-Date: 2025-12-22 03:08\n"
+"PO-Revision-Date: 2026-01-11 06:34\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Indonesian\n"
"MIME-Version: 1.0\n"
@@ -207,13 +207,13 @@ msgstr "% Progres"
#. 'Subcontracting Inward Order'
#: erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json
msgid "% Raw Material Received"
-msgstr ""
+msgstr "% Bahan Baku yang Diterima"
#. Label of the per_raw_material_returned (Percent) field in DocType
#. 'Subcontracting Inward Order'
#: erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json
msgid "% Raw Material Returned"
-msgstr ""
+msgstr "% Bahan Baku yang Dikembalikan"
#. Label of the per_received (Percent) field in DocType 'Purchase Order'
#. Label of the per_received (Percent) field in DocType 'Material Request'
@@ -242,13 +242,13 @@ msgstr "% Dikembalikan"
#: erpnext/selling/doctype/sales_order/sales_order.json
#, python-format
msgid "% of materials billed against this Sales Order"
-msgstr ""
+msgstr "Persentase bahan yang ditagihkan terhadap Pesanan Penjualan ini"
#. Description of the '% Delivered' (Percent) field in DocType 'Pick List'
#: erpnext/stock/doctype/pick_list/pick_list.json
#, python-format
msgid "% of materials delivered against this Pick List"
-msgstr ""
+msgstr "% Material yang Dikirim pada Pick List ini"
#. Description of the '% Delivered' (Percent) field in DocType 'Sales Order'
#: erpnext/selling/doctype/sales_order/sales_order.json
@@ -2321,7 +2321,7 @@ msgstr ""
#: erpnext/crm/doctype/opportunity/opportunity.json
#: erpnext/crm/doctype/prospect/prospect.json
msgid "Activities"
-msgstr ""
+msgstr "Aktivitas"
#. Name of a DocType
#. Label of a Link in the Projects Workspace
@@ -2421,15 +2421,15 @@ msgstr ""
#. Operation'
#: erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
msgid "Actual End Time"
-msgstr ""
+msgstr "Waktu Akhir Aktual"
#: erpnext/accounts/report/budget_variance_report/budget_variance_report.py:381
msgid "Actual Expense"
-msgstr ""
+msgstr "Beban Aktual"
#: erpnext/accounts/doctype/budget/budget.py:601
msgid "Actual Expenses"
-msgstr ""
+msgstr "Beban Aktual"
#. Label of the actual_operating_cost (Currency) field in DocType 'Work Order'
#. Label of the actual_operating_cost (Currency) field in DocType 'Work Order
@@ -2437,13 +2437,13 @@ msgstr ""
#: erpnext/manufacturing/doctype/work_order/work_order.json
#: erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
msgid "Actual Operating Cost"
-msgstr ""
+msgstr "Biaya Operasional Aktual"
#. Label of the actual_operation_time (Float) field in DocType 'Work Order
#. Operation'
#: erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json
msgid "Actual Operation Time"
-msgstr ""
+msgstr "Waktu Operasi Aktual"
#: erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py:430
msgid "Actual Posting"
@@ -3211,18 +3211,18 @@ msgstr "Jumlah Uang Muka"
#: erpnext/buying/doctype/purchase_order/purchase_order.json
#: erpnext/selling/doctype/sales_order/sales_order.json
msgid "Advance Paid"
-msgstr ""
+msgstr "Uang Muka Dibayar"
#: erpnext/buying/doctype/purchase_order/purchase_order_list.js:75
#: erpnext/selling/doctype/sales_order/sales_order_list.js:122
msgid "Advance Payment"
-msgstr ""
+msgstr "Pembayaran Uang Muka"
#. Option for the 'Reconciliation Takes Effect On' (Select) field in DocType
#. 'Company'
#: erpnext/setup/doctype/company/company.json
msgid "Advance Payment Date"
-msgstr ""
+msgstr "Tanggal Pembayaran Uang Muka"
#. Name of a DocType
#: erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json
@@ -3288,7 +3288,7 @@ msgstr ""
#. Advance'
#: erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json
msgid "Advance amount"
-msgstr ""
+msgstr "Jumlah uang muka"
#: erpnext/controllers/taxes_and_totals.py:942
msgid "Advance amount cannot be greater than {0} {1}"
@@ -3320,15 +3320,15 @@ msgstr ""
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.json
msgid "Advances"
-msgstr ""
+msgstr "Uang Muka"
#: erpnext/setup/setup_wizard/data/marketing_source.txt:3
msgid "Advertisement"
-msgstr ""
+msgstr "Iklan"
#: erpnext/setup/setup_wizard/data/industry_type.txt:2
msgid "Advertising"
-msgstr ""
+msgstr "Periklanan"
#: erpnext/setup/setup_wizard/data/industry_type.txt:3
msgid "Aerospace"
@@ -3338,7 +3338,7 @@ msgstr ""
#. Valuation'
#: erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
msgid "Affected Transactions"
-msgstr ""
+msgstr "Transaksi Terdampak"
#. Label of the against (Text) field in DocType 'GL Entry'
#: erpnext/accounts/doctype/gl_entry/gl_entry.json
@@ -3766,7 +3766,7 @@ msgstr "Semua komunikasi termasuk dan di atas ini akan dipindahkan ke Isu baru"
#: erpnext/manufacturing/doctype/production_plan/production_plan.py:946
msgid "All items are already requested"
-msgstr ""
+msgstr "Semua barang sudah diminta"
#: erpnext/stock/doctype/purchase_receipt/purchase_receipt.py:1369
msgid "All items have already been Invoiced/Returned"
@@ -3774,7 +3774,7 @@ msgstr "Semua item sudah Ditagih/Dikembalikan"
#: erpnext/stock/doctype/delivery_note/delivery_note.py:1208
msgid "All items have already been received"
-msgstr ""
+msgstr "Semua barang sudah diterima"
#: erpnext/stock/doctype/stock_entry/stock_entry.py:2993
msgid "All items have already been transferred for this Work Order."
@@ -3800,7 +3800,7 @@ msgstr ""
#: erpnext/selling/page/point_of_sale/pos_past_order_summary.js:200
msgid "All the items have been already returned."
-msgstr ""
+msgstr "Semua barang sudah dikembalikan."
#: erpnext/manufacturing/doctype/work_order/work_order.js:1171
msgid "All the required items (raw materials) will be fetched from BOM and populated in this table. Here you can also change the Source Warehouse for any item. And during the production, you can track transferred raw materials from this table."
@@ -3915,7 +3915,7 @@ msgstr "Alokasi"
#: erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.json
#: erpnext/public/js/utils/unreconcile.js:104
msgid "Allocations"
-msgstr ""
+msgstr "Alokasi"
#: erpnext/manufacturing/report/production_planning_report/production_planning_report.py:427
msgid "Allotted Qty"
@@ -3946,7 +3946,7 @@ msgstr "Izinkan Pembuatan Akun Terhadap Perusahaan Anak"
#: erpnext/stock/doctype/item/item.json
#: erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
msgid "Allow Alternative Item"
-msgstr ""
+msgstr "Izinkan Barang Alternatif"
#: erpnext/stock/doctype/item_alternative/item_alternative.py:65
msgid "Allow Alternative Item must be checked on Item {}"
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index ce466bc94cd..c82e257dc6c 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -443,7 +443,7 @@ erpnext.patches.v16_0.rename_subcontracted_quantity
erpnext.patches.v16_0.add_new_stock_entry_types
erpnext.patches.v15_0.set_asset_status_if_not_already_set
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
-erpnext.patches.v16_0.update_serial_batch_entries
+erpnext.patches.v16_0.update_serial_batch_entries #11-01-2026 10:00:00
erpnext.patches.v16_0.set_company_wise_warehouses
erpnext.patches.v16_0.set_valuation_method_on_companies
erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table
@@ -456,3 +456,5 @@ erpnext.patches.v16_0.update_tax_withholding_field_in_payment_entry
erpnext.patches.v16_0.migrate_tax_withholding_data
erpnext.patches.v16_0.update_corrected_cancelled_status
erpnext.patches.v16_0.fix_barcode_typo
+erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
+execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
diff --git a/erpnext/patches/v16_0/set_post_change_gl_entries_on_pos_settings.py b/erpnext/patches/v16_0/set_post_change_gl_entries_on_pos_settings.py
new file mode 100644
index 00000000000..9e051859157
--- /dev/null
+++ b/erpnext/patches/v16_0/set_post_change_gl_entries_on_pos_settings.py
@@ -0,0 +1,14 @@
+import frappe
+
+
+def execute():
+ Singles = frappe.qb.DocType("Singles")
+ query = (
+ frappe.qb.from_(Singles)
+ .select("value")
+ .where((Singles.doctype == "Accounts Settings") & (Singles.field == "post_change_gl_entries"))
+ )
+ result = query.run(as_dict=1)
+ if result:
+ post_change_gl_entries = int(result[0].get("value", 1))
+ frappe.db.set_single_value("POS Settings", "post_change_gl_entries", post_change_gl_entries)
diff --git a/erpnext/patches/v16_0/update_serial_batch_entries.py b/erpnext/patches/v16_0/update_serial_batch_entries.py
index 26a817dc7bf..a2391edd57f 100644
--- a/erpnext/patches/v16_0/update_serial_batch_entries.py
+++ b/erpnext/patches/v16_0/update_serial_batch_entries.py
@@ -11,7 +11,9 @@ def execute():
SABE.voucher_type = SABB.voucher_type,
SABE.voucher_no = SABB.voucher_no,
SABE.voucher_detail_no = SABB.voucher_detail_no,
- SABE.type_of_transaction = SABB.type_of_transaction
+ SABE.type_of_transaction = SABB.type_of_transaction,
+ SABE.is_cancelled = SABB.is_cancelled,
+ SABE.item_code = SABB.item_code
WHERE SABE.parent = SABB.name
"""
)
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index ed815c45884..c1f6e8d5dd9 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -7,6 +7,7 @@ import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import add_to_date, now_datetime, nowdate
+from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.projects.doctype.task.test_task import create_task
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
@@ -272,6 +273,60 @@ class TestTimesheet(ERPNextTestSuite):
ts.calculate_percentage_billed()
self.assertEqual(ts.per_billed, 100)
+ def test_partial_billing_and_return(self):
+ """
+ Test Timesheet status transitions during partial billing, full billing,
+ sales return, and return cancellation.
+
+ Scenario:
+ 1. Create a Timesheet with two billable time logs.
+ 2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed.
+ 3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed.
+ 4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed.
+ 5. Cancel the Sales Return → Timesheet returns to Billed status.
+
+ This test ensures Timesheet status is recalculated correctly
+ across billing and return lifecycle events.
+ """
+ emp = make_employee("test_employee_6@salary.com")
+
+ timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
+ timesheet_detail = timesheet.append("time_logs", {})
+ timesheet_detail.is_billable = 1
+ timesheet_detail.activity_type = "_Test Activity Type"
+ timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1)
+ timesheet_detail.hours = 2
+ timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta(
+ hours=timesheet_detail.hours
+ )
+ timesheet.save().submit()
+
+ sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
+ sales_invoice.due_date = nowdate()
+ sales_invoice.timesheets.pop()
+ sales_invoice.submit()
+
+ timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
+ self.assertEqual(timesheet_status, "Partially Billed")
+
+ sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
+ sales_invoice2.due_date = nowdate()
+ sales_invoice2.submit()
+
+ timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
+ self.assertEqual(timesheet_status, "Billed")
+
+ sales_return = make_sales_return(sales_invoice2.name).submit()
+ timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
+ self.assertEqual(timesheet_status, "Partially Billed")
+
+ sales_return.load_from_db()
+ sales_return.cancel()
+
+ timesheet.load_from_db()
+ self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name)
+ self.assertEqual(timesheet.status, "Billed")
+
def make_timesheet(
employee,
@@ -283,6 +338,7 @@ def make_timesheet(
company=None,
currency=None,
exchange_rate=None,
+ do_not_submit=False,
):
update_activity_type(activity_type)
timesheet = frappe.new_doc("Timesheet")
@@ -311,7 +367,8 @@ def make_timesheet(
else:
timesheet.save(ignore_permissions=True)
- timesheet.submit()
+ if not do_not_submit:
+ timesheet.submit()
return timesheet
diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json
index 255f7e8ed97..0022ef9193b 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.json
+++ b/erpnext/projects/doctype/timesheet/timesheet.json
@@ -91,7 +91,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
- "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled",
+ "options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1
},
@@ -310,7 +310,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-03-27 13:10:53.551907",
+ "modified": "2025-12-19 13:48:23.453636",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",
@@ -386,8 +386,9 @@
"write": 1
}
],
+ "row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "title"
-}
\ No newline at end of file
+}
diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py
index 866281bf1ef..7645ee263cc 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.py
+++ b/erpnext/projects/doctype/timesheet/timesheet.py
@@ -51,7 +51,9 @@ class Timesheet(Document):
per_billed: DF.Percent
sales_invoice: DF.Link | None
start_date: DF.Date | None
- status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"]
+ status: DF.Literal[
+ "Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled"
+ ]
time_logs: DF.Table[TimesheetDetail]
title: DF.Data | None
total_billable_amount: DF.Currency
@@ -128,6 +130,9 @@ class Timesheet(Document):
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
self.status = "Billed"
+ if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0:
+ self.status = "Partially Billed"
+
if self.sales_invoice:
self.status = "Completed"
@@ -433,7 +438,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate})
for time_log in timesheet.time_logs:
- if time_log.is_billable:
+ if time_log.is_billable and not time_log.sales_invoice:
target.append(
"timesheets",
{
diff --git a/erpnext/projects/doctype/timesheet/timesheet_list.js b/erpnext/projects/doctype/timesheet/timesheet_list.js
index 0de568ce589..b733cccc787 100644
--- a/erpnext/projects/doctype/timesheet/timesheet_list.js
+++ b/erpnext/projects/doctype/timesheet/timesheet_list.js
@@ -1,6 +1,10 @@
frappe.listview_settings["Timesheet"] = {
add_fields: ["status", "total_hours", "start_date", "end_date"],
get_indicator: function (doc) {
+ if (doc.status == "Partially Billed") {
+ return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"];
+ }
+
if (doc.status == "Billed") {
return [__("Billed"), "green", "status,=," + "Billed"];
}
diff --git a/erpnext/public/desktop_icons/settings.svg b/erpnext/public/desktop_icons/erpnext_settings.svg
similarity index 100%
rename from erpnext/public/desktop_icons/settings.svg
rename to erpnext/public/desktop_icons/erpnext_settings.svg
diff --git a/erpnext/public/icons/desktop_icons/solid/banking.svg b/erpnext/public/icons/desktop_icons/solid/banking.svg
new file mode 100644
index 00000000000..57d7d11a8e1
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/solid/banking.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/solid/budget.svg b/erpnext/public/icons/desktop_icons/solid/budget.svg
new file mode 100644
index 00000000000..ddee38fb472
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/solid/budget.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/solid/settings.svg b/erpnext/public/icons/desktop_icons/solid/erpnext_settings.svg
similarity index 100%
rename from erpnext/public/icons/desktop_icons/solid/settings.svg
rename to erpnext/public/icons/desktop_icons/solid/erpnext_settings.svg
diff --git a/erpnext/public/icons/desktop_icons/solid/share_management.svg b/erpnext/public/icons/desktop_icons/solid/share_management.svg
new file mode 100644
index 00000000000..bea49a5c5f0
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/solid/share_management.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/solid/subscription.svg b/erpnext/public/icons/desktop_icons/solid/subscription.svg
new file mode 100644
index 00000000000..bf371a29eca
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/solid/subscription.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/solid/taxes.svg b/erpnext/public/icons/desktop_icons/solid/taxes.svg
new file mode 100644
index 00000000000..6c9ae689c8b
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/solid/taxes.svg
@@ -0,0 +1,6 @@
+
diff --git a/erpnext/public/icons/desktop_icons/subtle/banking.svg b/erpnext/public/icons/desktop_icons/subtle/banking.svg
new file mode 100644
index 00000000000..d27566101aa
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/subtle/banking.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/subtle/budget.svg b/erpnext/public/icons/desktop_icons/subtle/budget.svg
new file mode 100644
index 00000000000..1a84e5fe7af
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/subtle/budget.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/subtle/settings.svg b/erpnext/public/icons/desktop_icons/subtle/erpnext_settings.svg
similarity index 100%
rename from erpnext/public/icons/desktop_icons/subtle/settings.svg
rename to erpnext/public/icons/desktop_icons/subtle/erpnext_settings.svg
diff --git a/erpnext/public/icons/desktop_icons/subtle/share_management.svg b/erpnext/public/icons/desktop_icons/subtle/share_management.svg
new file mode 100644
index 00000000000..c9bf99f0821
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/subtle/share_management.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/subtle/subscription.svg b/erpnext/public/icons/desktop_icons/subtle/subscription.svg
new file mode 100644
index 00000000000..aa23528ec5e
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/subtle/subscription.svg
@@ -0,0 +1,4 @@
+
diff --git a/erpnext/public/icons/desktop_icons/subtle/taxes.svg b/erpnext/public/icons/desktop_icons/subtle/taxes.svg
new file mode 100644
index 00000000000..b89d6014fa3
--- /dev/null
+++ b/erpnext/public/icons/desktop_icons/subtle/taxes.svg
@@ -0,0 +1,6 @@
+
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index a77bd824f2f..937bb8b8513 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -518,7 +518,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
barcode(doc, cdt, cdn) {
let row = locals[cdt][cdn];
- if (row.barcode) {
+ if (row.barcode && !frappe.flags.trigger_from_barcode_scanner) {
erpnext.stock.utils.set_item_details_using_barcode(this.frm, row, (r) => {
frappe.model.set_value(cdt, cdn, {
item_code: r.message.item_code,
@@ -1530,8 +1530,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} else if (
this.frm.doc.price_list_currency === this.frm.doc.currency &&
this.frm.doc.plc_conversion_rate &&
- cint(this.frm.doc.plc_conversion_rate) != 1 &&
- cint(this.frm.doc.plc_conversion_rate) != cint(this.frm.doc.conversion_rate)
+ flt(this.frm.doc.plc_conversion_rate) != 1 &&
+ flt(this.frm.doc.plc_conversion_rate) != flt(this.frm.doc.conversion_rate)
) {
this.frm.set_value("conversion_rate", this.frm.doc.plc_conversion_rate);
}
diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js
index d86296f1eea..a2e4dbf1da1 100644
--- a/erpnext/public/js/utils/party.js
+++ b/erpnext/public/js/utils/party.js
@@ -157,7 +157,7 @@ erpnext.utils.get_address_display = function (frm, address_field, display_field,
args: { address_dict: frm.doc[address_field] },
callback: function (r) {
if (r.message) {
- frm.set_value(display_field, frappe.utils.html2text(r.message));
+ frm.set_value(display_field, r.message);
}
},
});
diff --git a/erpnext/public/scss/point-of-sale.scss b/erpnext/public/scss/point-of-sale.scss
index d86d2038adf..5ef6e8fc5db 100644
--- a/erpnext/public/scss/point-of-sale.scss
+++ b/erpnext/public/scss/point-of-sale.scss
@@ -117,6 +117,42 @@
overflow-y: scroll;
overflow-x: hidden;
+ &.item-loading {
+ position: relative;
+ pointer-events: none;
+ }
+
+ &.item-loading::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 90deg,
+ #f3f3f3 0px,
+ #f3f3f3 160px,
+ #e9ecef 160px,
+ #e9ecef 320px
+ );
+ animation: skeletonMove 1.1s linear infinite;
+ z-index: 1;
+ }
+
+ @keyframes skeletonMove {
+ from {
+ background-position: 0 0;
+ }
+ to {
+ background-position: 320px 0;
+ }
+ }
+
+ &.items-not-found {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ }
+
&.show-item-image {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--margin-lg);
diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js
index 5a5f5b27331..a2abaa5527d 100644
--- a/erpnext/selling/doctype/customer/customer.js
+++ b/erpnext/selling/doctype/customer/customer.js
@@ -212,9 +212,11 @@ frappe.ui.form.on("Customer", {
frappe.contacts.clear_address_and_contact(frm);
}
- var grid = cur_frm.get_field("sales_team").grid;
- grid.set_column_disp("allocated_amount", false);
- grid.set_column_disp("incentives", false);
+ let grid = frm.get_field("sales_team")?.grid;
+ if (grid) {
+ grid.set_column_disp("allocated_amount", false);
+ grid.set_column_disp("incentives", false);
+ }
frm.set_query("customer_group", () => {
return {
diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json
index 7ddb8fc74cf..72798f32329 100644
--- a/erpnext/selling/doctype/customer/customer.json
+++ b/erpnext/selling/doctype/customer/customer.json
@@ -335,7 +335,7 @@
},
{
"fieldname": "primary_address",
- "fieldtype": "Text",
+ "fieldtype": "Text Editor",
"label": "Primary Address",
"read_only": 1
},
@@ -625,7 +625,7 @@
"link_fieldname": "party"
}
],
- "modified": "2025-11-25 09:35:56.772949",
+ "modified": "2026-01-16 15:56:05.967663",
"modified_by": "Administrator",
"module": "Selling",
"name": "Customer",
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 628173c2c7d..7003d491c32 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -83,7 +83,7 @@ class Customer(TransactionBase):
opportunity_name: DF.Link | None
payment_terms: DF.Link | None
portal_users: DF.Table[PortalUser]
- primary_address: DF.Text | None
+ primary_address: DF.TextEditor | None
prospect_name: DF.Link | None
represents_company: DF.Link | None
sales_team: DF.Table[SalesTeam]
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index 480ca04b6a9..bb82310d9c5 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -36,6 +36,15 @@ frappe.ui.form.on("Quotation", {
};
});
+ frm.set_query("warehouse", "items", (doc, cdt, cdn) => {
+ return {
+ filters: {
+ company: doc.company,
+ is_group: 0,
+ },
+ };
+ });
+
frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
});
diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py
index 50ab2404078..35d22e40fb7 100644
--- a/erpnext/selling/page/point_of_sale/point_of_sale.py
+++ b/erpnext/selling/page/point_of_sale/point_of_sale.py
@@ -122,11 +122,13 @@ def filter_result_items(result, pos_profile):
@frappe.whitelist()
-def get_parent_item_group():
- # Using get_all to ignore user permission
- item_group = frappe.get_all("Item Group", {"lft": 1, "is_group": 1}, pluck="name")
- if item_group:
- return item_group[0]
+def get_parent_item_group(pos_profile):
+ item_groups = get_item_groups(pos_profile)
+
+ if not item_groups:
+ item_groups = frappe.get_all("Item Group", {"lft": 1, "is_group": 1}, pluck="name")
+
+ return item_groups[0] if item_groups else None
@frappe.whitelist()
diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js
index 835fd65f846..69ec1e56934 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_selector.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js
@@ -7,8 +7,10 @@ erpnext.PointOfSale.ItemSelector = class {
this.events = events;
this.pos_profile = pos_profile;
this.hide_images = settings.hide_images;
+ this.item_display_class = this.hide_images ? "hide-item-image" : "show-item-image";
this.auto_add_item = settings.auto_add_item_to_cart;
+ this.item_ready_group = this.get_parent_item_group();
this.inti_component();
}
@@ -35,28 +37,36 @@ erpnext.PointOfSale.ItemSelector = class {
this.$component = this.wrapper.find(".items-selector");
this.$items_container = this.$component.find(".items-container");
- const show_hide_images = this.hide_images ? "hide-item-image" : "show-item-image";
- this.$items_container.addClass(show_hide_images);
+ this.$items_container.addClass(this.item_display_class);
+ }
+
+ async get_parent_item_group() {
+ const r = await frappe.call({
+ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_parent_item_group",
+ args: {
+ pos_profile: this.pos_profile,
+ },
+ });
+ if (r.message) this.item_group = this.parent_item_group = r.message;
}
async load_items_data() {
- if (!this.item_group) {
- frappe.call({
- method: "erpnext.selling.page.point_of_sale.point_of_sale.get_parent_item_group",
- async: false,
- callback: (r) => {
- if (r.message) this.parent_item_group = r.message;
- },
- });
- }
+ await this.item_ready_group;
+
+ this.start_item_loading_animation();
+
if (!this.price_list) {
const res = await frappe.db.get_value("POS Profile", this.pos_profile, "selling_price_list");
this.price_list = res.message.selling_price_list;
}
- this.get_items({}).then(({ message }) => {
- this.render_item_list(message.items);
- });
+ this.get_items({})
+ .then(({ message }) => {
+ this.render_item_list(message.items);
+ })
+ .always(() => {
+ this.stop_item_loading_animation();
+ });
}
get_items({ start = 0, page_length = 40, search_term = "" }) {
@@ -64,8 +74,6 @@ erpnext.PointOfSale.ItemSelector = class {
const price_list = (doc && doc.selling_price_list) || this.price_list;
let { item_group, pos_profile } = this;
- !item_group && (item_group = this.parent_item_group);
-
return frappe.call({
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
freeze: true,
@@ -76,16 +84,32 @@ erpnext.PointOfSale.ItemSelector = class {
render_item_list(items) {
this.$items_container.html("");
+ if (!items?.length) {
+ this.set_items_not_found_banner();
+ return;
+ }
+
+ if (this.$items_container.hasClass("items-not-found")) {
+ this.$items_container.removeClass("items-not-found");
+ this.$items_container.addClass(this.item_display_class);
+ }
+
if (this.hide_images) {
this.$items_container.append(this.render_item_list_column_header());
}
- items.forEach((item) => {
+ items?.forEach((item) => {
const item_html = this.get_item_html(item);
this.$items_container.append(item_html);
});
}
+ set_items_not_found_banner() {
+ this.$items_container.removeClass(this.item_display_class);
+ this.$items_container.addClass("items-not-found");
+ this.$items_container.html(__("Items not found."));
+ }
+
render_item_list_column_header() {
return `