Merge branch 'develop' of https://github.com/frappe/erpnext into dev_fr_chertofaccount_2025

This commit is contained in:
Florian HENRY
2026-01-17 20:57:19 +01:00
124 changed files with 2127 additions and 514 deletions

View File

@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["14", "15"]
version: ["14", "15", "16"]
steps:
- uses: octokit/request-action@v2.x

View File

@@ -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##*/}}"

View File

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

View File

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

View File

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

View File

@@ -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 <a href=\"https://docs.erpnext.com/docs/v13/user/manual/en/accounts/articles/common_party_accounting#:~:text=Common%20Party%20Accounting%20in%20ERPNext,Invoice%20against%20a%20primary%20Supplier.\">Common Party</a>",
@@ -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",

View File

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

View File

@@ -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 <b>{0}</b>. 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"):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. <br><br><b>Do you want to continue?</b>"
),
() => {
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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M26.0489 11.237C26.6795 10.9232 27.4418 10.9763 28.0283 11.3952L41.5283 21.0381C42.2105 21.5254 42.4998 22.3981 42.244 23.1964C41.9878 23.9943 41.2457 24.5355 40.4077 24.5355H38.4791V36.107L41.3718 36.1071C42.4369 36.1071 42.3362 36.9704 42.3362 38.0355C42.3362 39.1006 42.4368 39.9642 41.3718 39.9642L12.4432 39.9641C11.3781 39.9641 11.4792 39.1006 11.4791 38.0355C11.4791 36.9704 11.3781 36.107 12.4432 36.107H15.3363V24.5355H13.4077C12.5697 24.5355 11.8276 23.9943 11.5714 23.1964C11.3156 22.3981 11.6049 21.5254 12.2871 21.0381L25.7871 11.3952L26.0489 11.237ZM19.1934 36.107H24.9791V24.5355H19.1934V36.107ZM28.8363 36.107H34.622V24.5355H28.8363V36.107ZM19.4251 20.6784H34.3903L26.9077 15.3334L19.4251 20.6784Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M36.5357 10.6429C39.1985 10.6429 41.3571 12.8015 41.3571 15.4643V41.5H18.2143V22.2143H10.5V15.4643C10.5 12.8015 12.6586 10.6429 15.3214 10.6429H36.5357ZM21.9754 14.5C22.0386 14.8115 22.0714 15.1341 22.0714 15.4643V37.6429H37.5V14.5C36.5357 14.5 37.0683 14.5 36.5357 14.5H21.9754ZM35.5714 35.7143H29.7857V31.8571H35.5714V35.7143ZM35.5714 29.9286H25.9286V26.0714H35.5714V29.9286ZM15.3214 14.5C14.7889 14.5 14.3571 14.9317 14.3571 15.4643V18.3571H18.2143V15.4643C18.2143 14.9317 17.7826 14.5 17.25 14.5H15.3214Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 834 B

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 0H8C3.58172 0 0 3.58172 0 8V20C0 24.4183 3.58172 28 8 28H20C24.4183 28 28 24.4183 28 20V8C28 3.58172 24.4183 0 20 0Z" fill="#0289F7"/>
<path d="M19.25 15.25H16.5302C15.2052 16.863 12.695 16.8722 11.3622 15.25H8.75V18.75C8.75 18.75 8.94588 18.75 9.1875 18.75H19.25C19.25 18.75 19.25 18.5541 19.25 18.3125V15.25ZM8.75 13.5H12.2979L12.5841 13.9589C13.2216 14.9788 14.7126 14.962 15.327 13.9281L15.5817 13.5H19.25V11.75H8.75V13.5ZM19.25 8.6875C19.25 8.44588 19.25 8.25 19.25 8.25H8.75C8.75 8.25 8.75 8.44588 8.75 8.6875V10H19.25V8.6875ZM21 18.3125C21 19.5206 20.0206 20.5 18.8125 20.5H9.1875C7.97938 20.5 7 19.5206 7 18.3125V8.6875C7 7.47938 7.97938 6.5 9.1875 6.5H18.8125C20.0206 6.5 21 7.47938 21 8.6875V18.3125Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 843 B

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M40.5008 21.2145C41.566 21.2145 42.4294 22.0779 42.4294 23.1431V38.5716C42.4294 39.6368 41.566 40.5002 40.5008 40.5002H13.5008C12.4357 40.5002 11.5723 39.6368 11.5723 38.5716V23.1431C11.5723 22.0779 12.4357 21.2145 13.5008 21.2145H40.5008ZM15.4294 36.6431H38.5723V25.0716H15.4294V36.6431ZM27.0008 27.0002C29.1311 27.0002 30.858 28.7271 30.858 30.8574C30.858 32.9876 29.1311 34.7145 27.0008 34.7145C24.8706 34.7145 23.1437 32.9876 23.1437 30.8574C23.1437 28.7271 24.8706 27.0002 27.0008 27.0002ZM38.5723 19.2859H15.4294V15.4288H38.5723V19.2859ZM34.7151 13.5002H19.2866V9.64307H34.7151V13.5002Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

View File

@@ -0,0 +1,6 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M17 36.2857L26.6429 26.6429L36.2857 17" stroke="white" stroke-width="3.85714"/>
<circle cx="19.8571" cy="19.8571" r="3.85714" stroke="white" stroke-width="3.85714"/>
<circle cx="33.3571" cy="33.3571" r="3.85714" stroke="white" stroke-width="3.85714"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M26.1417 11.7727C26.7723 11.4588 27.5346 11.512 28.1211 11.9309L41.6211 21.5737C42.3033 22.0611 42.5926 22.9337 42.3368 23.7321C42.0806 24.5299 41.3385 25.0712 40.5005 25.0712H38.5719V36.6426L41.4645 36.6427C42.5297 36.6427 42.429 37.506 42.429 38.5712C42.429 39.6362 42.5296 40.4999 41.4645 40.4999L12.536 40.4997C11.4709 40.4997 11.572 39.6362 11.5719 38.5712C11.5719 37.506 11.4709 36.6426 12.536 36.6426H15.429V25.0712H13.5005C12.6625 25.0712 11.9204 24.5299 11.6642 23.7321C11.4083 22.9337 11.6977 22.0611 12.3799 21.5737L25.8799 11.9309L26.1417 11.7727ZM19.2862 36.6426H25.0719V25.0712H19.2862V36.6426ZM28.929 36.6426H34.7148V25.0712H28.929V36.6426ZM19.5178 21.214H34.4831L27.0005 15.869L19.5178 21.214Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M35.6783 11.5713C38.3411 11.5713 40.4997 13.7299 40.4997 16.3927V42.4284H17.3569V23.1427H9.64258V16.3927C9.64258 13.7299 11.8012 11.5713 14.464 11.5713H35.6783ZM21.118 15.4284C21.1812 15.74 21.214 16.0625 21.214 16.3927V38.5713H36.6426V15.4284C35.6783 15.4284 36.2109 15.4284 35.6783 15.4284H21.118ZM34.714 36.6427H28.9283V32.7856H34.714V36.6427ZM34.714 30.857H25.0711V26.9999H34.714V30.857ZM14.464 15.4284C13.9314 15.4284 13.4997 15.8602 13.4997 16.3927V19.2856H17.3569V16.3927C17.3569 15.8602 16.9251 15.4284 16.3926 15.4284H14.464Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 881 B

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 0H8C3.58172 0 0 3.58172 0 8V20C0 24.4183 3.58172 28 8 28H20C24.4183 28 28 24.4183 28 20V8C28 3.58172 24.4183 0 20 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M19.25 15.25H16.5302C15.2052 16.863 12.695 16.8722 11.3622 15.25H8.75V18.75C8.75 18.75 8.94588 18.75 9.1875 18.75H19.25C19.25 18.75 19.25 18.5541 19.25 18.3125V15.25ZM8.75 13.5H12.2979L12.5841 13.9589C13.2216 14.9788 14.7126 14.962 15.327 13.9281L15.5817 13.5H19.25V11.75H8.75V13.5ZM19.25 8.6875C19.25 8.44588 19.25 8.25 19.25 8.25H8.75C8.75 8.25 8.75 8.44588 8.75 8.6875V10H19.25V8.6875ZM21 18.3125C21 19.5206 20.0206 20.5 18.8125 20.5H9.1875C7.97938 20.5 7 19.5206 7 18.3125V8.6875C7 7.47938 7.97938 6.5 9.1875 6.5H18.8125C20.0206 6.5 21 7.47938 21 8.6875V18.3125Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 864 B

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M40.4999 21.2145C41.565 21.2145 42.4284 22.0779 42.4284 23.1431V38.5716C42.4284 39.6368 41.565 40.5002 40.4999 40.5002H13.4999C12.4347 40.5002 11.5713 39.6368 11.5713 38.5716V23.1431C11.5713 22.0779 12.4347 21.2145 13.4999 21.2145H40.4999ZM15.4284 36.6431H38.5713V25.0716H15.4284V36.6431ZM26.9999 27.0002C29.1301 27.0002 30.857 28.7271 30.857 30.8574C30.857 32.9876 29.1301 34.7145 26.9999 34.7145C24.8696 34.7145 23.1427 32.9876 23.1427 30.8574C23.1427 28.7271 24.8696 27.0002 26.9999 27.0002ZM38.5713 19.2859H15.4284V15.4288H38.5713V19.2859ZM34.7141 13.5002H19.2856V9.64307H34.7141V13.5002Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 939 B

View File

@@ -0,0 +1,6 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M17.3574 36.6426L36.6431 17.3569" stroke="#0981E3" stroke-width="3.85714"/>
<circle cx="21.2146" cy="19.2859" r="3.85714" stroke="#0981E3" stroke-width="3.85714"/>
<circle cx="34.7146" cy="32.7859" r="3.85714" stroke="#0981E3" stroke-width="3.85714"/>
</svg>

After

Width:  |  Height:  |  Size: 580 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 `<div class="list-column">
<div class="column-name">Name</div>
@@ -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(
`<span class="link-btn">
@@ -222,12 +260,24 @@ erpnext.PointOfSale.ItemSelector = class {
</span>`
);
this.item_group_field.$wrapper.find(".link-btn").append(
`<a class="btn-clear" tabindex="-1" style="display: inline-block;" title="${__("Clear Link")}">
${frappe.utils.icon("close", "xs", "es-icon")}
</a>`
);
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() {

View File

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

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

@@ -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 <a href='https://docs.erpnext.com/docs/v13/user/manual/en/stock/articles/item-valuation-fifo-and-moving-average' target='_blank'>Item Valuation, FIFO and Moving Average.</a>"
"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 <a href='https://docs.frappe.io/erpnext/user/manual/en/calculation-of-valuation-rate-in-fifo-and-moving-average' target='_blank'>Item Valuation, FIFO and Moving Average.</a>"
),
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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