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 `
Name
@@ -189,17 +213,18 @@ erpnext.PointOfSale.ItemSelector = class { fieldtype: "Link", options: "Item Group", placeholder: __("Select item group"), + only_select: true, onchange: function () { me.item_group = this.value; !me.item_group && (me.item_group = me.parent_item_group); me.filter_items(); + me.set_item_selector_filter_label(this.value); }, get_query: function () { - const doc = me.events.get_frm().doc; return { query: "erpnext.selling.page.point_of_sale.point_of_sale.item_group_query", filters: { - pos_profile: doc ? doc.pos_profile : "", + pos_profile: me.pos_profile, }, }; }, @@ -210,9 +235,22 @@ erpnext.PointOfSale.ItemSelector = class { this.search_field.toggle_label(false); this.item_group_field.toggle_label(false); + $(this.item_group_field.awesomplete.ul).css("min-width", "unset"); + + this.hide_open_link_btn(); this.attach_clear_btn(); } + set_item_selector_filter_label(value) { + const $filter_label = this.$component.find(".label"); + + $filter_label.html(value ? __(value) : __("All Items")); + } + + hide_open_link_btn() { + $(this.item_group_field.$wrapper.find(".btn-open")).css("display", "none"); + } + attach_clear_btn() { this.search_field.$wrapper.find(".control-input").append( ` @@ -222,12 +260,24 @@ erpnext.PointOfSale.ItemSelector = class { ` ); + this.item_group_field.$wrapper.find(".link-btn").append( + ` + ${frappe.utils.icon("close", "xs", "es-icon")} + ` + ); + this.$clear_search_btn = this.search_field.$wrapper.find(".link-btn"); + this.$clear_item_group_btn = this.item_group_field.$wrapper.find(".btn-clear"); this.$clear_search_btn.on("click", "a", () => { this.set_search_value(""); this.search_field.set_focus(); }); + + this.$clear_item_group_btn.on("click", () => { + $(this.item_group_field.$input[0]).val("").trigger("input"); + this.item_group_field.set_focus(); + }); } set_search_value(value) { @@ -359,6 +409,8 @@ erpnext.PointOfSale.ItemSelector = class { } filter_items({ search_term = "" } = {}) { + this.start_item_loading_animation(); + const selling_price_list = this.events.get_frm().doc.selling_price_list; if (search_term) { @@ -379,19 +431,31 @@ erpnext.PointOfSale.ItemSelector = class { } } - this.get_items({ search_term }).then(({ message }) => { - // eslint-disable-next-line no-unused-vars - const { items, serial_no, batch_no, barcode } = message; - if (search_term && !barcode) { - this.search_index[selling_price_list][search_term] = items; - } - this.items = items; - this.render_item_list(items); - this.auto_add_item && - this.search_field.$input[0].value && - this.items.length == 1 && - this.add_filtered_item_to_cart(); - }); + this.get_items({ search_term }) + .then(({ message }) => { + // eslint-disable-next-line no-unused-vars + const { items, serial_no, batch_no, barcode } = message; + if (search_term && !barcode) { + this.search_index[selling_price_list][search_term] = items; + } + this.items = items; + this.render_item_list(items); + this.auto_add_item && + this.search_field.$input[0].value && + this.items.length == 1 && + this.add_filtered_item_to_cart(); + }) + .always(() => { + this.stop_item_loading_animation(); + }); + } + + start_item_loading_animation() { + this.$items_container.addClass("is-loading"); + } + + stop_item_loading_animation() { + this.$items_container.removeClass("is-loading"); } add_filtered_item_to_cart() { diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index def469e4fa3..5a2b6db83f2 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -11,7 +11,16 @@ from frappe.cache_manager import clear_defaults_cache from frappe.contacts.address_and_contact import load_address_and_contact from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.desk.page.setup_wizard.setup_wizard import make_records -from frappe.utils import add_months, cint, formatdate, get_first_day, get_link_to_form, get_timestamp, today +from frappe.utils import ( + add_months, + cint, + formatdate, + get_first_day, + get_last_day, + get_link_to_form, + get_timestamp, + today, +) from frappe.utils.nestedset import NestedSet, rebuild_tree from erpnext.accounts.doctype.account.account import get_account_currency @@ -866,31 +875,41 @@ def install_country_fixtures(company, country): def update_company_current_month_sales(company): - from_date = get_first_day(today()) - to_date = get_first_day(add_months(from_date, 1)) + """Update Company's Total Monthly Sales. - results = frappe.db.sql( - """ - SELECT - SUM(base_grand_total) AS total, - DATE_FORMAT(posting_date, '%%m-%%Y') AS month_year - FROM - `tabSales Invoice` - WHERE - posting_date >= %s - AND posting_date < %s - AND docstatus = 1 - AND company = %s - GROUP BY - month_year - """, - (from_date, to_date, company), - as_dict=True, + Postgres compatibility: + - Avoid MariaDB-only DATE_FORMAT(). + - Use a date range for the current month instead (portable + index-friendly). + """ + + # Local imports so you don't have to touch file-level imports + from frappe.query_builder.functions import Sum + + start_date = get_first_day(today()) + end_date = get_last_day(today()) + + si = frappe.qb.DocType("Sales Invoice") + + total_monthly_sales = ( + frappe.qb.from_(si) + .select(Sum(si.base_grand_total)) + .where( + (si.docstatus == 1) + & (si.company == company) + & (si.posting_date >= start_date) + & (si.posting_date <= end_date) + ) + ).run(pluck=True)[0] or 0 + + # Fieldname in standard ERPNext is `total_monthly_sales` + frappe.db.set_value( + "Company", + company, + "total_monthly_sales", + total_monthly_sales, + update_modified=False, ) - monthly_total = results[0]["total"] if len(results) > 0 else 0 - frappe.db.set_value("Company", company, "total_monthly_sales", monthly_total) - def update_company_monthly_sales(company): """Cache past year monthly sales of every company based on sales invoices""" diff --git a/erpnext/setup/doctype/uom/uom.js b/erpnext/setup/doctype/uom/uom.js index 273e30fd197..eb61f63f034 100644 --- a/erpnext/setup/doctype/uom/uom.js +++ b/erpnext/setup/doctype/uom/uom.js @@ -1,7 +1,2 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt - -//--------- ONLOAD ------------- -cur_frm.cscript.onload = function (doc, cdt, cdn) {}; - -cur_frm.cscript.refresh = function (doc, cdt, cdn) {}; diff --git a/erpnext/setup/workspace/settings/settings.json b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json similarity index 96% rename from erpnext/setup/workspace/settings/settings.json rename to erpnext/setup/workspace/erpnext_settings/erpnext_settings.json index 2ebb66e6518..745d0ad4d24 100644 --- a/erpnext/setup/workspace/settings/settings.json +++ b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json @@ -11,7 +11,7 @@ "icon": "setting", "idx": 0, "is_hidden": 0, - "label": "Settings", + "label": "ERPNext Settings", "links": [ { "dependencies": "", @@ -69,10 +69,10 @@ "type": "Link" } ], - "modified": "2025-11-18 13:20:51.473774", + "modified": "2026-01-09 13:05:08.007297", "modified_by": "Administrator", "module": "Setup", - "name": "Settings", + "name": "ERPNext Settings", "number_cards": [], "owner": "Administrator", "parent_page": "", @@ -128,6 +128,6 @@ "type": "DocType" } ], - "title": "Settings", + "title": "ERPNext Settings", "type": "Workspace" } diff --git a/erpnext/stock/dashboard_chart_source/stock_value_by_item_group/stock_value_by_item_group.py b/erpnext/stock/dashboard_chart_source/stock_value_by_item_group/stock_value_by_item_group.py index 68f64619e7b..e2d0b1e41e8 100644 --- a/erpnext/stock/dashboard_chart_source/stock_value_by_item_group/stock_value_by_item_group.py +++ b/erpnext/stock/dashboard_chart_source/stock_value_by_item_group/stock_value_by_item_group.py @@ -53,12 +53,14 @@ def get_stock_value_by_item_group(company): .inner_join(item_doctype) .on(doctype.item_code == item_doctype.name) .select(item_doctype.item_group, stock_value.as_("stock_value")) - .where(doctype.warehouse.isin(warehouses)) .groupby(item_doctype.item_group) .orderby(stock_value, order=frappe.qb.desc) .limit(10) ) + if warehouses: + query = query.where(doctype.warehouse.isin(warehouses)) + results = query.run(as_dict=True) labels = [] diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index dd7a3830c9b..0fb3f048983 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -3,7 +3,7 @@ import json from collections import defaultdict import frappe -from frappe.query_builder.functions import CombineDatetime, Sum +from frappe.query_builder.functions import Sum from frappe.utils import flt, nowtime from pypika import Order from pypika.functions import Coalesce diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 79e9776b91c..62c4528f432 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -5,7 +5,7 @@ import frappe from frappe.model.document import Document from frappe.query_builder import Case, Order -from frappe.query_builder.functions import Coalesce, CombineDatetime, Sum +from frappe.query_builder.functions import Coalesce, Sum from frappe.utils import flt diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 9b02ebe2f7a..d85a76f9f75 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -2806,6 +2806,64 @@ class TestDeliveryNote(IntegrationTestCase): frappe.db.set_single_value("System Settings", "float_precision", original_flt_precision) + def test_different_rate_for_same_serial_nos(self): + item_code = make_item( + "Test Different Rate Serial No Item", + properties={"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "DRSN-.#####"}, + ).name + + se = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100) + serial_nos = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle) + + dn = create_delivery_note( + item_code=item_code, + qty=1, + rate=300, + use_serial_batch_fields=1, + serial_no="\n".join(serial_nos), + ) + + dn.reload() + + sabb = frappe.get_doc("Serial and Batch Bundle", dn.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 100) + + make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=1, + basic_rate=200, + use_serial_batch_fields=1, + serial_no="\n".join(serial_nos), + ) + dn1 = create_delivery_note( + item_code=item_code, + qty=1, + rate=300, + use_serial_batch_fields=1, + serial_no="\n".join(serial_nos), + ) + + dn1.reload() + + sabb = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 200) + + doc = frappe.new_doc("Repost Item Valuation") + doc.voucher_type = "Stock Entry" + doc.voucher_no = se.name + doc.submit() + + sabb = frappe.get_doc("Serial and Batch Bundle", dn.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 100) + + sabb = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) + for entry in sabb.entries: + self.assertEqual(entry.incoming_rate, 200) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 56cdb427acd..6f13428c22b 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -116,6 +116,11 @@ frappe.ui.form.on("Item", { }, __("View") ); + + frm.toggle_display( + ["opening_stock"], + frappe.model.can_create("Stock Entry") && frappe.model.can_write("Stock Entry") + ); } if (frm.doc.is_fixed_asset) { @@ -239,6 +244,8 @@ frappe.ui.form.on("Item", { }, }; }); + + frm.toggle_display(["standard_rate"], frappe.model.can_create("Item Price")); }, validate: function (frm) { @@ -1063,7 +1070,7 @@ frappe.tour["Item"] = [ fieldname: "valuation_rate", title: "Valuation Rate", description: __( - "There are two options to maintain valuation of stock. FIFO (first in - first out) and Moving Average. To understand this topic in detail please visit Item Valuation, FIFO and Moving Average." + "There are two options to maintain valuation of stock. FIFO (first in - first out) and Moving Average. To understand this topic in detail please visit Item Valuation, FIFO and Moving Average." ), }, { diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 5c92fecbfdf..feedb6a0f8a 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -183,7 +183,23 @@ class Item(Document): self.add_price(default.default_price_list) if self.opening_stock: - self.set_opening_stock() + if self.opening_stock > 10000 and self.has_serial_no: + frappe.enqueue( + self.set_opening_stock, + queue="long", + timeout=600, + job_name=f"set_opening_stock_for_{self.name}", + ) + frappe.msgprint( + _( + "Opening stock creation has been queued and will be created in the background. Please check the stock entry after some time." + ), + indicator="orange", + alert=True, + ) + + else: + self.set_opening_stock() def validate(self): if not self.item_name: @@ -264,7 +280,11 @@ class Item(Document): def set_opening_stock(self): """set opening stock""" - if not self.is_stock_item or self.has_serial_no or self.has_batch_no: + if ( + not self.is_stock_item + or (self.has_serial_no and not self.serial_no_series) + or (self.has_batch_no and (not self.create_new_batch or not self.batch_number_series)) + ): return if not self.valuation_rate and not self.standard_rate and not self.is_customer_provided_item: diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 7814efb1c0b..b259f6592c1 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -957,6 +957,43 @@ class TestItem(IntegrationTestCase): msg="Different Variant UOM should not be allowed when `allow_different_uom` is disabled.", ) + def test_opening_stock_for_serial_batch(self): + items = { + "Test Opening Stock for Serial No": { + "has_serial_no": 1, + "opening_stock": 5, + "serial_no_series": "SN-TOPN-.####", + "valuation_rate": 100, + }, + "Test Opening Stock for Batch No": { + "has_batch_no": 1, + "opening_stock": 5, + "batch_number_series": "BCH-TOPN-.####", + "valuation_rate": 100, + "create_new_batch": 1, + }, + "Test Opening Stock for Serial and Batch No": { + "has_serial_no": 1, + "has_batch_no": 1, + "opening_stock": 5, + "batch_number_series": "SN-BCH-TOPN-.####", + "serial_no_series": "BCH-SN-TOPN-.####", + "valuation_rate": 100, + "create_new_batch": 1, + }, + } + + for item_code, properties in items.items(): + make_item(item_code, properties) + + serial_and_batch_bundle = frappe.db.get_value( + "Stock Entry Detail", {"docstatus": 1, "item_code": item_code}, "serial_and_batch_bundle" + ) + self.assertTrue(serial_and_batch_bundle) + + sabb_qty = frappe.db.get_value("Serial and Batch Bundle", serial_and_batch_bundle, "total_qty") + self.assertEqual(sabb_qty, properties["opening_stock"]) + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index 02183bcfad3..b34bc7ded7d 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -366,16 +366,19 @@ }, { "default": "0", + "depends_on": "auto_created_via_reorder", "fieldname": "auto_created_via_reorder", "fieldtype": "Check", - "label": "Auto Created (Reorder)" + "label": "Auto Created (Reorder)", + "no_copy": 1, + "read_only": 1 } ], "icon": "fa fa-ticket", "idx": 70, "is_submittable": 1, "links": [], - "modified": "2025-12-02 13:56:33.001436", + "modified": "2026-01-10 15:34:59.000603", "modified_by": "Administrator", "module": "Stock", "name": "Material Request", diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 93f27d1c382..24a30581188 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1138,7 +1138,8 @@ def make_material_request(**args): mr = frappe.new_doc("Material Request") mr.material_request_type = args.material_request_type or "Purchase" mr.company = args.company or "_Test Company" - mr.customer = args.customer or "_Test Customer" + if mr.material_request_type == "Customer Provided": + mr.customer = args.customer or "_Test Customer" mr.append( "items", { @@ -1147,6 +1148,7 @@ def make_material_request(**args): "uom": args.uom or "_Test UOM", "conversion_factor": args.conversion_factor or 1, "schedule_date": args.schedule_date or today(), + "from_warehouse": args.from_warehouse, "warehouse": args.warehouse or "_Test Warehouse - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC", }, diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index d1a273f12a8..d9b1d29e5aa 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -400,7 +400,7 @@ class PickList(TransactionBase): picked_items = get_picked_items_qty(packed_items, contains_packed_items=True) self.validate_picked_qty(picked_items) - doc_updates = {} + doc_updates = {item: {"picked_qty": 0} for item in set(packed_items)} for d in picked_items: doc_updates[d.product_bundle_item] = {"picked_qty": flt(d.picked_qty)} @@ -411,7 +411,7 @@ class PickList(TransactionBase): picked_items = get_picked_items_qty(so_items) self.validate_picked_qty(picked_items) - doc_updates = {} + doc_updates = {item: {"picked_qty": 0} for item in set(so_items)} for d in picked_items: doc_updates[d.sales_order_item] = {"picked_qty": flt(d.picked_qty)} diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index b5c1c38729f..81c1b147697 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -266,7 +266,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend ); } cur_frm.add_custom_button( - __("Retention Stock Entry"), + __("Sample Retention Stock Entry"), this.make_retention_stock_entry, __("Create") ); diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 6ee22025565..b26bab3d1c4 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4849,6 +4849,154 @@ class TestPurchaseReceipt(IntegrationTestCase): self.assertEqual(return_entry.items[0].qty, -2) self.assertEqual(return_entry.items[0].rejected_qty, 0) # 3-3=0 + def test_do_not_use_batchwise_valuation_with_fifo(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = make_item( + "Test Item Do Not Use Batchwise Valuation with FIFO", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BN-TESTDNUBVWF-.#####", + "valuation_method": "FIFO", + }, + ).name + + doc = frappe.new_doc("Batch") + doc.update( + { + "batch_id": "BN-TESTDNUBVWF-00001", + "item": item_code, + } + ).insert() + + doc.db_set("use_batchwise_valuation", 0) + doc.reload() + + self.assertTrue(doc.use_batchwise_valuation == 0) + + doc = frappe.new_doc("Batch") + doc.update( + { + "batch_id": "BN-TESTDNUBVWF-00002", + "item": item_code, + } + ).insert() + + self.assertTrue(doc.use_batchwise_valuation == 1) + + warehouse = "_Test Warehouse - _TC" + make_stock_entry( + item_code=item_code, + qty=10, + rate=100, + target=warehouse, + batch_no="BN-TESTDNUBVWF-00001", + use_serial_batch_fields=1, + ) + + se1 = make_stock_entry( + item_code=item_code, + qty=10, + rate=200, + target=warehouse, + batch_no="BN-TESTDNUBVWF-00001", + use_serial_batch_fields=1, + ) + + stock_queue = frappe.db.get_value( + "Stock Ledger Entry", + { + "item_code": item_code, + "warehouse": warehouse, + "is_cancelled": 0, + "voucher_type": "Stock Entry", + "voucher_no": se1.name, + }, + "stock_queue", + ) + + stock_queue = frappe.parse_json(stock_queue) + + self.assertEqual(stock_queue, [[10, 100.0], [10, 200.0]]) + + se2 = make_stock_entry( + item_code=item_code, + qty=10, + rate=2, + target=warehouse, + batch_no="BN-TESTDNUBVWF-00002", + use_serial_batch_fields=1, + ) + + stock_queue = frappe.db.get_value( + "Stock Ledger Entry", + { + "item_code": item_code, + "warehouse": warehouse, + "is_cancelled": 0, + "voucher_type": "Stock Entry", + "voucher_no": se2.name, + }, + "stock_queue", + ) + + stock_queue = frappe.parse_json(stock_queue) + self.assertEqual(stock_queue, [[10, 100.0], [10, 200.0]]) + + se3 = make_stock_entry( + item_code=item_code, + qty=20, + source=warehouse, + batch_no="BN-TESTDNUBVWF-00001", + use_serial_batch_fields=1, + ) + + ste_details = frappe.db.get_value( + "Stock Ledger Entry", + { + "item_code": item_code, + "warehouse": warehouse, + "is_cancelled": 0, + "voucher_type": "Stock Entry", + "voucher_no": se3.name, + }, + ["stock_queue", "stock_value_difference"], + as_dict=1, + ) + + stock_queue = frappe.parse_json(ste_details.stock_queue) + self.assertEqual(stock_queue, []) + self.assertEqual(ste_details.stock_value_difference, 3000 * -1) + + se4 = make_stock_entry( + item_code=item_code, + qty=20, + rate=0, + target=warehouse, + batch_no="BN-TESTDNUBVWF-00001", + use_serial_batch_fields=1, + do_not_submit=1, + ) + + se4.items[0].basic_rate = 0.0 + se4.items[0].allow_zero_valuation_rate = 1 + se4.submit() + + stock_queue = frappe.db.get_value( + "Stock Ledger Entry", + { + "item_code": item_code, + "warehouse": warehouse, + "is_cancelled": 0, + "voucher_type": "Stock Entry", + "voucher_no": se4.name, + }, + "stock_queue", + ) + + self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]]) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 5a749b31f62..458df2fec38 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -12,7 +12,7 @@ import frappe.query_builder.functions from frappe import _, _dict, bold from frappe.model.document import Document from frappe.model.naming import make_autoname -from frappe.query_builder.functions import CombineDatetime, Sum +from frappe.query_builder.functions import Sum from frappe.utils import ( cint, cstr, @@ -118,10 +118,19 @@ class SerialandBatchBundle(Document): return self.allow_existing_serial_nos() - if not self.flags.ignore_validate_serial_batch or frappe.in_test: - self.validate_serial_nos_duplicate() + if self.docstatus == 1: + if not self.flags.ignore_validate_serial_batch or frappe.in_test: + self.validate_serial_nos_duplicate() + + self.check_future_entries_exists() + elif ( + self.has_serial_no + and self.type_of_transaction == "Outward" + and self.voucher_type != "Stock Reconciliation" + and self.voucher_no + ): + self.validate_serial_no_status() - self.check_future_entries_exists() self.set_is_outward() self.calculate_total_qty() self.set_warehouse() @@ -130,6 +139,26 @@ class SerialandBatchBundle(Document): self.set_incoming_rate() self.calculate_qty_and_amount() + self.set_child_details() + + def validate_serial_no_status(self): + serial_nos = [d.serial_no for d in self.entries if d.serial_no] + invalid_serial_nos = frappe.get_all( + "Serial No", + filters={ + "name": ("in", serial_nos), + "warehouse": ("!=", self.warehouse), + }, + pluck="name", + ) + + if invalid_serial_nos: + msg = _( + "You cannot outward following {0} as either they are Delivered, Inactive or located in a different warehouse." + ).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No")) + msg += "
" + msg += ", ".join(sn for sn in invalid_serial_nos) + frappe.throw(msg) def validate_voucher_detail_no(self): if self.type_of_transaction not in ["Inward", "Outward"] or self.voucher_type in [ @@ -683,17 +712,16 @@ class SerialandBatchBundle(Document): is_packed_item = True stock_queue = [] - batches = [] - if prev_sle and prev_sle.stock_queue: - batches = frappe.get_all( - "Batch", - filters={ - "name": ("in", [d.batch_no for d in self.entries if d.batch_no]), - "use_batchwise_valuation": 0, - }, - pluck="name", - ) + batches = frappe.get_all( + "Batch", + filters={ + "name": ("in", [d.batch_no for d in self.entries if d.batch_no]), + "use_batchwise_valuation": 0, + }, + pluck="name", + ) + if prev_sle and prev_sle.stock_queue and parse_json(prev_sle.stock_queue): if batches and valuation_method == "FIFO": stock_queue = parse_json(prev_sle.stock_queue) @@ -701,10 +729,16 @@ class SerialandBatchBundle(Document): "Buying Settings", "set_valuation_rate_for_rejected_materials" ) + precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate") for d in self.entries: if self.is_rejected and not set_valuation_rate_for_rejected_materials: rate = 0.0 - elif (d.incoming_rate == rate) and not stock_queue and d.qty and d.stock_value_difference: + elif ( + (flt(d.incoming_rate, precision) == flt(rate, precision)) + and not stock_queue + and d.qty + and d.stock_value_difference + ): continue if is_packed_item and d.incoming_rate: @@ -714,7 +748,7 @@ class SerialandBatchBundle(Document): if d.qty: d.stock_value_difference = flt(d.qty) * d.incoming_rate - if stock_queue and valuation_method == "FIFO" and d.batch_no in batches: + if valuation_method == "FIFO" and d.batch_no in batches and d.incoming_rate is not None: stock_queue.append([d.qty, d.incoming_rate]) d.stock_queue = json.dumps(stock_queue) @@ -764,7 +798,7 @@ class SerialandBatchBundle(Document): self.calculate_total_qty(save=True) # If user has changed the rate in the child table - if self.docstatus == 0: + if self.docstatus == 0 and self.type_of_transaction == "Inward": self.set_incoming_rate(parent=parent, row=row, save=True) if self.docstatus == 0 and parent.get("is_return") and parent.is_new(): @@ -1308,8 +1342,18 @@ class SerialandBatchBundle(Document): self.set_source_document_no() def on_submit(self): + self.validate_docstatus() self.validate_serial_nos_inventory() + def validate_docstatus(self): + for row in self.entries: + if row.docstatus != 1: + frappe.throw( + _("At Row {0}: In Serial and Batch Bundle {1} must have docstatus as 1 and not 0").format( + bold(row.idx), bold(self.name) + ) + ) + def set_child_details(self): for row in self.entries: for field in [ @@ -1319,6 +1363,7 @@ class SerialandBatchBundle(Document): "voucher_no", "voucher_detail_no", "type_of_transaction", + "item_code", ]: if not row.get(field) or row.get(field) != self.get(field): row.set(field, self.get(field)) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 4d188eb2e93..8cbb59599b7 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -988,6 +988,7 @@ def make_serial_batch_bundle(kwargs): "type_of_transaction": type_of_transaction, "company": kwargs.company or "_Test Company", "do_not_submit": kwargs.do_not_submit, + "ignore_sabb_validation": kwargs.ignore_sabb_validation or False, } ) diff --git a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json index 69aaf261945..b5d0200c1c2 100644 --- a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json +++ b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json @@ -7,6 +7,7 @@ "field_order": [ "serial_no", "batch_no", + "item_code", "column_break_2", "qty", "warehouse", @@ -22,6 +23,7 @@ "reference_for_reservation", "voucher_type", "voucher_no", + "is_cancelled", "column_break_eykr", "posting_datetime", "type_of_transaction", @@ -146,24 +148,28 @@ "fieldname": "posting_datetime", "fieldtype": "Datetime", "label": "Posting Datetime", + "no_copy": 1, "read_only": 1 }, { "fieldname": "voucher_type", "fieldtype": "Data", "label": "Voucher Type", + "no_copy": 1, "read_only": 1 }, { "fieldname": "voucher_no", "fieldtype": "Data", "label": "Voucher No", + "no_copy": 1, "read_only": 1 }, { "fieldname": "voucher_detail_no", "fieldtype": "Data", "label": "Voucher Detail No", + "no_copy": 1, "read_only": 1, "search_index": 1 }, @@ -171,18 +177,35 @@ "fieldname": "type_of_transaction", "fieldtype": "Data", "label": "Type of Transaction", + "no_copy": 1, "read_only": 1, "search_index": 1 }, { "fieldname": "column_break_eykr", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_cancelled", + "fieldtype": "Check", + "label": "Is Cancelled", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "no_copy": 1, + "options": "Item", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-11-09 23:28:35.191959", + "modified": "2026-01-11 11:05:10.789054", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Entry", diff --git a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py index 1f084e60c9c..adeb6a388da 100644 --- a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py +++ b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.py @@ -17,7 +17,9 @@ class SerialandBatchEntry(Document): batch_no: DF.Link | None delivered_qty: DF.Float incoming_rate: DF.Float + is_cancelled: DF.Check is_outward: DF.Check + item_code: DF.Link | None outgoing_rate: DF.Float parent: DF.Data parentfield: DF.Data diff --git a/erpnext/stock/doctype/shipment/shipment.json b/erpnext/stock/doctype/shipment/shipment.json index 9d7882f7094..a5e0a2377cf 100644 --- a/erpnext/stock/doctype/shipment/shipment.json +++ b/erpnext/stock/doctype/shipment/shipment.json @@ -115,7 +115,7 @@ }, { "fieldname": "pickup_address", - "fieldtype": "Small Text", + "fieldtype": "Text Editor", "read_only": 1 }, { @@ -135,7 +135,7 @@ }, { "fieldname": "pickup_contact", - "fieldtype": "Small Text", + "fieldtype": "Text Editor", "read_only": 1 }, { @@ -193,7 +193,7 @@ }, { "fieldname": "delivery_address", - "fieldtype": "Small Text", + "fieldtype": "Text Editor", "read_only": 1 }, { @@ -214,7 +214,7 @@ { "depends_on": "eval:doc.delivery_contact_name", "fieldname": "delivery_contact", - "fieldtype": "Small Text", + "fieldtype": "Text Editor", "read_only": 1 }, { @@ -382,6 +382,7 @@ "print_hide": 1 }, { + "allow_on_submit": 1, "fieldname": "tracking_status", "fieldtype": "Select", "label": "Tracking Status", @@ -440,11 +441,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2025-02-20 16:55:20.076418", + "modified": "2026-01-16 14:59:28.547953", "modified_by": "Administrator", "module": "Stock", "name": "Shipment", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -476,8 +477,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/shipment/shipment.py b/erpnext/stock/doctype/shipment/shipment.py index f638d6125fe..ae5a4214d24 100644 --- a/erpnext/stock/doctype/shipment/shipment.py +++ b/erpnext/stock/doctype/shipment/shipment.py @@ -20,19 +20,17 @@ class Shipment(Document): if TYPE_CHECKING: from frappe.types import DF - from erpnext.stock.doctype.shipment_delivery_note.shipment_delivery_note import ( - ShipmentDeliveryNote, - ) + from erpnext.stock.doctype.shipment_delivery_note.shipment_delivery_note import ShipmentDeliveryNote from erpnext.stock.doctype.shipment_parcel.shipment_parcel import ShipmentParcel amended_from: DF.Link | None awb_number: DF.Data | None carrier: DF.Data | None carrier_service: DF.Data | None - delivery_address: DF.SmallText | None + delivery_address: DF.TextEditor | None delivery_address_name: DF.Link delivery_company: DF.Link | None - delivery_contact: DF.SmallText | None + delivery_contact: DF.TextEditor | None delivery_contact_email: DF.Data | None delivery_contact_name: DF.Link | None delivery_customer: DF.Link | None @@ -44,10 +42,10 @@ class Shipment(Document): pallets: DF.Literal["No", "Yes"] parcel_template: DF.Link | None pickup: DF.Data | None - pickup_address: DF.SmallText | None + pickup_address: DF.TextEditor | None pickup_address_name: DF.Link pickup_company: DF.Link | None - pickup_contact: DF.SmallText | None + pickup_contact: DF.TextEditor | None pickup_contact_email: DF.Data | None pickup_contact_name: DF.Link | None pickup_contact_person: DF.Link | None diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index d106d097256..7d2ae01357a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -440,12 +440,16 @@ frappe.ui.form.on("Stock Entry", { if ( frm.doc.docstatus == 1 && - frm.doc.purpose == "Material Receipt" && + ["Material Receipt", "Manufacture"].includes(frm.doc.purpose) && frm.get_sum("items", "sample_quantity") ) { - frm.add_custom_button(__("Create Sample Retention Stock Entry"), function () { - frm.trigger("make_retention_stock_entry"); - }); + frm.add_custom_button( + __("Sample Retention Stock Entry"), + function () { + frm.trigger("make_retention_stock_entry"); + }, + __("Create") + ); } frm.trigger("setup_quality_inspection"); @@ -568,10 +572,6 @@ frappe.ui.form.on("Stock Entry", { if (r.message) { var doc = frappe.model.sync(r.message)[0]; frappe.set_route("Form", doc.doctype, doc.name); - } else { - frappe.msgprint( - __("Retention Stock Entry already created or Sample Quantity not provided") - ); } }, }); @@ -1054,7 +1054,7 @@ frappe.ui.form.on("Stock Entry Detail", { var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; - if (d.sample_quantity && frm.doc.purpose == "Material Receipt") { + if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") { frappe.call({ method: "erpnext.stock.doctype.stock_entry.stock_entry.validate_sample_quantity", args: { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 215ad6d2ee9..fbcc43231dd 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2570,6 +2570,7 @@ class StockEntry(StockController, SubcontractingInwardController): "expense_account": expense_account, "cost_center": item.get("buying_cost_center"), "is_finished_item": 1, + "sample_quantity": item.get("sample_quantity"), } if ( @@ -3103,6 +3104,7 @@ class StockEntry(StockController, SubcontractingInwardController): se_child.po_detail = item_row.get("po_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail") se_child.scio_detail = item_row.get("scio_detail") + se_child.sample_quantity = item_row.get("sample_quantity", 0) for field in [ self.subcontract_data.rm_detail_field, @@ -3238,7 +3240,7 @@ class StockEntry(StockController, SubcontractingInwardController): stock_entries_child_list.append(d.ste_detail) transferred_qty = frappe.get_all( "Stock Entry Detail", - fields=[{"SUM": "qty", "as": "qty"}], + fields=[{"SUM": "transfer_qty", "as": "qty"}], filters={ "against_stock_entry": d.against_stock_entry, "ste_detail": d.ste_detail, @@ -3408,13 +3410,14 @@ class StockEntry(StockController, SubcontractingInwardController): @frappe.whitelist() def move_sample_to_retention_warehouse(company, items): - from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( - get_batch_from_bundle, + from erpnext.stock.serial_batch_bundle import ( + SerialBatchCreation, + get_batch_nos, ) - from erpnext.stock.serial_batch_bundle import SerialBatchCreation if isinstance(items, str): items = json.loads(items) + retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") stock_entry = frappe.new_doc("Stock Entry") stock_entry.company = company @@ -3422,38 +3425,64 @@ def move_sample_to_retention_warehouse(company, items): stock_entry.set_stock_entry_type() for item in items: if item.get("sample_quantity") and item.get("serial_and_batch_bundle"): - batch_no = get_batch_from_bundle(item.get("serial_and_batch_bundle")) - sample_quantity = validate_sample_quantity( - item.get("item_code"), - item.get("sample_quantity"), - item.get("transfer_qty") or item.get("qty"), - batch_no, + warehouse = item.get("t_warehouse") or item.get("warehouse") + total_qty = 0 + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), + "item_code": item.get("item_code"), + "warehouse": warehouse, + "do_not_save": True, + } ) - - if sample_quantity: - cls_obj = SerialBatchCreation( - { - "type_of_transaction": "Outward", - "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), - "item_code": item.get("item_code"), - "warehouse": item.get("t_warehouse"), - } + sabb = cls_obj.duplicate_package() + batches = get_batch_nos(item.get("serial_and_batch_bundle")) + sabe_list = [] + for batch_no in batches.keys(): + sample_quantity = validate_sample_quantity( + item.get("item_code"), + item.get("sample_quantity"), + item.get("transfer_qty") or item.get("qty"), + batch_no, ) - cls_obj.duplicate_package() + sabe = next(item for item in sabb.entries if item.batch_no == batch_no) + if sample_quantity: + if sabb.has_serial_no: + new_sabe = [ + entry + for entry in sabb.entries + if entry.batch_no == batch_no + and frappe.db.exists( + "Serial No", {"name": entry.serial_no, "warehouse": warehouse} + ) + ][: int(sample_quantity)] + sabe_list.extend(new_sabe) + total_qty += len(new_sabe) + else: + total_qty += sample_quantity + sabe.qty = sample_quantity + else: + sabb.entries.remove(sabe) + + if total_qty: + if sabe_list: + sabb.entries = sabe_list + sabb.save() stock_entry.append( "items", { "item_code": item.get("item_code"), - "s_warehouse": item.get("t_warehouse"), + "s_warehouse": warehouse, "t_warehouse": retention_warehouse, - "qty": item.get("sample_quantity"), + "qty": total_qty, "basic_rate": item.get("valuation_rate"), "uom": item.get("uom"), "stock_uom": item.get("stock_uom"), "conversion_factor": item.get("conversion_factor") or 1.0, - "serial_and_batch_bundle": cls_obj.serial_and_batch_bundle, + "serial_and_batch_bundle": sabb.name, }, ) if stock_entry.get("items"): diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index 576d129ee2d..6e54fd4e3b9 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -190,6 +190,7 @@ def make_stock_entry(**args): "cost_center": args.cost_center, "expense_account": args.expense_account, "use_serial_batch_fields": args.use_serial_batch_fields, + "sample_quantity": frappe.get_value("Item", args.item, "sample_quantity") or 0, }, ) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index a3e0aea07be..a0ac4f180b2 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -14,6 +14,13 @@ from erpnext.stock.doctype.item.test_item import ( make_item_variant, set_item_variant_settings, ) +from erpnext.stock.doctype.material_request.material_request import ( + make_in_transit_stock_entry, +) +from erpnext.stock.doctype.material_request.test_material_request import ( + get_in_transit_warehouse, + make_material_request, +) from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_batch_from_bundle, get_serial_nos_from_bundle, @@ -2190,6 +2197,126 @@ class TestStockEntry(IntegrationTestCase): self.assertEqual(se.purpose, "Repack") self.assertRaises(frappe.ValidationError, se.submit) + def test_transferred_qty_in_material_transfer(self): + item_code = "_Test Item" + source_warehouse = "_Test Warehouse - _TC" + target_warehouse = "_Test Warehouse 1 - _TC" + + if not frappe.db.get_value("UOM Conversion Detail", {"parent": item_code, "uom": "Box"}): + item_doc = frappe.get_doc("Item", item_code) + item_doc.append("uoms", {"uom": "Box", "conversion_factor": 12}) + item_doc.save(ignore_permissions=True) + + make_stock_entry(item_code=item_code, target=source_warehouse, qty=12, rate=100) + + # Create a Material Request for Material Transfer + material_request = make_material_request( + material_request_type="Material Transfer", + qty=1, + item_code=item_code, + uom="Box", + conversion_factor=12, + from_warehouse=source_warehouse, + warehouse=target_warehouse, + ) + in_transit_wh = get_in_transit_warehouse(material_request.company) + + # Create first Stock Entry (Source -> In-Transit) + stock_entry_1 = make_in_transit_stock_entry(material_request.name, in_transit_wh) + stock_entry_1.items[0].update( + { + "qty": 1, + "s_warehouse": source_warehouse, + } + ) + stock_entry_1.save().submit() + + # Validate transfer status after first transfer + material_request.reload() + self.assertEqual(material_request.transfer_status, "In Transit") + + # Create final Stock Entry (In-Transit -> Target) + end_transit_1 = make_stock_in_entry(stock_entry_1.name) + end_transit_1.save().submit() + end_transit_1.reload() + + # Validate quantities + stock_entry_1.reload() + self.assertEqual(stock_entry_1.items[0].qty, 1) + self.assertEqual(stock_entry_1.items[0].transfer_qty, 12) + self.assertEqual(stock_entry_1.items[0].transferred_qty, 12) + + # Validate transfer status after final transfer + material_request.reload() + self.assertEqual(material_request.transfer_status, "Completed") + + def test_manufacture_entry_without_wo(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name + rm_item1 = make_item("_Temper Glass", properties={"is_stock_item": 1}).name + rm_item2 = make_item("_Battery", properties={"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + make_stock_entry(item_code=rm_item1, target=warehouse, qty=5, purpose="Material Receipt") + make_stock_entry(item_code=rm_item2, target=warehouse, qty=5, purpose="Material Receipt") + + bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name + se = make_stock_entry(item_code=fg_item, qty=1, purpose="Manufacture", do_not_save=True) + se.from_bom = 1 + se.use_multi_level_bom = 1 + se.bom_no = bom_no + se.fg_completed_qty = 1 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + + se.get_items() + rm_items = {d.item_code: d.qty for d in se.items if d.item_code != fg_item} + self.assertEqual(rm_items[rm_item1], 1) + self.assertEqual(rm_items[rm_item2], 1) + se.calculate_rate_and_amount() + se.save() + se.submit() + + @IntegrationTestCase.change_settings( + "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} + ) + def test_sample_retention_stock_entry(self): + from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse + + warehouse = "_Test Warehouse - _TC" + retain_sample_item = make_item( + "Retain Sample Item", + properties={ + "is_stock_item": 1, + "retain_sample": 1, + "sample_quantity": 2, + "has_batch_no": 1, + "has_serial_no": 1, + "create_new_batch": 1, + "batch_number_series": "SAMPLE-RET-.#####", + "serial_no_series": "SAMPLE-RET-SN-.#####", + }, + ) + material_receipt = make_stock_entry( + item_code=retain_sample_item.item_code, target=warehouse, qty=10, purpose="Material Receipt" + ) + + source_sabb = frappe.get_doc( + "Serial and Batch Bundle", material_receipt.items[0].serial_and_batch_bundle + ) + batch = source_sabb.entries[0].batch_no + serial_nos = [entry.serial_no for entry in source_sabb.entries] + + sample_entry = frappe.get_doc( + move_sample_to_retention_warehouse(material_receipt.company, material_receipt.items) + ) + sample_entry.submit() + target_sabb = frappe.get_doc("Serial and Batch Bundle", sample_entry.items[0].serial_and_batch_bundle) + + self.assertEqual(sample_entry.items[0].transfer_qty, 2) + self.assertEqual(target_sabb.entries[0].batch_no, batch) + self.assertEqual([entry.serial_no for entry in target_sabb.entries], serial_nos[:2]) + def make_serialized_item(self, **args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 87e070b0b05..4f6919e145b 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -232,7 +232,7 @@ class StockLedgerEntry(Document): ) if item_detail.is_stock_item != 1: - self.throw_error_message("Item {0} must be a stock Item").format(self.item_code) + self.throw_error_message(f"Item {self.item_code} must be a stock Item") if item_detail.has_serial_no or item_detail.has_batch_no: if not self.serial_and_batch_bundle: diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index fa3cada5731..59683e87e3d 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -4,7 +4,7 @@ import frappe from frappe import _, bold, json, msgprint -from frappe.query_builder.functions import CombineDatetime, Sum +from frappe.query_builder.functions import Sum from frappe.utils import add_to_date, cint, cstr, flt, get_datetime, now import erpnext diff --git a/erpnext/stock/report/stock_analytics/stock_analytics.py b/erpnext/stock/report/stock_analytics/stock_analytics.py index 16dea3c2942..36f0f2e5be6 100644 --- a/erpnext/stock/report/stock_analytics/stock_analytics.py +++ b/erpnext/stock/report/stock_analytics/stock_analytics.py @@ -4,7 +4,6 @@ import datetime import frappe from frappe import _, scrub -from frappe.query_builder.functions import CombineDatetime from frappe.utils import get_datetime, get_first_day_of_week, get_quarter_start, getdate from frappe.utils import get_first_day as get_first_day_of_month from frappe.utils.nestedset import get_descendants_of diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index 172e0fa6a41..8afe1d72e27 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -42,9 +42,37 @@ def get_data(report_filters): gl_data = voucher_wise_gl_data.get(key) or {} d.account_value = gl_data.get("account_value", 0) d.difference_value = d.stock_value - d.account_value + d.ledger_type = "Stock Ledger Entry" if abs(d.difference_value) > 0.1: data.append(d) + if key in voucher_wise_gl_data: + del voucher_wise_gl_data[key] + + if voucher_wise_gl_data: + data += get_gl_ledgers_with_no_stock_ledger_entries(voucher_wise_gl_data) + + return data + + +def get_gl_ledgers_with_no_stock_ledger_entries(voucher_wise_gl_data): + data = [] + + for key in voucher_wise_gl_data: + gl_data = voucher_wise_gl_data.get(key) or {} + data.append( + { + "name": gl_data.get("name"), + "ledger_type": "GL Entry", + "voucher_type": gl_data.get("voucher_type"), + "voucher_no": gl_data.get("voucher_no"), + "posting_date": gl_data.get("posting_date"), + "stock_value": 0, + "account_value": gl_data.get("account_value", 0), + "difference_value": gl_data.get("account_value", 0) * -1, + } + ) + return data @@ -88,6 +116,7 @@ def get_gl_data(report_filters, filters): "name", "voucher_type", "voucher_no", + "posting_date", { "SUB": [{"SUM": "debit_in_account_currency"}, {"SUM": "credit_in_account_currency"}], "as": "account_value", @@ -109,10 +138,15 @@ def get_columns(filters): { "label": _("Stock Ledger ID"), "fieldname": "name", - "fieldtype": "Link", - "options": "Stock Ledger Entry", + "fieldtype": "Dynamic Link", + "options": "ledger_type", "width": "80", }, + { + "label": _("Ledger Type"), + "fieldname": "ledger_type", + "fieldtype": "Data", + }, {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date"}, {"label": _("Posting Time"), "fieldname": "posting_time", "fieldtype": "Time"}, {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": "110"}, diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index b55a8e43f67..8e37799e8ce 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -7,7 +7,7 @@ from collections import defaultdict import frappe from frappe import _ -from frappe.query_builder.functions import CombineDatetime, Sum +from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, get_datetime from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 298cec2fa89..50603eb609d 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -4,7 +4,7 @@ import frappe from frappe import _, bold from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series from frappe.query_builder import Case -from frappe.query_builder.functions import CombineDatetime, Sum, Timestamp +from frappe.query_builder.functions import Max, Sum, Timestamp from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today from pypika import Order from pypika.terms import ExistsCriterion @@ -324,6 +324,12 @@ class SerialBatchBundle: {"is_cancelled": 1}, ) + frappe.db.set_value( + "Serial and Batch Entry", + {"voucher_no": self.sle.voucher_no, "voucher_type": self.sle.voucher_type}, + {"is_cancelled": 1}, + ) + if self.sle.serial_and_batch_bundle: frappe.get_cached_doc( "Serial and Batch Bundle", self.sle.serial_and_batch_bundle @@ -629,8 +635,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation): self.old_serial_nos = [] serial_nos = self.get_serial_nos() + result = self.get_serial_no_wise_incoming_rate(serial_nos) for serial_no in serial_nos: - incoming_rate = self.get_incoming_rate_from_bundle(serial_no) + incoming_rate = result.get(serial_no) if incoming_rate is None: self.old_serial_nos.append(serial_no) continue @@ -640,39 +647,88 @@ class SerialNoValuation(DeprecatedSerialNoValuation): self.calculate_stock_value_from_deprecarated_ledgers() - def get_incoming_rate_from_bundle(self, serial_no) -> float: - bundle = frappe.qb.DocType("Serial and Batch Bundle") + def get_serial_no_wise_incoming_rate(self, serial_nos): bundle_child = frappe.qb.DocType("Serial and Batch Entry") - query = ( - frappe.qb.from_(bundle) - .inner_join(bundle_child) - .on(bundle.name == bundle_child.parent) - .select((bundle_child.incoming_rate * bundle_child.qty).as_("incoming_rate")) - .where( - (bundle.is_cancelled == 0) - & (bundle.docstatus == 1) - & (bundle_child.serial_no == serial_no) - & (bundle.type_of_transaction == "Inward") - & (bundle_child.qty > 0) - & (bundle.item_code == self.sle.item_code) - & (bundle_child.warehouse == self.sle.warehouse) + def get_latest_based_on_posting_datetime(): + # Get latest inward record based on posting datetime for each serial no + + latest_posting = ( + frappe.qb.from_(bundle_child) + .select( + bundle_child.serial_no, + Max(bundle_child.posting_datetime).as_("max_posting_dt"), + ) + .where( + (bundle_child.is_cancelled == 0) + & (bundle_child.docstatus == 1) + & (bundle_child.type_of_transaction == "Inward") + & (bundle_child.qty > 0) + & (bundle_child.item_code == self.sle.item_code) + & (bundle_child.warehouse == self.sle.warehouse) + & (bundle_child.serial_no.isin(serial_nos)) + ) + .groupby(bundle_child.serial_no) + ) + + # Important to exclude the current voucher to calculate correct the stock value difference + if self.sle.voucher_no: + latest_posting = latest_posting.where(bundle_child.voucher_no != self.sle.voucher_no) + + if self.sle.posting_datetime: + timestamp_condition = bundle_child.posting_datetime <= self.sle.posting_datetime + + latest_posting = latest_posting.where(timestamp_condition) + + latest_posting = latest_posting.as_("latest_posting") + + return latest_posting + + def get_latest_based_on_creation(latest_posting): + # Get latest inward record based on creation for each serial no + latest_creation = ( + frappe.qb.from_(bundle_child) + .join(latest_posting) + .on( + (latest_posting.serial_no == bundle_child.serial_no) + & (latest_posting.max_posting_dt == bundle_child.posting_datetime) + ) + .select( + bundle_child.serial_no, + Max(bundle_child.creation).as_("max_creation"), + ) + .where( + (bundle_child.is_cancelled == 0) + & (bundle_child.docstatus == 1) + & (bundle_child.type_of_transaction == "Inward") + & (bundle_child.qty > 0) + & (bundle_child.item_code == self.sle.item_code) + & (bundle_child.warehouse == self.sle.warehouse) + ) + .groupby(bundle_child.serial_no) + ).as_("latest_creation") + + return latest_creation + + latest_posting = get_latest_based_on_posting_datetime() + latest_creation = get_latest_based_on_creation(latest_posting) + + query = ( + frappe.qb.from_(bundle_child) + .join(latest_creation) + .on( + (latest_creation.serial_no == bundle_child.serial_no) + & (latest_creation.max_creation == bundle_child.creation) + ) + .select( + bundle_child.serial_no, + bundle_child.incoming_rate, ) - .orderby(bundle.posting_datetime, order=Order.desc) - .limit(1) ) - # Important to exclude the current voucher to calculate correct the stock value difference - if self.sle.voucher_no: - query = query.where(bundle.voucher_no != self.sle.voucher_no) + result = query.run(as_list=1) - if self.sle.posting_datetime: - timestamp_condition = bundle.posting_datetime <= self.sle.posting_datetime - - query = query.where(timestamp_condition) - - incoming_rate = query.run() - return flt(incoming_rate[0][0]) if incoming_rate else None + return frappe._dict(result) if result else frappe._dict({}) def get_serial_nos(self): if self.sle.get("serial_nos"): @@ -784,7 +840,8 @@ class BatchNoValuation(DeprecatedBatchNoValuation): Sum(child.qty).as_("total_qty"), ) .where( - (child.warehouse == self.sle.warehouse) + (child.item_code == self.sle.item_code) + & (child.warehouse == self.sle.warehouse) & (child.batch_no.isin(self.batchwise_valuation_batches)) & (child.docstatus == 1) & (child.type_of_transaction.isin(["Inward", "Outward"])) @@ -830,7 +887,8 @@ class BatchNoValuation(DeprecatedBatchNoValuation): Sum(child.qty).as_("qty"), ) .where( - (child.warehouse == self.sle.warehouse) + (child.item_code == self.sle.item_code) + & (child.warehouse == self.sle.warehouse) & (child.batch_no.isin(self.batchwise_valuation_batches)) & (child.docstatus == 1) & (child.type_of_transaction.isin(["Inward", "Outward"])) @@ -931,9 +989,10 @@ def get_batch_nos(serial_and_batch_bundle): entries = frappe.get_all( "Serial and Batch Entry", - fields=["batch_no", "qty", "name"], + fields=["batch_no", {"SUM": "qty", "as": "qty"}], filters={"parent": serial_and_batch_bundle, "batch_no": ("is", "set")}, order_by="idx", + group_by="batch_no", ) if not entries: @@ -1058,7 +1117,7 @@ class SerialBatchCreation: id = self.serial_and_batch_bundle package = frappe.get_doc("Serial and Batch Bundle", id) - new_package = frappe.copy_doc(package) + new_package = frappe.copy_doc(package, ignore_no_copy=False) if self.get("returned_serial_nos"): self.remove_returned_serial_nos(new_package) @@ -1135,6 +1194,9 @@ class SerialBatchCreation: doc.submit() else: + if self.get("ignore_sabb_validation"): + doc.flags.ignore_validate = True + doc.save() self.validate_qty(doc) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 10b319325ae..7a9a2ad273a 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1903,31 +1903,6 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): ) -def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None): - sle = frappe.qb.DocType("Stock Ledger Entry") - - timestamp_condition = sle.posting_datetime < get_combine_datetime(posting_date, posting_time) - if creation: - timestamp_condition |= (sle.posting_datetime == get_combine_datetime(posting_date, posting_time)) & ( - sle.creation < creation - ) - - batch_details = ( - frappe.qb.from_(sle) - .select(Sum(sle.stock_value_difference).as_("batch_value"), Sum(sle.actual_qty).as_("batch_qty")) - .where( - (sle.item_code == item_code) - & (sle.warehouse == warehouse) - & (sle.batch_no == batch_no) - & (sle.is_cancelled == 0) - ) - .where(timestamp_condition) - ).run(as_dict=True) - - if batch_details and batch_details[0].batch_qty: - return batch_details[0].batch_value / batch_details[0].batch_qty - - def get_valuation_rate( item_code, warehouse, diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 5367d67004c..7a60dcb64fc 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -6,7 +6,7 @@ import json import frappe from frappe import _ -from frappe.query_builder.functions import CombineDatetime, IfNull, Sum +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime import erpnext @@ -331,34 +331,6 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): return flt(in_rate) -def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None): - sle = frappe.qb.DocType("Stock Ledger Entry") - - timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime( - posting_date, posting_time - ) - if creation: - timestamp_condition |= ( - CombineDatetime(sle.posting_date, sle.posting_time) == CombineDatetime(posting_date, posting_time) - ) & (sle.creation < creation) - - batch_details = ( - frappe.qb.from_(sle) - .select(Sum(sle.stock_value_difference).as_("batch_value"), Sum(sle.actual_qty).as_("batch_qty")) - .where( - (sle.item_code == item_code) - & (sle.warehouse == warehouse) - & (sle.batch_no == batch_no) - & (sle.serial_and_batch_bundle.isnull()) - & (sle.is_cancelled == 0) - ) - .where(timestamp_condition) - ).run(as_dict=True) - - if batch_details and batch_details[0].batch_qty: - return batch_details[0].batch_value / batch_details[0].batch_qty - - def get_avg_purchase_rate(serial_nos): """get average value of serial numbers""" diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 0f29de81ebd..375c2a4409a 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -295,6 +295,9 @@ class TransactionBase(StatusUpdater): # Server side 'item' doc. Update this to reflect in UI item_obj = self.get("items", {"idx": item_idx})[0] + if not item_obj.item_code: + return + # 'item_details' has latest item related values item_details = self.fetch_item_details(item_obj) diff --git a/erpnext/workspace_sidebar/accounting.json b/erpnext/workspace_sidebar/accounting.json index e40f17b546f..0246a1f31b2 100644 --- a/erpnext/workspace_sidebar/accounting.json +++ b/erpnext/workspace_sidebar/accounting.json @@ -67,7 +67,7 @@ { "child": 1, "collapsible": 1, - "filters": "", + "filters": "[[\"Sales Invoice\",\"is_return\",\"=\",1]]", "icon": "", "indent": 0, "keep_closed": 0, @@ -126,6 +126,7 @@ { "child": 1, "collapsible": 1, + "filters": "[[\"Purchase Invoice\",\"is_return\",\"=\",1]]", "indent": 0, "keep_closed": 0, "label": "Debit Note", @@ -570,10 +571,11 @@ "type": "Link" } ], - "modified": "2026-01-02 18:07:04.450536", + "modified": "2026-01-10 00:06:13.234927", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting", "owner": "Administrator", + "standard": 1, "title": "Accounting" } diff --git a/erpnext/workspace_sidebar/assets.json b/erpnext/workspace_sidebar/assets.json index 6f6e709c8f6..5f54ce2bd35 100644 --- a/erpnext/workspace_sidebar/assets.json +++ b/erpnext/workspace_sidebar/assets.json @@ -258,10 +258,11 @@ "url": "" } ], - "modified": "2026-01-02 14:54:20.640887", + "modified": "2026-01-10 00:06:13.218453", "modified_by": "Administrator", "module": "Assets", "name": "Assets", "owner": "Administrator", + "standard": 1, "title": "Assets" } diff --git a/erpnext/workspace_sidebar/banking.json b/erpnext/workspace_sidebar/banking.json index 95834113772..b73120cc4a7 100644 --- a/erpnext/workspace_sidebar/banking.json +++ b/erpnext/workspace_sidebar/banking.json @@ -6,18 +6,6 @@ "header_icon": "circle-dollar-sign", "idx": 0, "items": [ - { - "child": 0, - "collapsible": 1, - "icon": "home", - "indent": 0, - "keep_closed": 0, - "label": "Home", - "link_to": "Banking", - "link_type": "Workspace", - "show_arrow": 0, - "type": "Link" - }, { "child": 0, "collapsible": 1, @@ -192,10 +180,11 @@ "type": "Link" } ], - "modified": "2026-01-02 13:53:50.930215", + "modified": "2026-01-10 00:06:13.017457", "modified_by": "Administrator", "module": "Accounts", "name": "Banking", "owner": "Administrator", + "standard": 1, "title": "Banking" } diff --git a/erpnext/workspace_sidebar/budget.json b/erpnext/workspace_sidebar/budget.json index 94ebfc9c994..dd9b6f87311 100644 --- a/erpnext/workspace_sidebar/budget.json +++ b/erpnext/workspace_sidebar/budget.json @@ -67,10 +67,11 @@ "type": "Link" } ], - "modified": "2026-01-02 11:46:10.598472", + "modified": "2026-01-10 00:06:13.032297", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", "owner": "Administrator", + "standard": 1, "title": "Budget" } diff --git a/erpnext/workspace_sidebar/buying.json b/erpnext/workspace_sidebar/buying.json index ecf87ecc68f..ec7f6a3ccd4 100644 --- a/erpnext/workspace_sidebar/buying.json +++ b/erpnext/workspace_sidebar/buying.json @@ -369,10 +369,11 @@ "type": "Link" } ], - "modified": "2026-01-02 12:10:07.304778", + "modified": "2026-01-10 00:06:12.979668", "modified_by": "Administrator", "module": "Buying", "name": "Buying", "owner": "Administrator", + "standard": 1, "title": "Buying" } diff --git a/erpnext/workspace_sidebar/crm.json b/erpnext/workspace_sidebar/crm.json index 264318c1c8d..6e8bde422f2 100644 --- a/erpnext/workspace_sidebar/crm.json +++ b/erpnext/workspace_sidebar/crm.json @@ -485,10 +485,11 @@ "type": "Link" } ], - "modified": "2026-01-02 15:06:29.836236", + "modified": "2026-01-10 00:06:13.192366", "modified_by": "Administrator", "module": "CRM", "name": "CRM", "owner": "Administrator", + "standard": 1, "title": "CRM" } diff --git a/erpnext/workspace_sidebar/settings.json b/erpnext/workspace_sidebar/erpnext_settings.json similarity index 97% rename from erpnext/workspace_sidebar/settings.json rename to erpnext/workspace_sidebar/erpnext_settings.json index 6414b0f21f8..117a36b666e 100644 --- a/erpnext/workspace_sidebar/settings.json +++ b/erpnext/workspace_sidebar/erpnext_settings.json @@ -227,10 +227,11 @@ "type": "Link" } ], - "modified": "2026-01-02 18:04:13.860117", + "modified": "2026-01-10 00:06:12.956275", "modified_by": "Administrator", "module": "Setup", - "name": "Settings", + "name": "ERPNext Settings", "owner": "Administrator", - "title": "Settings" + "standard": 1, + "title": "ERPNext Settings" } diff --git a/erpnext/workspace_sidebar/financial_reports.json b/erpnext/workspace_sidebar/financial_reports.json index 85363d9e6d3..52cfb4fd9ec 100644 --- a/erpnext/workspace_sidebar/financial_reports.json +++ b/erpnext/workspace_sidebar/financial_reports.json @@ -72,6 +72,17 @@ "show_arrow": 0, "type": "Link" }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Custom Financial Statement", + "link_to": "Custom Financial Statement", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, { "child": 1, "collapsible": 1, @@ -128,6 +139,104 @@ "show_arrow": 0, "type": "Link" }, + { + "child": 0, + "collapsible": 1, + "indent": 1, + "keep_closed": 1, + "label": "Registers", + "link_type": "DocType", + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Accounts Receivable", + "link_to": "Accounts Receivable", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Accounts Payable", + "link_to": "Accounts Payable", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "AR Summary", + "link_to": "Accounts Receivable Summary", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "AP Summary", + "link_to": "Accounts Payable Summary", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Register", + "link_to": "Sales Register", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Purchase Register", + "link_to": "Purchase Register", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item-wise sales Register", + "link_to": "Item-wise Sales Register", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item-wise Purchase Register", + "link_to": "Item-wise Purchase Register", + "link_type": "Report", + "show_arrow": 0, + "type": "Link" + }, { "child": 0, "collapsible": 1, @@ -272,10 +381,11 @@ "type": "Link" } ], - "modified": "2026-01-02 14:44:46.627363", + "modified": "2026-01-10 00:06:13.168391", "modified_by": "Administrator", "module": "Accounts", "name": "Financial Reports", "owner": "Administrator", + "standard": 1, "title": "Financial Reports" } diff --git a/erpnext/workspace_sidebar/home.json b/erpnext/workspace_sidebar/home.json index 61b3e600421..82e1b8d7605 100644 --- a/erpnext/workspace_sidebar/home.json +++ b/erpnext/workspace_sidebar/home.json @@ -62,10 +62,11 @@ "type": "Link" } ], - "modified": "2025-11-25 10:46:09.198568", + "modified": "2026-01-10 00:06:12.971358", "modified_by": "Administrator", "module": "Setup", "name": "Home", "owner": "Administrator", + "standard": 1, "title": "Home" } diff --git a/erpnext/workspace_sidebar/manufacturing.json b/erpnext/workspace_sidebar/manufacturing.json index 08b6a054660..3c2a4a3dd9d 100644 --- a/erpnext/workspace_sidebar/manufacturing.json +++ b/erpnext/workspace_sidebar/manufacturing.json @@ -425,10 +425,11 @@ "type": "Link" } ], - "modified": "2026-01-02 15:08:30.661411", + "modified": "2026-01-10 00:06:13.058137", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing", "owner": "Administrator", + "standard": 1, "title": "Manufacturing" } diff --git a/erpnext/workspace_sidebar/projects.json b/erpnext/workspace_sidebar/projects.json index 58fec69ff3d..ec0960aa32a 100644 --- a/erpnext/workspace_sidebar/projects.json +++ b/erpnext/workspace_sidebar/projects.json @@ -223,10 +223,11 @@ "type": "Link" } ], - "modified": "2026-01-02 11:57:04.237376", + "modified": "2026-01-10 00:06:13.151947", "modified_by": "Administrator", "module": "Projects", "name": "Projects", "owner": "Administrator", + "standard": 1, "title": "Projects" } diff --git a/erpnext/workspace_sidebar/quality.json b/erpnext/workspace_sidebar/quality.json index 5dc41335404..0e9d20d2a91 100644 --- a/erpnext/workspace_sidebar/quality.json +++ b/erpnext/workspace_sidebar/quality.json @@ -124,17 +124,6 @@ "show_arrow": 0, "type": "Link" }, - { - "child": 1, - "collapsible": 1, - "indent": 0, - "keep_closed": 0, - "label": "Tree of Procedures", - "link_to": "Quality Procedure", - "link_type": "DocType", - "show_arrow": 0, - "type": "Link" - }, { "child": 1, "collapsible": 1, @@ -158,10 +147,11 @@ "type": "Link" } ], - "modified": "2026-01-02 17:39:50.641254", + "modified": "2026-01-12 12:59:09.607272", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality", "owner": "Administrator", + "standard": 1, "title": "Quality" } diff --git a/erpnext/workspace_sidebar/selling.json b/erpnext/workspace_sidebar/selling.json index 766b8f87fa9..3ac2dc8665c 100644 --- a/erpnext/workspace_sidebar/selling.json +++ b/erpnext/workspace_sidebar/selling.json @@ -687,10 +687,11 @@ "type": "Link" } ], - "modified": "2026-01-02 17:44:08.721891", + "modified": "2026-01-10 00:06:13.103140", "modified_by": "Administrator", "module": "Selling", "name": "Selling", "owner": "Administrator", + "standard": 1, "title": "Selling" } diff --git a/erpnext/workspace_sidebar/share_management.json b/erpnext/workspace_sidebar/share_management.json index 1b49348e477..20eb476bfb7 100644 --- a/erpnext/workspace_sidebar/share_management.json +++ b/erpnext/workspace_sidebar/share_management.json @@ -55,10 +55,11 @@ "type": "Link" } ], - "modified": "2026-01-02 14:53:29.842384", + "modified": "2026-01-10 00:06:13.040767", "modified_by": "Administrator", "module": "Accounts", "name": "Share Management", "owner": "Administrator", + "standard": 1, "title": "Share Management" } diff --git a/erpnext/workspace_sidebar/stock.json b/erpnext/workspace_sidebar/stock.json index 87c53dad060..14c5faa24e8 100644 --- a/erpnext/workspace_sidebar/stock.json +++ b/erpnext/workspace_sidebar/stock.json @@ -625,10 +625,11 @@ "type": "Link" } ], - "modified": "2026-01-02 18:10:31.153427", + "modified": "2026-01-10 00:06:12.912403", "modified_by": "Administrator", "module": "Stock", "name": "Stock", "owner": "Administrator", + "standard": 1, "title": "Stock" } diff --git a/erpnext/workspace_sidebar/subcontracting.json b/erpnext/workspace_sidebar/subcontracting.json index f555875c292..31618a4a860 100644 --- a/erpnext/workspace_sidebar/subcontracting.json +++ b/erpnext/workspace_sidebar/subcontracting.json @@ -192,14 +192,16 @@ "label": "Settings", "link_to": "Buying Settings", "link_type": "DocType", + "navigate_to_tab": "subcontract", "show_arrow": 0, "type": "Link" } ], - "modified": "2026-01-02 17:51:15.843931", + "modified": "2026-01-12 13:12:47.927785", "modified_by": "Administrator", "module": "Buying", "name": "Subcontracting", "owner": "Administrator", + "standard": 1, "title": "Subcontracting" } diff --git a/erpnext/workspace_sidebar/subscription.json b/erpnext/workspace_sidebar/subscription.json index ed91cc0c04c..ca42736b27c 100644 --- a/erpnext/workspace_sidebar/subscription.json +++ b/erpnext/workspace_sidebar/subscription.json @@ -87,10 +87,11 @@ "type": "Link" } ], - "modified": "2025-12-22 17:06:54.262451", + "modified": "2026-01-10 00:06:13.048591", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription", "owner": "Administrator", + "standard": 1, "title": "Subscription" } diff --git a/erpnext/workspace_sidebar/support.json b/erpnext/workspace_sidebar/support.json index b62a52554f3..364c0071ebd 100644 --- a/erpnext/workspace_sidebar/support.json +++ b/erpnext/workspace_sidebar/support.json @@ -145,10 +145,11 @@ "type": "Link" } ], - "modified": "2026-01-02 17:47:19.864824", + "modified": "2026-01-10 00:06:13.089991", "modified_by": "Administrator", "module": "Support", "name": "Support", "owner": "Administrator", + "standard": 1, "title": "Support" } diff --git a/erpnext/workspace_sidebar/taxes.json b/erpnext/workspace_sidebar/taxes.json index 9d9033f29ac..5cf65ff3c67 100644 --- a/erpnext/workspace_sidebar/taxes.json +++ b/erpnext/workspace_sidebar/taxes.json @@ -148,10 +148,11 @@ "type": "Link" } ], - "modified": "2026-01-02 14:40:04.075046", + "modified": "2026-01-10 00:06:13.005238", "modified_by": "Administrator", "module": "Accounts", "name": "Taxes", "owner": "Administrator", + "standard": 1, "title": "Taxes" } diff --git a/pyproject.toml b/pyproject.toml index e450d675f02..c4029137907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ skip_namespaces = [ ] [tool.bench.frappe-dependencies] -frappe = ">=16.0.0-dev,<17.0.0" +frappe = ">=17.0.0-dev,<18.0.0" [tool.ruff] line-length = 110