Merge branch 'develop' of https://github.com/frappe/erpnext into dev_fr_chertofaccount_2025
2
.github/workflows/initiate_release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ["14", "15"]
|
||||
version: ["14", "15", "16"]
|
||||
|
||||
steps:
|
||||
- uses: octokit/request-action@v2.x
|
||||
|
||||
6
.github/workflows/patch.yml
vendored
@@ -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##*/}}"
|
||||
|
||||
1
.github/workflows/server-tests-mariadb.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.css'
|
||||
- '**.svg'
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
- 'crowdin.yml'
|
||||
|
||||
@@ -50,13 +50,13 @@ pull_request_rules:
|
||||
- version-15-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to version-16-beta
|
||||
- name: backport to version-16-hotfix
|
||||
conditions:
|
||||
- label="backport version-16-beta"
|
||||
- label="backport version-16-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- version-16-beta
|
||||
- version-16-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: Automatic merge on CI success and review
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
4
erpnext/public/icons/desktop_icons/solid/banking.svg
Normal 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 |
4
erpnext/public/icons/desktop_icons/solid/budget.svg
Normal 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 |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -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 |
@@ -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 |
6
erpnext/public/icons/desktop_icons/solid/taxes.svg
Normal 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 |
4
erpnext/public/icons/desktop_icons/subtle/banking.svg
Normal 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 |
4
erpnext/public/icons/desktop_icons/subtle/budget.svg
Normal 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 |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -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 |
@@ -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 |
6
erpnext/public/icons/desktop_icons/subtle/taxes.svg
Normal 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 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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" : "";
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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) {};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>"
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||