mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-28 06:58:17 +00:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e52738150 | ||
|
|
cbd0a76645 | ||
|
|
552c5b5911 | ||
|
|
66b2b89bcd | ||
|
|
d804d43ed0 | ||
|
|
53bb2cf7c0 | ||
|
|
2aff16928c | ||
|
|
630dcf072f | ||
|
|
b73eb47a43 | ||
|
|
981c9c76c1 | ||
|
|
6a6398a392 | ||
|
|
325fc619dc | ||
|
|
2c9c6c3798 | ||
|
|
3a9888aad9 | ||
|
|
695ca39d84 | ||
|
|
9032d4c3d6 | ||
|
|
c01e40da3c | ||
|
|
552cb5c528 | ||
|
|
e6ccb00d4c | ||
|
|
5b481d9235 | ||
|
|
6b27b659e3 | ||
|
|
16c8b74d52 | ||
|
|
8dcb2f39c2 | ||
|
|
5d5dff9103 | ||
|
|
b529a6d00c | ||
|
|
41659a875b | ||
|
|
a61890ec2b | ||
|
|
fa04e368d3 | ||
|
|
98eeff8775 | ||
|
|
64acf179db | ||
|
|
0d5e45bb7c | ||
|
|
fc86784eb1 | ||
|
|
e1dc80b6d8 | ||
|
|
9e8bb9b235 | ||
|
|
ae90ee3f17 | ||
|
|
dab8ac7b1d | ||
|
|
ce769d3a2f | ||
|
|
d09857294c | ||
|
|
3a74968ced | ||
|
|
26725f4e53 | ||
|
|
030ce6d6a0 | ||
|
|
b46d93c709 | ||
|
|
5d01cad1d5 | ||
|
|
97b253740b | ||
|
|
94c3d66f2f | ||
|
|
a2b6e4a1c5 | ||
|
|
ebbecdba23 | ||
|
|
2263f9a477 | ||
|
|
b340d7d4f4 | ||
|
|
99b69c121e | ||
|
|
860486bb34 | ||
|
|
1693e3ef3f | ||
|
|
279cf6fe00 | ||
|
|
cdc04292f2 | ||
|
|
6788b58d1c | ||
|
|
5bfdc010f3 | ||
|
|
0e7efd75cd | ||
|
|
2d198e698a | ||
|
|
857ab70f4e | ||
|
|
ca21f16db2 | ||
|
|
5324000e2e | ||
|
|
9d2055c620 | ||
|
|
113da4f512 | ||
|
|
8d32ba9a2e | ||
|
|
23c82d410b | ||
|
|
580e825ec2 | ||
|
|
1e0532f387 | ||
|
|
8f569d9711 | ||
|
|
df820aece6 | ||
|
|
6530cfe84b | ||
|
|
c8565c47a2 | ||
|
|
f3c70a66b5 | ||
|
|
115fd48bbf | ||
|
|
918f8ca79b | ||
|
|
46ca347578 | ||
|
|
8226502956 | ||
|
|
71e537b030 | ||
|
|
8fd3e8e22e | ||
|
|
4ee4a57f72 | ||
|
|
a8ed2815a4 | ||
|
|
a2b676b340 | ||
|
|
691db5b877 | ||
|
|
7bec3d19ac | ||
|
|
3f85aa3aea | ||
|
|
9ccf4900fe | ||
|
|
bf1c606610 | ||
|
|
6c53d31f2d | ||
|
|
4de1af498b | ||
|
|
c65409c348 | ||
|
|
422aec12cb | ||
|
|
52e26b6da8 | ||
|
|
ebb62966d3 | ||
|
|
b05e2910d8 | ||
|
|
3785ffe5c9 | ||
|
|
a4ab198042 | ||
|
|
67c5249b38 | ||
|
|
af067d1c00 | ||
|
|
b4053ee0d8 | ||
|
|
9f846e2636 | ||
|
|
8d734df63b | ||
|
|
0998123e52 | ||
|
|
f2c556a6cc | ||
|
|
b41612bea8 | ||
|
|
da88196a89 | ||
|
|
bde209b077 | ||
|
|
1a278e7ca0 | ||
|
|
1637cb4168 | ||
|
|
d9e9f35230 | ||
|
|
cbc73148d3 | ||
|
|
60a18247e1 | ||
|
|
7cc0436083 | ||
|
|
f8eb48472e | ||
|
|
8074d396d0 | ||
|
|
cfbd71693b | ||
|
|
3a2d7d18a3 | ||
|
|
b55cefc54f | ||
|
|
05778bb81a | ||
|
|
a70296e9b5 | ||
|
|
bd6210a212 | ||
|
|
944c9ad0b3 | ||
|
|
bd00a484ea | ||
|
|
1427b4ac3f | ||
|
|
fd6e42e15e | ||
|
|
8b071c0d22 | ||
|
|
176ce0d4d6 | ||
|
|
dd888fc30a | ||
|
|
789adaeabe | ||
|
|
2342f8d710 | ||
|
|
8b22d9d95e | ||
|
|
626c799b60 | ||
|
|
b2f6d07c25 | ||
|
|
70d57260d6 | ||
|
|
9062b90237 | ||
|
|
40467bc26c | ||
|
|
abe599a49d | ||
|
|
9975f5fe69 | ||
|
|
8a3148eee6 | ||
|
|
a31fb2ac6c | ||
|
|
2c13c4746b | ||
|
|
ec3a226a83 | ||
|
|
19dc26ea16 | ||
|
|
e29a384f90 | ||
|
|
088bbac543 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.91.0"
|
||||
__version__ = "15.92.1"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
"column_break_3czf",
|
||||
"bank_party_name",
|
||||
"bank_party_account_number",
|
||||
"bank_party_iban"
|
||||
"bank_party_iban",
|
||||
"extended_bank_statement_section",
|
||||
"included_fee",
|
||||
"excluded_fee"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -233,12 +236,32 @@
|
||||
{
|
||||
"fieldname": "column_break_oufv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "extended_bank_statement_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Extended Bank Statement"
|
||||
},
|
||||
{
|
||||
"fieldname": "included_fee",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Included Fee",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"description": "On save, the Excluded Fee will be converted to an Included Fee.",
|
||||
"fieldname": "excluded_fee",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Excluded Fee",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-23 17:32:58.514807",
|
||||
"modified": "2025-12-07 20:49:18.600757",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Transaction",
|
||||
|
||||
@@ -32,6 +32,8 @@ class BankTransaction(Document):
|
||||
date: DF.Date | None
|
||||
deposit: DF.Currency
|
||||
description: DF.SmallText | None
|
||||
excluded_fee: DF.Currency
|
||||
included_fee: DF.Currency
|
||||
naming_series: DF.Literal["ACC-BTN-.YYYY.-"]
|
||||
party: DF.DynamicLink | None
|
||||
party_type: DF.Link | None
|
||||
@@ -45,9 +47,11 @@ class BankTransaction(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
self.handle_excluded_fee()
|
||||
self.update_allocated_amount()
|
||||
|
||||
def validate(self):
|
||||
self.validate_included_fee()
|
||||
self.validate_duplicate_references()
|
||||
self.validate_currency()
|
||||
|
||||
@@ -307,6 +311,40 @@ class BankTransaction(Document):
|
||||
|
||||
self.party_type, self.party = result
|
||||
|
||||
def validate_included_fee(self):
|
||||
"""
|
||||
The included_fee is only handled for withdrawals. An included_fee for a deposit, is not credited to the account and is
|
||||
therefore outside of the deposit value and can be larger than the deposit itself.
|
||||
"""
|
||||
|
||||
if self.included_fee and self.withdrawal:
|
||||
if self.included_fee > self.withdrawal:
|
||||
frappe.throw(_("Included fee is bigger than the withdrawal itself."))
|
||||
|
||||
def handle_excluded_fee(self):
|
||||
# Include the excluded fee on validate to handle all further processing the same
|
||||
excluded_fee = flt(self.excluded_fee)
|
||||
if excluded_fee <= 0:
|
||||
return
|
||||
|
||||
# Suppress a negative deposit (aka withdrawal), likely not intendend
|
||||
if flt(self.deposit) > 0 and (flt(self.deposit) - excluded_fee) < 0:
|
||||
frappe.throw(_("The Excluded Fee is bigger than the Deposit it is deducted from."))
|
||||
|
||||
# Enforce directionality
|
||||
if flt(self.deposit) > 0 and flt(self.withdrawal) > 0:
|
||||
frappe.throw(
|
||||
_("Only one of Deposit or Withdrawal should be non-zero when applying an Excluded Fee.")
|
||||
)
|
||||
|
||||
if flt(self.deposit) > 0:
|
||||
self.deposit = flt(self.deposit) - excluded_fee
|
||||
# A fee applied to deposit and withdrawal equal 0 become a withdrawal
|
||||
elif flt(self.withdrawal) >= 0:
|
||||
self.withdrawal = flt(self.withdrawal) + excluded_fee
|
||||
self.included_fee = flt(self.included_fee) + excluded_fee
|
||||
self.excluded_fee = 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_doctypes_for_bank_reconciliation():
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestBankTransactionFees(FrappeTestCase):
|
||||
def test_included_fee_throws(self):
|
||||
"""A fee that's part of a withdrawal cannot be bigger than the
|
||||
withdrawal itself."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 101
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.validate_included_fee)
|
||||
|
||||
def test_included_fee_allows_equal(self):
|
||||
"""A fee that's part of a withdrawal may be equal to the withdrawal
|
||||
amount (only the fee was deducted from the account)."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 100
|
||||
|
||||
bt.validate_included_fee()
|
||||
|
||||
def test_included_fee_allows_for_deposit(self):
|
||||
"""For deposits, a fee may be recorded separately without limiting the
|
||||
received amount."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.included_fee = 999
|
||||
|
||||
bt.validate_included_fee()
|
||||
|
||||
def test_excluded_fee_noop_when_zero(self):
|
||||
"""When there is no excluded fee to apply, the amounts should remain
|
||||
unchanged."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 100
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 5
|
||||
bt.excluded_fee = 0
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 100)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_throws_when_exceeds_deposit(self):
|
||||
"""A fee deducted from an incoming payment must not exceed the incoming
|
||||
amount (else it would be a withdrawal, a conversion we don't support)."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.excluded_fee = 11
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
|
||||
|
||||
def test_excluded_fee_throws_when_both_deposit_and_withdrawal_are_set(self):
|
||||
"""A transaction must be either incoming or outgoing when applying a
|
||||
fee, not both."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.withdrawal = 10
|
||||
bt.excluded_fee = 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
|
||||
|
||||
def test_excluded_fee_deducts_from_deposit(self):
|
||||
"""When a fee is deducted from an incoming payment, the net received
|
||||
amount decreases and the fee is tracked as included."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 100
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 2
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 95)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 7)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_can_reduce_an_incoming_payment_to_zero(self):
|
||||
"""A separately-deducted fee may reduce an incoming payment to zero,
|
||||
while still tracking the fee."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 5
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 0
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_increases_outgoing_payment(self):
|
||||
"""When a separately-deducted fee is provided for an outgoing payment,
|
||||
the total money leaving increases and the fee is tracked."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 0
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 2
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 105)
|
||||
self.assertEqual(bt.included_fee, 7)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_turns_zero_amount_into_withdrawal(self):
|
||||
"""If only an excluded fee is provided, it should be treated as an
|
||||
outgoing payment and the fee is then tracked as included."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 0
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 0
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 5)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
@@ -19,7 +19,7 @@ frappe.ui.form.on("Currency Exchange Settings", {
|
||||
to: "{to_currency}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
} else if (frm.doc.service_provider == "frankfurter.dev") {
|
||||
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
||||
let result = ["rates", "{to_currency}"];
|
||||
let params = {
|
||||
base: "{from_currency}",
|
||||
|
||||
@@ -60,7 +60,7 @@ class CurrencyExchangeSettings(Document):
|
||||
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
|
||||
self.append("req_params", {"key": "from", "value": "{from_currency}"})
|
||||
self.append("req_params", {"key": "to", "value": "{to_currency}"})
|
||||
elif self.service_provider == "frankfurter.dev":
|
||||
elif self.service_provider in ("frankfurter.dev", "frankfurter.app"):
|
||||
self.set("result_key", [])
|
||||
self.set("req_params", [])
|
||||
|
||||
@@ -105,9 +105,11 @@ class CurrencyExchangeSettings(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev"]:
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
|
||||
if service_provider == "exchangerate.host":
|
||||
api = "api.exchangerate.host/convert"
|
||||
elif service_provider == "frankfurter.app":
|
||||
api = "api.frankfurter.app/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev":
|
||||
api = "api.frankfurter.dev/v1/{transaction_date}"
|
||||
|
||||
|
||||
@@ -486,6 +486,9 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.posting_date = self.posting_date
|
||||
journal_entry.multi_currency = 1
|
||||
|
||||
# Prevent JE from overriding user-entered exchange rates (e.g., rate of 1)
|
||||
journal_entry.flags.ignore_exchange_rate = True
|
||||
|
||||
journal_entry_accounts = []
|
||||
for d in accounts:
|
||||
if not flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")):
|
||||
|
||||
@@ -33,6 +33,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
|
||||
get_depr_schedule,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
|
||||
|
||||
|
||||
class StockAccountInvalidTransaction(frappe.ValidationError):
|
||||
@@ -273,93 +274,7 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
|
||||
def apply_tax_withholding(self):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
|
||||
|
||||
if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
|
||||
parties = [d.party for d in self.get("accounts") if d.party]
|
||||
parties = list(set(parties))
|
||||
|
||||
if len(parties) > 1:
|
||||
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
|
||||
|
||||
account_type_map = get_account_type_map(self.company)
|
||||
party_type = "supplier" if self.voucher_type == "Credit Note" else "customer"
|
||||
doctype = "Purchase Invoice" if self.voucher_type == "Credit Note" else "Sales Invoice"
|
||||
debit_or_credit = (
|
||||
"debit_in_account_currency"
|
||||
if self.voucher_type == "Credit Note"
|
||||
else "credit_in_account_currency"
|
||||
)
|
||||
rev_debit_or_credit = (
|
||||
"credit_in_account_currency"
|
||||
if debit_or_credit == "debit_in_account_currency"
|
||||
else "debit_in_account_currency"
|
||||
)
|
||||
|
||||
party_account = get_party_account(party_type.title(), parties[0], self.company)
|
||||
|
||||
net_total = sum(
|
||||
d.get(debit_or_credit)
|
||||
for d in self.get("accounts")
|
||||
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
|
||||
)
|
||||
|
||||
party_amount = sum(
|
||||
d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account
|
||||
)
|
||||
|
||||
inv = frappe._dict(
|
||||
{
|
||||
party_type: parties[0],
|
||||
"doctype": doctype,
|
||||
"company": self.company,
|
||||
"posting_date": self.posting_date,
|
||||
"net_total": net_total,
|
||||
}
|
||||
)
|
||||
|
||||
tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
|
||||
inv, self.tax_withholding_category
|
||||
)
|
||||
|
||||
if not tax_withholding_details:
|
||||
return
|
||||
|
||||
accounts = []
|
||||
for d in self.get("accounts"):
|
||||
if d.get("account") == tax_withholding_details.get("account_head"):
|
||||
d.update(
|
||||
{
|
||||
"account": tax_withholding_details.get("account_head"),
|
||||
debit_or_credit: tax_withholding_details.get("tax_amount"),
|
||||
}
|
||||
)
|
||||
|
||||
accounts.append(d.get("account"))
|
||||
|
||||
if d.get("account") == party_account:
|
||||
d.update({rev_debit_or_credit: party_amount - tax_withholding_details.get("tax_amount")})
|
||||
|
||||
if not accounts or tax_withholding_details.get("account_head") not in accounts:
|
||||
self.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": tax_withholding_details.get("account_head"),
|
||||
rev_debit_or_credit: tax_withholding_details.get("tax_amount"),
|
||||
"against_account": parties[0],
|
||||
},
|
||||
)
|
||||
|
||||
to_remove = [
|
||||
d
|
||||
for d in self.get("accounts")
|
||||
if not d.get(rev_debit_or_credit) and d.account == tax_withholding_details.get("account_head")
|
||||
]
|
||||
|
||||
for d in to_remove:
|
||||
self.remove(d)
|
||||
JournalEntryTaxWithholding(self).apply()
|
||||
|
||||
def update_asset_value(self):
|
||||
if self.flags.planned_depr_entry or self.voucher_type != "Depreciation Entry":
|
||||
@@ -1281,6 +1196,230 @@ class JournalEntry(AccountsController):
|
||||
frappe.throw(_("Accounts table cannot be blank."))
|
||||
|
||||
|
||||
class JournalEntryTaxWithholding:
|
||||
def __init__(self, journal_entry):
|
||||
self.doc: JournalEntry = journal_entry
|
||||
self.party = None
|
||||
self.party_type = None
|
||||
self.party_account = None
|
||||
self.party_row = None
|
||||
self.existing_tds_rows = []
|
||||
self.precision = None
|
||||
self.has_multiple_parties = False
|
||||
|
||||
# Direction fields based on party type
|
||||
self.party_field = None # "credit" for Supplier, "debit" for Customer
|
||||
self.reverse_field = None # opposite of party_field
|
||||
|
||||
def apply(self):
|
||||
if not self._set_party_info():
|
||||
return
|
||||
|
||||
self._setup_direction_fields()
|
||||
self._reset_existing_tds()
|
||||
|
||||
if not self._should_apply_tds():
|
||||
self._cleanup_duplicate_tds_rows(None)
|
||||
return
|
||||
|
||||
if self.has_multiple_parties:
|
||||
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
|
||||
|
||||
net_total = self._calculate_net_total()
|
||||
if net_total <= 0:
|
||||
return
|
||||
|
||||
tds_details = self._get_tds_details(net_total)
|
||||
if not tds_details or not tds_details.get("tax_amount"):
|
||||
return
|
||||
|
||||
self._create_or_update_tds_row(tds_details)
|
||||
self._update_party_amount(tds_details.get("tax_amount"), is_reversal=False)
|
||||
|
||||
self._recalculate_totals()
|
||||
|
||||
def _should_apply_tds(self):
|
||||
return self.doc.apply_tds and self.doc.voucher_type in ("Debit Note", "Credit Note")
|
||||
|
||||
def _set_party_info(self):
|
||||
for row in self.doc.get("accounts"):
|
||||
if row.party_type in ("Customer", "Supplier") and row.party:
|
||||
if self.party and row.party != self.party:
|
||||
self.has_multiple_parties = True
|
||||
|
||||
if not self.party:
|
||||
self.party = row.party
|
||||
self.party_type = row.party_type
|
||||
self.party_account = row.account
|
||||
self.party_row = row
|
||||
|
||||
if row.get("is_tax_withholding_account"):
|
||||
self.existing_tds_rows.append(row)
|
||||
|
||||
return bool(self.party)
|
||||
|
||||
def _setup_direction_fields(self):
|
||||
"""
|
||||
For Supplier (TDS): party has credit, TDS reduces credit
|
||||
For Customer (TCS): party has debit, TCS increases debit
|
||||
"""
|
||||
if self.party_type == "Supplier":
|
||||
self.party_field = "credit"
|
||||
self.reverse_field = "debit"
|
||||
else: # Customer
|
||||
self.party_field = "debit"
|
||||
self.reverse_field = "credit"
|
||||
|
||||
self.precision = self.doc.precision(self.party_field, self.party_row)
|
||||
|
||||
def _reset_existing_tds(self):
|
||||
for row in self.existing_tds_rows:
|
||||
# TDS amount is always in credit (liability to government)
|
||||
tds_amount = flt(row.get("credit") - row.get("debit"), self.precision)
|
||||
if not tds_amount:
|
||||
continue
|
||||
|
||||
self._update_party_amount(tds_amount, is_reversal=True)
|
||||
|
||||
# zero_out_tds_row
|
||||
row.update(
|
||||
{
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def _update_party_amount(self, amount, is_reversal=False):
|
||||
amount = flt(amount, self.precision)
|
||||
amount_in_party_currency = flt(amount / self.party_row.get("exchange_rate", 1), self.precision)
|
||||
|
||||
# Determine which field the party amount is in
|
||||
active_field = self.party_field if self.party_row.get(self.party_field) else self.reverse_field
|
||||
|
||||
# If amount is in reverse field, flip the signs
|
||||
if active_field == self.reverse_field:
|
||||
amount = -amount
|
||||
amount_in_party_currency = -amount_in_party_currency
|
||||
|
||||
# Direction multiplier based on party type:
|
||||
# Customer (TCS): +1 (add to debit)
|
||||
# Supplier (TDS): -1 (subtract from credit)
|
||||
direction = 1 if self.party_type == "Customer" else -1
|
||||
|
||||
# Reversal inverts the direction
|
||||
if is_reversal:
|
||||
direction = -direction
|
||||
|
||||
adjustment = amount * direction
|
||||
adjustment_in_party_currency = amount_in_party_currency * direction
|
||||
|
||||
active_field_account_currency = f"{active_field}_in_account_currency"
|
||||
|
||||
self.party_row.update(
|
||||
{
|
||||
active_field: flt(self.party_row.get(active_field) + adjustment, self.precision),
|
||||
active_field_account_currency: flt(
|
||||
self.party_row.get(active_field_account_currency) + adjustment_in_party_currency,
|
||||
self.precision,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def _calculate_net_total(self):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
|
||||
|
||||
account_type_map = get_account_type_map(self.doc.company)
|
||||
|
||||
return flt(
|
||||
sum(
|
||||
d.get(self.reverse_field) - d.get(self.party_field)
|
||||
for d in self.doc.get("accounts")
|
||||
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
|
||||
and d.account != self.party_account
|
||||
and not d.get("is_tax_withholding_account")
|
||||
),
|
||||
self.precision,
|
||||
)
|
||||
|
||||
def _get_tds_details(self, net_total):
|
||||
return get_party_tax_withholding_details(
|
||||
frappe._dict(
|
||||
{
|
||||
"party_type": self.party_type,
|
||||
"party": self.party,
|
||||
"doctype": self.doc.doctype,
|
||||
"company": self.doc.company,
|
||||
"posting_date": self.doc.posting_date,
|
||||
"tax_withholding_net_total": net_total,
|
||||
"base_tax_withholding_net_total": net_total,
|
||||
"grand_total": net_total,
|
||||
}
|
||||
),
|
||||
self.doc.tax_withholding_category,
|
||||
)
|
||||
|
||||
def _create_or_update_tds_row(self, tds_details):
|
||||
tax_account = tds_details.get("account_head")
|
||||
account_currency = get_account_currency(tax_account)
|
||||
company_currency = frappe.get_cached_value("Company", self.doc.company, "default_currency")
|
||||
exchange_rate = _get_exchange_rate(account_currency, company_currency, self.doc.posting_date)
|
||||
|
||||
tax_amount = flt(tds_details.get("tax_amount"), self.precision)
|
||||
tax_amount_in_account_currency = flt(tax_amount / exchange_rate, self.precision)
|
||||
|
||||
# Find existing TDS row for this account
|
||||
tax_row = None
|
||||
for row in self.doc.get("accounts"):
|
||||
if row.account == tax_account and row.get("is_tax_withholding_account"):
|
||||
tax_row = row
|
||||
break
|
||||
|
||||
if not tax_row:
|
||||
tax_row = self.doc.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": tax_account,
|
||||
"account_currency": account_currency,
|
||||
"exchange_rate": exchange_rate,
|
||||
"cost_center": tds_details.get("cost_center"),
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
"is_tax_withholding_account": 1,
|
||||
},
|
||||
)
|
||||
|
||||
# TDS/TCS is always credited (liability to government)
|
||||
tax_row.update(
|
||||
{
|
||||
"credit": tax_amount,
|
||||
"credit_in_account_currency": tax_amount_in_account_currency,
|
||||
"debit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
}
|
||||
)
|
||||
|
||||
self._cleanup_duplicate_tds_rows(tax_row)
|
||||
|
||||
def _cleanup_duplicate_tds_rows(self, current_tax_row):
|
||||
rows_to_remove = [
|
||||
row
|
||||
for row in self.doc.get("accounts")
|
||||
if row.get("is_tax_withholding_account") and row != current_tax_row
|
||||
]
|
||||
|
||||
for row in rows_to_remove:
|
||||
self.doc.remove(row)
|
||||
|
||||
def _recalculate_totals(self):
|
||||
self.doc.set_amounts_in_company_currency()
|
||||
self.doc.set_total_debit_credit()
|
||||
self.doc.set_against_account()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
@@ -1649,8 +1788,6 @@ def get_exchange_rate(
|
||||
credit=None,
|
||||
exchange_rate=None,
|
||||
):
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
|
||||
)
|
||||
@@ -1672,8 +1809,8 @@ def get_exchange_rate(
|
||||
|
||||
# The date used to retreive the exchange rate here is the date passed
|
||||
# in as an argument to this function.
|
||||
elif (not exchange_rate or flt(exchange_rate) == 1) and account_currency and posting_date:
|
||||
exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date)
|
||||
elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date:
|
||||
exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date)
|
||||
else:
|
||||
exchange_rate = 1
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"reference_detail_no",
|
||||
"advance_voucher_type",
|
||||
"advance_voucher_no",
|
||||
"is_tax_withholding_account",
|
||||
"col_break3",
|
||||
"is_advance",
|
||||
"user_remark",
|
||||
@@ -282,12 +283,19 @@
|
||||
"options": "advance_voucher_type",
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_tax_withholding_account",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Tax Withholding Account",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-27 13:48:32.805100",
|
||||
"modified": "2025-11-27 12:23:33.157655",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Journal Entry Account",
|
||||
|
||||
@@ -28,6 +28,7 @@ class JournalEntryAccount(Document):
|
||||
debit_in_account_currency: DF.Currency
|
||||
exchange_rate: DF.Float
|
||||
is_advance: DF.Literal["No", "Yes"]
|
||||
is_tax_withholding_account: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
||||
@@ -1302,15 +1302,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
|
||||
|
||||
if (!row) {
|
||||
const response = await get_company_defaults(frm.doc.company);
|
||||
|
||||
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
|
||||
const account =
|
||||
response.message?.[account_fieldname] ||
|
||||
company_defaults?.[account_fieldname] ||
|
||||
(await prompt_for_missing_account(frm, account_fieldname));
|
||||
|
||||
row = frm.add_child("deductions");
|
||||
row.account = account;
|
||||
row.cost_center = response.message?.cost_center;
|
||||
row.cost_center = company_defaults?.cost_center;
|
||||
row.is_exchange_gain_loss = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -545,6 +545,9 @@ def make_payment_request(**args):
|
||||
if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
|
||||
frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt)))
|
||||
|
||||
if args.dn and not isinstance(args.dn, str):
|
||||
frappe.throw(_("Invalid parameter. 'dn' should be of type str"))
|
||||
|
||||
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
|
||||
if not args.get("company"):
|
||||
args.company = ref_doc.company
|
||||
@@ -850,6 +853,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
)
|
||||
|
||||
referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
|
||||
doc_updates = {}
|
||||
|
||||
for ref in references:
|
||||
if not ref.payment_request:
|
||||
@@ -875,7 +879,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
title=_("Invalid Allocated Amount"),
|
||||
)
|
||||
|
||||
# update status
|
||||
# determine status
|
||||
if new_outstanding_amount == payment_request["grand_total"]:
|
||||
status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
|
||||
elif new_outstanding_amount == 0:
|
||||
@@ -883,12 +887,15 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
elif new_outstanding_amount > 0:
|
||||
status = "Partially Paid"
|
||||
|
||||
# update database
|
||||
frappe.db.set_value(
|
||||
"Payment Request",
|
||||
ref.payment_request,
|
||||
{"outstanding_amount": new_outstanding_amount, "status": status},
|
||||
)
|
||||
# prepare bulk update data
|
||||
doc_updates[ref.payment_request] = {
|
||||
"outstanding_amount": new_outstanding_amount,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
# bulk update all payment requests
|
||||
if doc_updates:
|
||||
frappe.db.bulk_update("Payment Request", doc_updates)
|
||||
|
||||
|
||||
def get_dummy_message(doc):
|
||||
|
||||
@@ -126,8 +126,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
|
||||
cur_frm.add_custom_button(
|
||||
if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
|
||||
this.frm.add_custom_button(
|
||||
__("Payment Request"),
|
||||
function () {
|
||||
me.make_payment_request();
|
||||
@@ -575,17 +575,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.cost_center) {
|
||||
var cl = doc.items || [];
|
||||
for (var i = 0; i < cl.length; i++) {
|
||||
if (!cl[i].cost_center) cl[i].cost_center = d.cost_center;
|
||||
}
|
||||
}
|
||||
refresh_field("items");
|
||||
};
|
||||
|
||||
cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [["Project", "status", "not in", "Completed, Cancelled"]],
|
||||
|
||||
@@ -648,10 +648,6 @@ cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account");
|
||||
};
|
||||
|
||||
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center");
|
||||
};
|
||||
|
||||
cur_frm.set_query("debit_to", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1349,7 +1349,11 @@ class SalesInvoice(SellingController):
|
||||
)
|
||||
|
||||
for item in self.get("items"):
|
||||
if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset:
|
||||
if (
|
||||
flt(item.base_net_amount, item.precision("base_net_amount"))
|
||||
or item.is_fixed_asset
|
||||
or enable_discount_accounting
|
||||
):
|
||||
# Do not book income for transfer within same company
|
||||
if self.is_internal_transfer():
|
||||
continue
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
@@ -102,7 +102,7 @@
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
@@ -199,7 +199,7 @@
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
@@ -221,7 +221,7 @@
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
@@ -324,7 +324,7 @@
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-01-10 18:32:36.201124",
|
||||
"modified": "2025-12-10 08:06:40.611761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Balance",
|
||||
@@ -339,4 +339,4 @@
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1,
|
||||
"track_seen": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ShareBalance(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Int
|
||||
amount: DF.Currency
|
||||
current_state: DF.Literal["", "Issued", "Purchased"]
|
||||
from_no: DF.Int
|
||||
is_company: DF.Check
|
||||
@@ -22,7 +22,7 @@ class ShareBalance(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
rate: DF.Int
|
||||
rate: DF.Currency
|
||||
share_type: DF.Link
|
||||
to_no: DF.Int
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -85,6 +85,9 @@ def get_party_details(inv):
|
||||
if inv.doctype == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party = inv.customer
|
||||
elif inv.doctype == "Journal Entry":
|
||||
party_type = inv.party_type
|
||||
party = inv.party
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party = inv.supplier
|
||||
@@ -155,7 +158,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
|
||||
party_type, parties, inv, tax_details, posting_date, pan_no
|
||||
)
|
||||
|
||||
if party_type == "Supplier":
|
||||
if party_type == "Supplier" or inv.doctype == "Journal Entry":
|
||||
tax_row = get_tax_row_for_tds(tax_details, tax_amount)
|
||||
else:
|
||||
tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted)
|
||||
@@ -346,7 +349,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
|
||||
elif party_type == "Customer":
|
||||
if tax_deducted:
|
||||
# if already TCS is charged, then amount will be calculated based on 'Previous Row Total'
|
||||
tax_amount = 0
|
||||
if inv.doctype == "Sales Invoice":
|
||||
tax_amount = 0
|
||||
else:
|
||||
tax_amount = inv.base_tax_withholding_net_total * tax_details.rate / 100
|
||||
else:
|
||||
# if no TCS has been charged in FY,
|
||||
# then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold
|
||||
@@ -718,7 +724,7 @@ def get_advance_adjusted_in_invoice(inv):
|
||||
|
||||
|
||||
def get_invoice_total_without_tcs(inv, tax_details):
|
||||
tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head]
|
||||
tcs_tax_row = [d for d in inv.get("taxes") or [] if d.account_head == tax_details.account_head]
|
||||
tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0
|
||||
|
||||
return inv.grand_total - tcs_tax_row_amount
|
||||
|
||||
@@ -848,6 +848,90 @@ class TestTaxWithholdingCategory(FrappeTestCase):
|
||||
self.assertEqual(payment.taxes[0].tax_amount, 6000)
|
||||
self.assertEqual(payment.taxes[0].allocated_amount, 6000)
|
||||
|
||||
def test_tds_on_journal_entry_for_supplier(self):
|
||||
"""Test TDS deduction for Supplier in Debit Note"""
|
||||
frappe.db.set_value(
|
||||
"Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS"
|
||||
)
|
||||
|
||||
jv = make_journal_entry_with_tax_withholding(
|
||||
party_type="Supplier",
|
||||
party="Test TDS Supplier",
|
||||
voucher_type="Debit Note",
|
||||
amount=50000,
|
||||
save=False,
|
||||
)
|
||||
jv.apply_tds = 1
|
||||
jv.tax_withholding_category = "Cumulative Threshold TDS"
|
||||
jv.save()
|
||||
|
||||
# Again saving should not change tds amount
|
||||
jv.user_remark = "Test TDS on Journal Entry for Supplier"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
# TDS = 50000 * 10% = 5000
|
||||
self.assertEqual(len(jv.accounts), 3)
|
||||
|
||||
# Find TDS account row
|
||||
tds_row = None
|
||||
supplier_row = None
|
||||
for row in jv.accounts:
|
||||
if row.account == "TDS - _TC":
|
||||
tds_row = row
|
||||
elif row.party == "Test TDS Supplier":
|
||||
supplier_row = row
|
||||
|
||||
self.assertEqual(tds_row.credit, 5000)
|
||||
self.assertEqual(tds_row.debit, 0)
|
||||
|
||||
# Supplier amount should be reduced by TDS
|
||||
self.assertEqual(supplier_row.credit, 45000)
|
||||
jv.cancel()
|
||||
|
||||
def test_tcs_on_journal_entry_for_customer(self):
|
||||
"""Test TCS collection for Customer in Credit Note"""
|
||||
frappe.db.set_value(
|
||||
"Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
|
||||
)
|
||||
|
||||
# Create Credit Note with amount exceeding threshold
|
||||
jv = make_journal_entry_with_tax_withholding(
|
||||
party_type="Customer",
|
||||
party="Test TCS Customer",
|
||||
voucher_type="Credit Note",
|
||||
amount=50000,
|
||||
save=False,
|
||||
)
|
||||
jv.apply_tds = 1
|
||||
jv.tax_withholding_category = "Cumulative Threshold TCS"
|
||||
jv.save()
|
||||
|
||||
# Again saving should not change tds amount
|
||||
jv.user_remark = "Test TCS on Journal Entry for Customer"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
# Assert TCS calculation (10% on amount above threshold of 30000)
|
||||
self.assertEqual(len(jv.accounts), 3)
|
||||
|
||||
# Find TCS account row
|
||||
tcs_row = None
|
||||
customer_row = None
|
||||
for row in jv.accounts:
|
||||
if row.account == "TCS - _TC":
|
||||
tcs_row = row
|
||||
elif row.party == "Test TCS Customer":
|
||||
customer_row = row
|
||||
|
||||
# TCS should be credited (liability to government)
|
||||
self.assertEqual(tcs_row.credit, 2000) # above threshold 20000*10%
|
||||
self.assertEqual(tcs_row.debit, 0)
|
||||
|
||||
# Customer amount should be increased by TCS
|
||||
self.assertEqual(customer_row.debit, 52000)
|
||||
jv.cancel()
|
||||
|
||||
|
||||
def cancel_invoices():
|
||||
purchase_invoices = frappe.get_all(
|
||||
@@ -996,6 +1080,88 @@ def create_payment_entry(**args):
|
||||
return pe
|
||||
|
||||
|
||||
def make_journal_entry_with_tax_withholding(
|
||||
party_type,
|
||||
party,
|
||||
voucher_type,
|
||||
amount,
|
||||
cost_center=None,
|
||||
posting_date=None,
|
||||
save=True,
|
||||
submit=False,
|
||||
):
|
||||
"""Helper function to create Journal Entry for tax withholding"""
|
||||
if not cost_center:
|
||||
cost_center = "_Test Cost Center - _TC"
|
||||
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = posting_date or today()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = voucher_type
|
||||
jv.multi_currency = 0
|
||||
|
||||
if party_type == "Supplier":
|
||||
# Debit Note: Expense Dr, Supplier Cr
|
||||
expense_account = "Stock Received But Not Billed - _TC"
|
||||
party_account = "Creditors - _TC"
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": expense_account,
|
||||
"cost_center": cost_center,
|
||||
"debit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": party_account,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"cost_center": cost_center,
|
||||
"credit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
else: # Customer
|
||||
# Credit Note: Customer Dr, Income Cr
|
||||
party_account = "Debtors - _TC"
|
||||
income_account = "Sales - _TC"
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": party_account,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"cost_center": cost_center,
|
||||
"debit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": income_account,
|
||||
"cost_center": cost_center,
|
||||
"credit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
|
||||
if save or submit:
|
||||
jv.insert()
|
||||
|
||||
if submit:
|
||||
jv.submit()
|
||||
|
||||
return jv
|
||||
|
||||
|
||||
def create_records():
|
||||
# create a new suppliers
|
||||
for name in [
|
||||
|
||||
@@ -199,19 +199,20 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_r
|
||||
for d in gl_map:
|
||||
cost_center = d.get("cost_center")
|
||||
|
||||
cost_center_allocation = get_cost_center_allocation_data(
|
||||
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
|
||||
)
|
||||
|
||||
if not cost_center_allocation:
|
||||
new_gl_map.append(d)
|
||||
continue
|
||||
|
||||
# Validate budget against main cost center
|
||||
if not from_repost:
|
||||
validate_expense_against_budget(
|
||||
d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)
|
||||
)
|
||||
|
||||
cost_center_allocation = get_cost_center_allocation_data(
|
||||
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
|
||||
)
|
||||
if not cost_center_allocation:
|
||||
new_gl_map.append(d)
|
||||
continue
|
||||
|
||||
if d.account == round_off_account:
|
||||
d.cost_center = cost_center_allocation[0][0]
|
||||
new_gl_map.append(d)
|
||||
@@ -414,7 +415,11 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
|
||||
gle.flags.notify_update = False
|
||||
gle.submit()
|
||||
|
||||
if not from_repost and gle.voucher_type != "Period Closing Voucher":
|
||||
if (
|
||||
not from_repost
|
||||
and gle.voucher_type != "Period Closing Voucher"
|
||||
and (gle.is_cancelled == 0 or gle.voucher_type == "Journal Entry")
|
||||
):
|
||||
validate_expense_against_budget(args)
|
||||
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
|
||||
immutable_ledger = frappe.db.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
||||
|
||||
def update_value_in_dict(data, key, gle):
|
||||
def update_value_in_dict(data, key, gle, show_net_values=False):
|
||||
data[key].debit += gle.debit
|
||||
data[key].credit += gle.credit
|
||||
|
||||
@@ -493,10 +493,14 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
data[key].debit_in_transaction_currency += gle.debit_in_transaction_currency
|
||||
data[key].credit_in_transaction_currency += gle.credit_in_transaction_currency
|
||||
|
||||
if filters.get("show_net_values_in_party_account") and account_type_map.get(data[key].account) in (
|
||||
"Receivable",
|
||||
"Payable",
|
||||
):
|
||||
if (
|
||||
filters.get("show_net_values_in_party_account")
|
||||
and account_type_map.get(data[key].account)
|
||||
in (
|
||||
"Receivable",
|
||||
"Payable",
|
||||
)
|
||||
) or show_net_values:
|
||||
net_value = data[key].debit - data[key].credit
|
||||
net_value_in_account_currency = (
|
||||
data[key].debit_in_account_currency - data[key].credit_in_account_currency
|
||||
@@ -526,11 +530,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
|
||||
if gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries):
|
||||
if not group_by_voucher_consolidated:
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle, True)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle, True)
|
||||
|
||||
update_value_in_dict(totals, "opening", gle)
|
||||
update_value_in_dict(totals, "closing", gle)
|
||||
update_value_in_dict(totals, "opening", gle, True)
|
||||
update_value_in_dict(totals, "closing", gle, True)
|
||||
|
||||
elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries):
|
||||
if not group_by_voucher_consolidated:
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
|
||||
@@ -16,7 +15,7 @@ from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register i
|
||||
get_group_by_and_display_fields,
|
||||
get_tax_accounts,
|
||||
)
|
||||
from erpnext.accounts.report.utils import get_query_columns, get_values_for_columns
|
||||
from erpnext.accounts.report.utils import get_values_for_columns
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -41,16 +40,6 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
tax_doctype="Purchase Taxes and Charges",
|
||||
)
|
||||
|
||||
scrubbed_tax_fields = {}
|
||||
|
||||
for tax in tax_columns:
|
||||
scrubbed_tax_fields.update(
|
||||
{
|
||||
tax + " Rate": frappe.scrub(tax + " Rate"),
|
||||
tax + " Amount": frappe.scrub(tax + " Amount"),
|
||||
}
|
||||
)
|
||||
|
||||
po_pr_map = get_purchase_receipts_against_purchase_order(item_list)
|
||||
|
||||
data = []
|
||||
@@ -100,8 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
{
|
||||
scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0),
|
||||
scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0),
|
||||
f"{tax}_rate": item_tax.get("tax_rate", 0),
|
||||
f"{tax}_amount": item_tax.get("tax_amount", 0),
|
||||
}
|
||||
)
|
||||
total_tax += flt(item_tax.get("tax_amount"))
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import functions as fn
|
||||
from frappe.utils import cstr, flt
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
from frappe.utils.xlsxutils import handle_html
|
||||
|
||||
@@ -32,16 +32,6 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
if item_list:
|
||||
itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency)
|
||||
|
||||
scrubbed_tax_fields = {}
|
||||
|
||||
for tax in tax_columns:
|
||||
scrubbed_tax_fields.update(
|
||||
{
|
||||
tax + " Rate": frappe.scrub(tax + " Rate"),
|
||||
tax + " Amount": frappe.scrub(tax + " Amount"),
|
||||
}
|
||||
)
|
||||
|
||||
mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list))
|
||||
so_dn_map = get_delivery_notes_against_sales_order(item_list)
|
||||
|
||||
@@ -102,8 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
{
|
||||
scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0),
|
||||
scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0),
|
||||
f"{tax}_rate": item_tax.get("tax_rate", 0),
|
||||
f"{tax}_amount": item_tax.get("tax_amount", 0),
|
||||
}
|
||||
)
|
||||
if item_tax.get("is_other_charges"):
|
||||
@@ -546,9 +536,10 @@ def get_tax_accounts(
|
||||
import json
|
||||
|
||||
item_row_map = {}
|
||||
tax_columns = []
|
||||
tax_columns = {}
|
||||
invoice_item_row = {}
|
||||
itemised_tax = {}
|
||||
scrubbed_description_map = {}
|
||||
add_deduct_tax = "charge_type"
|
||||
|
||||
tax_amount_precision = (
|
||||
@@ -605,9 +596,14 @@ def get_tax_accounts(
|
||||
tax_amount,
|
||||
) in tax_details:
|
||||
description = handle_html(description)
|
||||
if description not in tax_columns and tax_amount:
|
||||
scrubbed_description = scrubbed_description_map.get(description)
|
||||
if not scrubbed_description:
|
||||
scrubbed_description = frappe.scrub(description)
|
||||
scrubbed_description_map[description] = scrubbed_description
|
||||
|
||||
if scrubbed_description not in tax_columns and tax_amount:
|
||||
# as description is text editor earlier and markup can break the column convention in reports
|
||||
tax_columns.append(description)
|
||||
tax_columns[scrubbed_description] = description
|
||||
|
||||
if item_wise_tax_detail:
|
||||
try:
|
||||
@@ -641,7 +637,7 @@ def get_tax_accounts(
|
||||
else tax_value
|
||||
)
|
||||
|
||||
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
|
||||
itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict(
|
||||
{
|
||||
"tax_rate": tax_rate,
|
||||
"tax_amount": tax_value,
|
||||
@@ -653,7 +649,7 @@ def get_tax_accounts(
|
||||
continue
|
||||
elif charge_type == "Actual" and tax_amount:
|
||||
for d in invoice_item_row.get(parent, []):
|
||||
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
|
||||
itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict(
|
||||
{
|
||||
"tax_rate": "NA",
|
||||
"tax_amount": flt(
|
||||
@@ -662,12 +658,14 @@ def get_tax_accounts(
|
||||
}
|
||||
)
|
||||
|
||||
tax_columns.sort()
|
||||
for desc in tax_columns:
|
||||
tax_columns_list = list(tax_columns.keys())
|
||||
tax_columns_list.sort()
|
||||
for scrubbed_desc in tax_columns_list:
|
||||
desc = tax_columns[scrubbed_desc]
|
||||
columns.append(
|
||||
{
|
||||
"label": _(desc + " Rate"),
|
||||
"fieldname": frappe.scrub(desc + " Rate"),
|
||||
"fieldname": f"{scrubbed_desc}_rate",
|
||||
"fieldtype": "Float",
|
||||
"width": 100,
|
||||
}
|
||||
@@ -676,7 +674,7 @@ def get_tax_accounts(
|
||||
columns.append(
|
||||
{
|
||||
"label": _(desc + " Amount"),
|
||||
"fieldname": frappe.scrub(desc + " Amount"),
|
||||
"fieldname": f"{scrubbed_desc}_amount",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"width": 100,
|
||||
@@ -714,7 +712,7 @@ def get_tax_accounts(
|
||||
},
|
||||
]
|
||||
|
||||
return itemised_tax, tax_columns
|
||||
return itemised_tax, tax_columns_list
|
||||
|
||||
|
||||
def add_total_row(
|
||||
@@ -807,5 +805,5 @@ def add_sub_total_row(item, total_row_map, group_by_value, tax_columns):
|
||||
total_row["percent_gt"] += item["percent_gt"]
|
||||
|
||||
for tax in tax_columns:
|
||||
total_row.setdefault(frappe.scrub(tax + " Amount"), 0.0)
|
||||
total_row[frappe.scrub(tax + " Amount")] += flt(item[frappe.scrub(tax + " Amount")])
|
||||
total_row.setdefault(f"{tax}_amount", 0.0)
|
||||
total_row[f"{tax}_amount"] += flt(item[f"{tax}_amount"])
|
||||
|
||||
@@ -399,7 +399,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
|
||||
}
|
||||
|
||||
for key in value_fields:
|
||||
row[key] = flt(d.get(key, 0.0), 3)
|
||||
row[key] = flt(d.get(key, 0.0))
|
||||
|
||||
if abs(row[key]) >= get_zero_cutoff(company_currency):
|
||||
# ignore zero values
|
||||
|
||||
@@ -537,6 +537,7 @@ def modify_depreciation_schedule_for_asset_repairs(asset, notes):
|
||||
for repair in asset_repairs:
|
||||
if repair.increase_in_asset_life:
|
||||
asset_repair = frappe.get_doc("Asset Repair", repair.name)
|
||||
asset_repair.asset_doc = asset
|
||||
asset_repair.modify_depreciation_schedule()
|
||||
make_new_active_asset_depr_schedules_and_cancel_current_ones(asset, notes)
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ class AssetCapitalization(StockController):
|
||||
self.make_gl_entries()
|
||||
self.repost_future_sle_and_gle()
|
||||
self.restore_consumed_asset_items()
|
||||
self.update_target_asset()
|
||||
|
||||
def set_title(self):
|
||||
self.title = self.target_asset_name or self.target_item_name or self.target_item_code
|
||||
@@ -607,8 +608,12 @@ class AssetCapitalization(StockController):
|
||||
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
|
||||
|
||||
asset_doc = frappe.get_doc("Asset", self.target_asset)
|
||||
asset_doc.gross_purchase_amount += total_target_asset_value
|
||||
asset_doc.purchase_amount += total_target_asset_value
|
||||
if self.docstatus == 2:
|
||||
asset_doc.gross_purchase_amount -= total_target_asset_value
|
||||
asset_doc.purchase_amount -= total_target_asset_value
|
||||
else:
|
||||
asset_doc.gross_purchase_amount += total_target_asset_value
|
||||
asset_doc.purchase_amount += total_target_asset_value
|
||||
asset_doc.set_status("Work In Progress")
|
||||
asset_doc.flags.ignore_validate = True
|
||||
asset_doc.save()
|
||||
|
||||
@@ -312,13 +312,30 @@ class AccountsController(TransactionBase):
|
||||
|
||||
def validate_company_linked_addresses(self):
|
||||
address_fields = []
|
||||
if self.doctype in ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
|
||||
sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice")
|
||||
purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation")
|
||||
|
||||
if self.doctype in sales_doctypes:
|
||||
address_fields = ["dispatch_address_name", "company_address"]
|
||||
elif self.doctype in ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation"):
|
||||
elif self.doctype in purchase_doctypes:
|
||||
address_fields = ["billing_address", "shipping_address"]
|
||||
|
||||
if not address_fields:
|
||||
return
|
||||
|
||||
# Determine if drop ship applies
|
||||
is_drop_ship = self.doctype in {
|
||||
"Purchase Order",
|
||||
"Sales Order",
|
||||
"Sales Invoice",
|
||||
} and self.is_drop_ship(self.items)
|
||||
|
||||
for field in address_fields:
|
||||
address = self.get(field)
|
||||
|
||||
if (field in ["dispatch_address_name", "shipping_address"]) and is_drop_ship:
|
||||
continue
|
||||
|
||||
if address and not frappe.db.exists(
|
||||
"Dynamic Link",
|
||||
{
|
||||
@@ -329,11 +346,15 @@ class AccountsController(TransactionBase):
|
||||
},
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} does not belong to the {1}.").format(
|
||||
_("{0} does not belong to the Company {1}.").format(
|
||||
_(self.meta.get_label(field)), bold(self.company)
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_drop_ship(items):
|
||||
return any(item.delivered_by_supplier for item in items)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
if hasattr(self, "letter_head") and not self.letter_head:
|
||||
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
||||
|
||||
@@ -86,6 +86,7 @@ status_map = {
|
||||
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"],
|
||||
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
|
||||
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
|
||||
["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"],
|
||||
["Cancelled", "eval:self.docstatus==2"],
|
||||
["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import functions
|
||||
from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
|
||||
from frappe.utils.deprecations import deprecated
|
||||
|
||||
@@ -377,6 +378,9 @@ class calculate_taxes_and_totals:
|
||||
self._calculate()
|
||||
|
||||
def calculate_taxes(self):
|
||||
# reset value from earlier calculations
|
||||
self.grand_total_diff = 0
|
||||
|
||||
doc = self.doc
|
||||
if not doc.get("taxes"):
|
||||
return
|
||||
@@ -586,7 +590,7 @@ class calculate_taxes_and_totals:
|
||||
self.grand_total_diff = 0
|
||||
|
||||
def calculate_totals(self):
|
||||
grand_total_diff = getattr(self, "grand_total_diff", 0)
|
||||
grand_total_diff = self.grand_total_diff
|
||||
|
||||
if self.doc.get("taxes"):
|
||||
self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + grand_total_diff
|
||||
@@ -685,6 +689,22 @@ class calculate_taxes_and_totals:
|
||||
discount_amount = self.doc.discount_amount or 0
|
||||
grand_total = self.doc.grand_total
|
||||
|
||||
if self.doc.get("is_return") and self.doc.get("return_against"):
|
||||
doctype = frappe.qb.DocType(self.doc.doctype)
|
||||
|
||||
result = (
|
||||
frappe.qb.from_(doctype)
|
||||
.select(functions.Sum(doctype.discount_amount).as_("total_return_discount"))
|
||||
.where(
|
||||
(doctype.return_against == self.doc.return_against)
|
||||
& (doctype.is_return == 1)
|
||||
& (doctype.docstatus == 1)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
total_return_discount = abs(result[0].get("total_return_discount") or 0)
|
||||
discount_amount += total_return_discount
|
||||
|
||||
# validate that discount amount cannot exceed the total before discount
|
||||
if (
|
||||
(grand_total >= 0 and discount_amount > grand_total)
|
||||
@@ -833,12 +853,11 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
)
|
||||
|
||||
if self.doc.docstatus.is_draft():
|
||||
if self.doc.get("write_off_outstanding_amount_automatically"):
|
||||
self.doc.write_off_amount = 0
|
||||
if self.doc.get("write_off_outstanding_amount_automatically"):
|
||||
self.doc.write_off_amount = 0
|
||||
|
||||
self.calculate_outstanding_amount()
|
||||
self.calculate_write_off_amount()
|
||||
self.calculate_outstanding_amount()
|
||||
self.calculate_write_off_amount()
|
||||
|
||||
def is_internal_invoice(self):
|
||||
"""
|
||||
|
||||
@@ -2434,6 +2434,7 @@ class TestAccountsController(FrappeTestCase):
|
||||
|
||||
def test_company_linked_address(self):
|
||||
from erpnext.crm.doctype.prospect.test_prospect import make_address
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
company_address = make_address(
|
||||
address_title="Company", address_type="Shipping", address_line1="100", city="Mumbai"
|
||||
@@ -2462,3 +2463,16 @@ class TestAccountsController(FrappeTestCase):
|
||||
po.billing_address = company_address.name
|
||||
po.reload()
|
||||
po.save()
|
||||
|
||||
si = make_sales_order(do_not_save=1, do_not_submit=1)
|
||||
si.dispatch_address_name = supplier_billing.name
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
si.items[0].delivered_by_supplier = 1
|
||||
si.items[0].supplier = "_Test Supplier"
|
||||
si.save()
|
||||
|
||||
po = create_purchase_order(do_not_save=True)
|
||||
po.shipping_address = customer_shipping.name
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
po.items[0].delivered_by_supplier = 1
|
||||
po.save()
|
||||
|
||||
@@ -412,29 +412,29 @@ scheduler_events = {
|
||||
"0/15 * * * *": [
|
||||
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
|
||||
],
|
||||
"0/30 * * * *": [
|
||||
"erpnext.utilities.doctype.video.video.update_youtube_data",
|
||||
],
|
||||
"0/30 * * * *": [],
|
||||
# Hourly but offset by 30 minutes
|
||||
"30 * * * *": [
|
||||
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
|
||||
],
|
||||
# Daily but offset by 45 minutes
|
||||
"45 0 * * *": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
],
|
||||
"45 0 * * *": [],
|
||||
},
|
||||
"hourly": [
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
"erpnext.projects.doctype.project.project.hourly_reminder",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
],
|
||||
"hourly_long": [
|
||||
"hourly_long": [],
|
||||
"hourly_maintenance": [
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
|
||||
"erpnext.utilities.bulk_transaction.retry",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.utilities.doctype.video.video.update_youtube_data",
|
||||
],
|
||||
"daily": [
|
||||
"daily": [],
|
||||
"daily_long": [],
|
||||
"daily_maintenance": [
|
||||
"erpnext.support.doctype.issue.issue.auto_close_tickets",
|
||||
"erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity",
|
||||
"erpnext.controllers.accounts_controller.update_invoice_status",
|
||||
@@ -458,17 +458,16 @@ scheduler_events = {
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_daily",
|
||||
"erpnext.accounts.utils.run_ledger_health_checks",
|
||||
"erpnext.assets.doctype.asset_maintenance_log.asset_maintenance_log.update_asset_maintenance_log_status",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
],
|
||||
"daily_long": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
"erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process",
|
||||
"erpnext.setup.doctype.email_digest.email_digest.send",
|
||||
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
|
||||
"erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",
|
||||
"erpnext.assets.doctype.asset.depreciation.post_depreciation_entries",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_monthly",
|
||||
|
||||
@@ -389,10 +389,12 @@ frappe.ui.form.on("BOM", {
|
||||
);
|
||||
|
||||
has_template_rm.forEach((d) => {
|
||||
let bom_qty = dialog.fields_dict.qty?.value || 1;
|
||||
|
||||
dialog.fields_dict.items.df.data.push({
|
||||
item_code: d.item_code,
|
||||
variant_item_code: "",
|
||||
qty: (d.qty / frm.doc.quantity) * (dialog.fields_dict.qty.value || 1),
|
||||
qty: flt(d.qty / frm.doc.quantity) * flt(bom_qty),
|
||||
source_warehouse: d.source_warehouse,
|
||||
operation: d.operation,
|
||||
});
|
||||
|
||||
@@ -178,17 +178,12 @@ class JobCard(Document):
|
||||
|
||||
if job_card_qty and ((job_card_qty - completed_qty) > wo_qty):
|
||||
form_link = get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
|
||||
|
||||
msg = f"""
|
||||
Qty To Manufacture in the job card
|
||||
cannot be greater than Qty To Manufacture in the
|
||||
work order for the operation {bold(self.operation)}.
|
||||
<br><br><b>Solution: </b> Either you can reduce the
|
||||
Qty To Manufacture in the job card or set the
|
||||
'Overproduction Percentage For Work Order'
|
||||
in the {form_link}."""
|
||||
|
||||
frappe.throw(_(msg), title=_("Extra Job Card Quantity"))
|
||||
frappe.throw(
|
||||
_(
|
||||
"Qty To Manufacture in the job card cannot be greater than Qty To Manufacture in the work order for the operation {0}. <br><br><b>Solution: </b> Either you can reduce the Qty To Manufacture in the job card or set the 'Overproduction Percentage For Work Order' in the {1}."
|
||||
).format(bold(self.operation), form_link),
|
||||
title=_("Extra Job Card Quantity"),
|
||||
)
|
||||
|
||||
def set_sub_operations(self):
|
||||
if not self.sub_operations and self.operation:
|
||||
@@ -1064,14 +1059,16 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
if row.completed_qty < current_operation_qty:
|
||||
msg = f"""The completed quantity {bold(current_operation_qty)}
|
||||
of an operation {bold(self.operation)} cannot be greater
|
||||
than the completed quantity {bold(row.completed_qty)}
|
||||
of a previous operation
|
||||
{bold(row.operation)}.
|
||||
"""
|
||||
|
||||
frappe.throw(_(msg))
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
|
||||
).format(
|
||||
bold(current_operation_qty),
|
||||
bold(self.operation),
|
||||
bold(row.completed_qty),
|
||||
bold(row.operation),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_work_order(self):
|
||||
if self.is_work_order_closed():
|
||||
|
||||
@@ -229,7 +229,8 @@ frappe.ui.form.on("Work Order", {
|
||||
if (
|
||||
frm.doc.docstatus === 1 &&
|
||||
["Closed", "Completed"].includes(frm.doc.status) &&
|
||||
frm.doc.produced_qty > 0
|
||||
frm.doc.produced_qty > 0 &&
|
||||
frm.doc.produced_qty > frm.doc.disassembled_qty
|
||||
) {
|
||||
frm.add_custom_button(
|
||||
__("Disassemble Order"),
|
||||
@@ -402,11 +403,14 @@ frappe.ui.form.on("Work Order", {
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, "Disassemble")
|
||||
.then((data) => {
|
||||
if (flt(data.qty) <= 0) {
|
||||
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
|
||||
return;
|
||||
}
|
||||
return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
|
||||
work_order_id: frm.doc.name,
|
||||
purpose: "Disassemble",
|
||||
qty: data.qty,
|
||||
target_warehouse: data.target_warehouse,
|
||||
});
|
||||
})
|
||||
.then((stock_entry) => {
|
||||
@@ -863,24 +867,6 @@ erpnext.work_order = {
|
||||
},
|
||||
];
|
||||
|
||||
if (purpose === "Disassemble") {
|
||||
fields.push({
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
fieldname: "target_warehouse",
|
||||
label: __("Target Warehouse"),
|
||||
default: frm.doc.source_warehouse || frm.doc.wip_warehouse,
|
||||
get_query() {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
frm.qty_prompt = frappe.prompt(
|
||||
fields,
|
||||
|
||||
@@ -979,14 +979,14 @@ class WorkOrder(Document):
|
||||
|
||||
for d in self.get("operations"):
|
||||
precision = d.precision("completed_qty")
|
||||
qty = flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision)
|
||||
qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision)
|
||||
if not qty:
|
||||
d.status = "Pending"
|
||||
elif flt(qty) < flt(self.qty):
|
||||
elif qty < flt(self.qty, precision):
|
||||
d.status = "Work in Progress"
|
||||
elif flt(qty) == flt(self.qty):
|
||||
elif qty == flt(self.qty, precision):
|
||||
d.status = "Completed"
|
||||
elif flt(qty) <= max_allowed_qty_for_wo:
|
||||
elif qty <= flt(max_allowed_qty_for_wo, precision):
|
||||
d.status = "Completed"
|
||||
else:
|
||||
frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'"))
|
||||
@@ -1373,6 +1373,13 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m
|
||||
|
||||
item_details = get_item_details(item, project)
|
||||
|
||||
if frappe.db.get_value("Item", item, "variant_of"):
|
||||
if variant_bom := frappe.db.get_value(
|
||||
"BOM",
|
||||
{"item": item, "is_default": 1, "docstatus": 1},
|
||||
):
|
||||
bom_no = variant_bom
|
||||
|
||||
wo_doc = frappe.new_doc("Work Order")
|
||||
wo_doc.production_item = item
|
||||
wo_doc.update(item_details)
|
||||
@@ -1502,7 +1509,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
stock_entry.get_items(qty, work_order.production_item)
|
||||
stock_entry.get_items()
|
||||
|
||||
if purpose != "Disassemble":
|
||||
stock_entry.set_serial_no_batch_for_finished_good()
|
||||
|
||||
@@ -426,4 +426,4 @@ erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", 1)
|
||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter
|
||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11
|
||||
|
||||
@@ -2,8 +2,13 @@ import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
settings_meta = frappe.get_meta("Currency Exchange Settings")
|
||||
settings = frappe.get_doc("Currency Exchange Settings")
|
||||
if settings.service_provider != "frankfurter.app":
|
||||
|
||||
if (
|
||||
"frankfurter.dev" not in settings_meta.get_options("service_provider").split("\n")
|
||||
or settings.service_provider != "frankfurter.app"
|
||||
):
|
||||
return
|
||||
|
||||
settings.service_provider = "frankfurter.dev"
|
||||
|
||||
@@ -75,13 +75,27 @@ def get_chart_data(data):
|
||||
delay = delay + 1
|
||||
else:
|
||||
on_track = on_track + 1
|
||||
|
||||
labels = []
|
||||
datasets = []
|
||||
colors = []
|
||||
|
||||
if on_track:
|
||||
labels.append(_("On Track"))
|
||||
datasets.append(on_track)
|
||||
colors.append("#84D5BA")
|
||||
if delay:
|
||||
labels.append(_("Delayed"))
|
||||
datasets.append(delay)
|
||||
colors.append("#CB4B5F")
|
||||
|
||||
charts = {
|
||||
"data": {
|
||||
"labels": [_("On Track"), _("Delayed")],
|
||||
"datasets": [{"name": _("Delayed"), "values": [on_track, delay]}],
|
||||
"labels": labels,
|
||||
"datasets": [{"name": _("Delayed"), "values": datasets}],
|
||||
},
|
||||
"type": "percentage",
|
||||
"colors": ["#84D5BA", "#CB4B5F"],
|
||||
"colors": colors,
|
||||
}
|
||||
return charts
|
||||
|
||||
|
||||
@@ -343,6 +343,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
|
||||
calculate_taxes() {
|
||||
// reset value from earlier calculations
|
||||
this.grand_total_diff = 0;
|
||||
|
||||
const doc = this.frm.doc;
|
||||
if (!doc.taxes?.length) return;
|
||||
|
||||
@@ -578,6 +581,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
if ( diff && Math.abs(diff) <= (5.0 / Math.pow(10, precision("tax_amount", last_tax))) ) {
|
||||
me.grand_total_diff = diff;
|
||||
} else {
|
||||
me.grand_total_diff = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,7 +592,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
// Changing sequence can cause rounding_adjustmentng issue and on-screen discrepency
|
||||
const me = this;
|
||||
const tax_count = this.frm.doc.taxes?.length;
|
||||
const grand_total_diff = this.grand_total_diff || 0;
|
||||
const grand_total_diff = this.grand_total_diff;
|
||||
|
||||
this.frm.doc.grand_total = flt(tax_count
|
||||
? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff
|
||||
|
||||
@@ -1093,6 +1093,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
this.frm.refresh_field("payment_schedule");
|
||||
}
|
||||
|
||||
cost_center(doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center");
|
||||
}
|
||||
|
||||
due_date(doc, cdt, cdn) {
|
||||
// due_date is to be changed, payment terms template and/or payment schedule must
|
||||
// be removed as due_date is automatically changed based on payment terms
|
||||
|
||||
@@ -1408,7 +1408,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_map": {"dispatch_address_name": "dispatch_address"},
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
@@ -1417,6 +1416,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
"contact_person",
|
||||
"taxes_and_charges",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
},
|
||||
@@ -1549,7 +1549,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_map": {"dispatch_address_name": "dispatch_address"},
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
@@ -1558,6 +1557,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
"contact_person",
|
||||
"taxes_and_charges",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
},
|
||||
|
||||
@@ -11,7 +11,13 @@ erpnext.setup.EmployeeController = class EmployeeController extends frappe.ui.fo
|
||||
};
|
||||
};
|
||||
this.frm.fields_dict.reports_to.get_query = function (doc, cdt, cdn) {
|
||||
return { query: "erpnext.controllers.queries.employee_query" };
|
||||
return {
|
||||
query: "erpnext.controllers.queries.employee_query",
|
||||
filters: [
|
||||
["status", "=", "Active"],
|
||||
["name", "!=", doc.name],
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -347,8 +347,9 @@ class TransactionDeletionRecord(Document):
|
||||
self.db_set("error_log", None)
|
||||
|
||||
def get_doctypes_to_be_ignored_list(self):
|
||||
singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name")
|
||||
doctypes_to_be_ignored_list = singles
|
||||
doctypes_to_be_ignored_list = frappe.get_all(
|
||||
"DocType", or_filters=[["issingle", "=", 1], ["is_virtual", "=", 1]], pluck="name"
|
||||
)
|
||||
for doctype in self.doctypes_to_be_ignored:
|
||||
doctypes_to_be_ignored_list.append(doctype.doctype_name)
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
|
||||
import click
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
|
||||
from frappe.utils import cint
|
||||
|
||||
import erpnext
|
||||
from erpnext.setup.default_energy_point_rules import get_default_energy_point_rules
|
||||
from erpnext.setup.doctype.incoterm.incoterm import create_incoterms
|
||||
from erpnext.setup.utils import identity as _
|
||||
|
||||
from .default_success_action import get_default_success_action
|
||||
|
||||
@@ -193,28 +192,27 @@ def add_company_to_session_defaults():
|
||||
|
||||
def add_standard_navbar_items():
|
||||
navbar_settings = frappe.get_single("Navbar Settings")
|
||||
|
||||
erpnext_navbar_items = [
|
||||
{
|
||||
"item_label": "Documentation",
|
||||
"item_label": _("Documentation"),
|
||||
"item_type": "Route",
|
||||
"route": "https://docs.erpnext.com/",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "User Forum",
|
||||
"item_label": _("User Forum"),
|
||||
"item_type": "Route",
|
||||
"route": "https://discuss.frappe.io",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "Frappe School",
|
||||
"item_label": _("Frappe School"),
|
||||
"item_type": "Route",
|
||||
"route": "https://frappe.io/school?utm_source=in_app",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "Report an Issue",
|
||||
"item_label": _("Report an Issue"),
|
||||
"item_type": "Route",
|
||||
"route": "https://github.com/frappe/erpnext/issues",
|
||||
"is_standard": 1,
|
||||
|
||||
@@ -234,3 +234,15 @@ def welcome_email():
|
||||
site_name = get_default_company() or "ERPNext"
|
||||
title = _("Welcome to {0}").format(site_name)
|
||||
return title
|
||||
|
||||
|
||||
def identity(x, *args, **kwargs):
|
||||
"""Used for redefining the translation function to return the string as is.
|
||||
|
||||
We want to create english records but still mark the strings as translatable.
|
||||
E.g. when the respective DocTypes have 'Translate Link Fields' enabled or
|
||||
we're creating custom fields.
|
||||
|
||||
Use like this: `from erpnext.setup.utils import identity as _`
|
||||
"""
|
||||
return x
|
||||
|
||||
@@ -50,7 +50,7 @@ def boot_session(bootinfo):
|
||||
|
||||
bootinfo.docs += frappe.db.sql(
|
||||
"""select name, default_currency, cost_center, default_selling_terms, default_buying_terms,
|
||||
default_letter_head, default_bank_account, enable_perpetual_inventory, country from `tabCompany`""",
|
||||
default_letter_head, default_bank_account, enable_perpetual_inventory, country, exchange_gain_loss_account from `tabCompany`""",
|
||||
as_dict=1,
|
||||
update={"doctype": ":Company"},
|
||||
)
|
||||
|
||||
@@ -166,7 +166,9 @@ class Batch(Document):
|
||||
for row in batches:
|
||||
batch_qty += row.get("qty")
|
||||
|
||||
self.db_set("batch_qty", batch_qty)
|
||||
if self.batch_qty != batch_qty:
|
||||
self.db_set("batch_qty", batch_qty)
|
||||
|
||||
frappe.msgprint(_("Batch Qty updated to {0}").format(batch_qty), alert=True)
|
||||
|
||||
def set_batchwise_valuation(self):
|
||||
|
||||
@@ -130,10 +130,6 @@ frappe.ui.form.on("Delivery Note Item", {
|
||||
var d = locals[dt][dn];
|
||||
frm.update_in_all_rows("items", "expense_account", d.expense_account);
|
||||
},
|
||||
cost_center: function (frm, dt, dn) {
|
||||
var d = locals[dt][dn];
|
||||
frm.update_in_all_rows("items", "cost_center", d.cost_center);
|
||||
},
|
||||
});
|
||||
|
||||
erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
|
||||
|
||||
@@ -1091,7 +1091,7 @@
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "status",
|
||||
"oldfieldtype": "Select",
|
||||
"options": "\nDraft\nTo Bill\nCompleted\nReturn Issued\nCancelled\nClosed",
|
||||
"options": "\nDraft\nTo Bill\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed",
|
||||
"print_hide": 1,
|
||||
"print_width": "150px",
|
||||
"read_only": 1,
|
||||
@@ -1404,7 +1404,7 @@
|
||||
"idx": 146,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-04 19:20:47.724218",
|
||||
"modified": "2025-12-02 23:55:25.415443",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note",
|
||||
|
||||
@@ -126,7 +126,9 @@ class DeliveryNote(SellingController):
|
||||
shipping_address_name: DF.Link | None
|
||||
shipping_rule: DF.Link | None
|
||||
source: DF.Link | None
|
||||
status: DF.Literal["", "Draft", "To Bill", "Completed", "Return Issued", "Cancelled", "Closed"]
|
||||
status: DF.Literal[
|
||||
"", "Draft", "To Bill", "Completed", "Return", "Return Issued", "Cancelled", "Closed"
|
||||
]
|
||||
tax_category: DF.Link | None
|
||||
tax_id: DF.Data | None
|
||||
taxes: DF.Table[SalesTaxesandCharges]
|
||||
|
||||
@@ -12,8 +12,8 @@ frappe.listview_settings["Delivery Note"] = {
|
||||
"currency",
|
||||
],
|
||||
get_indicator: function (doc) {
|
||||
if (cint(doc.is_return) == 1) {
|
||||
return [__("Return"), "gray", "is_return,=,Yes"];
|
||||
if (cint(doc.is_return) == 1 && doc.status == "Return") {
|
||||
return [__("Return"), "gray", "is_return,=,1"];
|
||||
} else if (doc.status === "Closed") {
|
||||
return [__("Closed"), "green", "status,=,Closed"];
|
||||
} else if (doc.status === "Return Issued") {
|
||||
|
||||
@@ -2581,6 +2581,7 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.submit()
|
||||
self.assertEqual(dn.per_billed, 0)
|
||||
self.assertEqual(dn.status, "To Bill")
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.location = "Test Location"
|
||||
@@ -2595,6 +2596,7 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
dn.load_from_db()
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.per_returned, 100)
|
||||
self.assertEqual(returned.status, "Return")
|
||||
|
||||
def test_sales_return_for_product_bundle(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
@@ -504,6 +504,61 @@ class TestInventoryDimension(FrappeTestCase):
|
||||
|
||||
self.assertEqual(site_name, "Site 1")
|
||||
|
||||
def test_validate_negative_stock_with_multiple_dimension(self):
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0)
|
||||
item_code = "Test Negative Multi Inventory Dimension Item"
|
||||
create_item(item_code)
|
||||
|
||||
inv_dimension_1 = create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Inv Site",
|
||||
reference_document="Inv Site",
|
||||
document_type="Inv Site",
|
||||
validate_negative_stock=1,
|
||||
)
|
||||
inv_dimension_1.db_set("validate_negative_stock", 1)
|
||||
|
||||
inv_dimension_2 = create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Rack",
|
||||
reference_document="Rack",
|
||||
document_type="Rack",
|
||||
validate_negative_stock=1,
|
||||
)
|
||||
inv_dimension_2.db_set("validate_negative_stock", 1)
|
||||
frappe.local.inventory_dimensions = {}
|
||||
frappe.local.document_wise_inventory_dimensions = {}
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=30, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 1"
|
||||
pr_doc.items[0].rack = "Rack 1"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=15, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 1"
|
||||
pr_doc.items[0].rack = "Rack 2"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=30, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 2"
|
||||
pr_doc.items[0].rack = "Rack 1"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=25, do_not_submit=True)
|
||||
pr_doc.items[0].inv_site = "Site 2"
|
||||
pr_doc.items[0].rack = "Rack 2"
|
||||
pr_doc.save()
|
||||
pr_doc.submit()
|
||||
|
||||
dn_doc = create_delivery_note(item_code=item_code, qty=35, do_not_submit=True)
|
||||
dn_doc.items[0].inv_site = "Site 2"
|
||||
dn_doc.items[0].rack = "Rack 1"
|
||||
dn_doc.save()
|
||||
self.assertRaises(InventoryDimensionNegativeStockError, dn_doc.submit)
|
||||
|
||||
|
||||
def get_voucher_sl_entries(voucher_no, fields):
|
||||
return frappe.get_all(
|
||||
@@ -593,7 +648,7 @@ def prepare_test_data():
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
for rack in ["Rack 1"]:
|
||||
for rack in ["Rack 1", "Rack 2"]:
|
||||
if not frappe.db.exists("Rack", rack):
|
||||
frappe.get_doc({"doctype": "Rack", "rack_name": rack}).insert(ignore_permissions=True)
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ frappe.ui.form.on("Item", {
|
||||
["is_stock_item", "has_serial_no", "has_batch_no", "has_variants"].forEach((fieldname) => {
|
||||
frm.set_df_property(fieldname, "read_only", stock_exists);
|
||||
});
|
||||
|
||||
frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0);
|
||||
frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0);
|
||||
frm.set_query("item_group", () => {
|
||||
return {
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"in_preview": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Item Group",
|
||||
"link_filters": "[[\"Item Group\",\"is_group\",\"=\",0]]",
|
||||
"oldfieldname": "item_group",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Item Group",
|
||||
@@ -243,8 +244,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "is_fixed_asset",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Fixed Asset",
|
||||
"set_only_once": 1
|
||||
"label": "Is Fixed Asset"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
@@ -895,7 +895,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-08-08 14:58:48.674193",
|
||||
"modified": "2025-12-15 20:08:35.634046",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -155,6 +155,7 @@ class Item(Document):
|
||||
self.set_onload("stock_exists", self.stock_ledger_created())
|
||||
self.set_onload("asset_naming_series", get_asset_naming_series())
|
||||
self.set_onload("current_valuation_method", get_valuation_method(self.name))
|
||||
self.set_onload("asset_exists", self.has_submitted_assets())
|
||||
|
||||
def autoname(self):
|
||||
if frappe.db.get_default("item_naming_by") == "Naming Series":
|
||||
@@ -306,9 +307,8 @@ class Item(Document):
|
||||
if self.stock_ledger_created():
|
||||
frappe.throw(_("Cannot be a fixed asset item as Stock Ledger is created."))
|
||||
|
||||
if not self.is_fixed_asset:
|
||||
asset = frappe.db.get_all("Asset", filters={"item_code": self.name, "docstatus": 1}, limit=1)
|
||||
if asset:
|
||||
if not self.is_fixed_asset and not self.is_new():
|
||||
if self.has_submitted_assets():
|
||||
frappe.throw(
|
||||
_('"Is Fixed Asset" cannot be unchecked, as Asset record exists against the item')
|
||||
)
|
||||
@@ -525,6 +525,9 @@ class Item(Document):
|
||||
)
|
||||
return self._stock_ledger_created
|
||||
|
||||
def has_submitted_assets(self):
|
||||
return bool(frappe.db.exists("Asset", {"item_code": self.name, "docstatus": 1}))
|
||||
|
||||
def update_item_price(self):
|
||||
frappe.db.sql(
|
||||
"""
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.stock.doctype.material_request.material_request import (
|
||||
make_supplier_quotation,
|
||||
raise_work_orders,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import make_stock_in_entry
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
|
||||
@@ -926,6 +927,49 @@ class TestMaterialRequest(FrappeTestCase):
|
||||
pl_for_pending = create_pick_list(mr.name)
|
||||
self.assertEqual(pl_for_pending.locations[0].qty, 5)
|
||||
|
||||
def test_mr_status_with_partial_and_excess_end_transit(self):
|
||||
material_request = make_material_request(
|
||||
material_request_type="Material Transfer",
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
)
|
||||
|
||||
in_transit_wh = get_in_transit_warehouse(material_request.company)
|
||||
|
||||
# Make sure stock is available in source warehouse
|
||||
self._insert_stock_entry(20.0, 20.0)
|
||||
|
||||
# Stock Entry (Transfer to In-Transit)
|
||||
stock_entry_1 = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
stock_entry_1.items[0].update(
|
||||
{
|
||||
"qty": 5,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
stock_entry_1.save().submit()
|
||||
|
||||
stock_entry_2 = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
stock_entry_2.items[0].update(
|
||||
{
|
||||
"qty": 5,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
stock_entry_2.save().submit()
|
||||
|
||||
end_transit_1 = make_stock_in_entry(stock_entry_1.name)
|
||||
end_transit_1.save().submit()
|
||||
|
||||
# Material Request Transfer Status should still be In Transit
|
||||
material_request.load_from_db()
|
||||
self.assertEqual(material_request.transfer_status, "In Transit")
|
||||
|
||||
end_transit_2 = make_stock_in_entry(stock_entry_2.name)
|
||||
end_transit_2.items[0].update({"qty": 6}) # More than transferred
|
||||
end_transit_2.save()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
|
||||
|
||||
|
||||
def get_in_transit_warehouse(company):
|
||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||
|
||||
@@ -743,7 +743,10 @@ class PickList(TransactionBase):
|
||||
pi_item.serial_no,
|
||||
(
|
||||
Case()
|
||||
.when((pi_item.picked_qty > 0) & (pi_item.docstatus == 1), pi_item.picked_qty)
|
||||
.when(
|
||||
(pi_item.picked_qty > 0) & (pi_item.docstatus == 1),
|
||||
pi_item.picked_qty - pi_item.delivered_qty,
|
||||
)
|
||||
.else_(pi_item.stock_qty)
|
||||
).as_("picked_qty"),
|
||||
)
|
||||
|
||||
@@ -645,6 +645,46 @@ class TestPickList(FrappeTestCase):
|
||||
if dn_item.item_code == "_Test Item 2":
|
||||
self.assertEqual(dn_item.qty, 2)
|
||||
|
||||
def test_picklist_reserved_qty_validation(self):
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
test_stock_item = "_Test Stock Item"
|
||||
|
||||
# Ensure stock item exists
|
||||
if not frappe.db.exists("Item", test_stock_item):
|
||||
create_item(
|
||||
item_code=test_stock_item,
|
||||
is_stock_item=1,
|
||||
)
|
||||
|
||||
# Add initial stock qty
|
||||
make_stock_entry(item_code=test_stock_item, to_warehouse=warehouse, qty=15)
|
||||
|
||||
# Create SO for 10 qty
|
||||
sales_order_1 = make_sales_order(item_code=test_stock_item, warehouse=warehouse, qty=10)
|
||||
|
||||
# Create and Submit picklist for SO
|
||||
picklist_1 = create_pick_list(sales_order_1.name)
|
||||
picklist_1.submit()
|
||||
|
||||
# Create DN for 5 qty
|
||||
dn = create_delivery_note(picklist_1.name)
|
||||
dn.items[0].qty = 5
|
||||
dn.save()
|
||||
dn.submit()
|
||||
|
||||
# Verify partly delivered state
|
||||
picklist_1.reload()
|
||||
self.assertEqual(picklist_1.status, "Partly Delivered")
|
||||
|
||||
# Create another SO (10 qty)
|
||||
sales_order_2 = make_sales_order(item_code=test_stock_item, warehouse=warehouse, qty=10)
|
||||
|
||||
# Expected pick qty = 5
|
||||
picklist_2 = create_pick_list(sales_order_2.name)
|
||||
self.assertEqual(picklist_2.locations[0].qty, 5)
|
||||
|
||||
def test_picklist_with_multi_uom(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder.functions import CombineDatetime
|
||||
from frappe.query_builder.functions import Abs, CombineDatetime, Sum
|
||||
from frappe.utils import cint, flt, get_datetime, getdate, nowdate
|
||||
from pypika import functions as fn
|
||||
|
||||
@@ -19,6 +19,11 @@ from erpnext.buying.utils import check_on_hold_or_closed_status
|
||||
from erpnext.controllers.accounts_controller import merge_taxes
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction
|
||||
from erpnext.stock.serial_batch_bundle import (
|
||||
SerialBatchCreation,
|
||||
get_batches_from_bundle,
|
||||
get_serial_nos_from_bundle,
|
||||
)
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -1367,21 +1372,26 @@ def get_invoiced_qty_map(purchase_receipt):
|
||||
|
||||
|
||||
def get_returned_qty_map(purchase_receipt):
|
||||
"""returns a map: {so_detail: returned_qty}"""
|
||||
returned_qty_map = frappe._dict(
|
||||
frappe.db.sql(
|
||||
"""select pr_item.purchase_receipt_item, abs(pr_item.qty) as qty
|
||||
from `tabPurchase Receipt Item` pr_item, `tabPurchase Receipt` pr
|
||||
where pr.name = pr_item.parent
|
||||
and pr.docstatus = 1
|
||||
and pr.is_return = 1
|
||||
and pr.return_against = %s
|
||||
""",
|
||||
purchase_receipt,
|
||||
)
|
||||
)
|
||||
"""returns a map: {pr_detail: returned_qty}"""
|
||||
|
||||
return returned_qty_map
|
||||
pr = frappe.qb.DocType("Purchase Receipt")
|
||||
pr_item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(pr)
|
||||
.inner_join(pr_item)
|
||||
.on(pr.name == pr_item.parent)
|
||||
.select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty"))
|
||||
.where(
|
||||
(pr.docstatus == 1)
|
||||
& (pr.is_return == 1)
|
||||
& (pr.return_against == purchase_receipt)
|
||||
& (pr_item.purchase_receipt_item.isnotnull())
|
||||
)
|
||||
.groupby(pr_item.purchase_receipt_item)
|
||||
).run(as_list=1)
|
||||
|
||||
return frappe._dict(query) if query else frappe._dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1411,6 +1421,35 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
target.purpose = "Material Transfer"
|
||||
target.set_missing_values()
|
||||
|
||||
def update_item(source_doc, target_doc, source_parent):
|
||||
if source_doc.serial_and_batch_bundle:
|
||||
serial_nos = get_serial_nos_from_bundle(source_doc.serial_and_batch_bundle)
|
||||
if serial_nos:
|
||||
serial_nos = "\n".join(serial_nos)
|
||||
|
||||
batches = get_batches_from_bundle(source_doc.serial_and_batch_bundle)
|
||||
if batches:
|
||||
if len(batches) == 1:
|
||||
target_doc.use_serial_batch_fields = 1
|
||||
target_doc.batch_no = next(iter(batches))
|
||||
elif not serial_nos:
|
||||
cls_obj = SerialBatchCreation(
|
||||
{
|
||||
"type_of_transaction": "Outward",
|
||||
"serial_and_batch_bundle": source_doc.serial_and_batch_bundle,
|
||||
"item_code": source_doc.item_code,
|
||||
"warehouse": source_doc.warehouse,
|
||||
}
|
||||
)
|
||||
|
||||
cls_obj.duplicate_package()
|
||||
|
||||
target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle
|
||||
|
||||
if serial_nos:
|
||||
target_doc.use_serial_batch_fields = 1
|
||||
target_doc.serial_no = serial_nos
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Purchase Receipt",
|
||||
source_name,
|
||||
@@ -1425,6 +1464,7 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
"parent": "reference_purchase_receipt",
|
||||
"batch_no": "batch_no",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.listview_settings["Purchase Receipt"] = {
|
||||
],
|
||||
get_indicator: function (doc) {
|
||||
if (cint(doc.is_return) == 1 && doc.status == "Return") {
|
||||
return [__("Return"), "gray", "is_return,=,Yes"];
|
||||
return [__("Return"), "gray", "is_return,=,1"];
|
||||
} else if (doc.status === "Closed") {
|
||||
return [__("Closed"), "green", "status,=,Closed"];
|
||||
} else if (flt(doc.per_returned, 2) === 100) {
|
||||
|
||||
@@ -4448,6 +4448,87 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
self.assertEqual(srbnb_cost, 1000)
|
||||
|
||||
def test_lcv_for_repack_entry(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
create_landed_cost_voucher,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
for item in [
|
||||
"Potatoes Raw Material Item",
|
||||
"Fries Finished Goods Item",
|
||||
]:
|
||||
create_item(item)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code="Potatoes Raw Material Item",
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
qty=100,
|
||||
rate=50,
|
||||
)
|
||||
|
||||
wh1 = create_warehouse("WH A1", company=pr.company)
|
||||
wh2 = create_warehouse("WH A2", company=pr.company)
|
||||
|
||||
ste = make_stock_entry(
|
||||
purpose="Repack",
|
||||
source="_Test Warehouse - _TC",
|
||||
item_code="Potatoes Raw Material Item",
|
||||
qty=100,
|
||||
company=pr.company,
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
ste.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "Fries Finished Goods Item",
|
||||
"qty": 50,
|
||||
"t_warehouse": wh1,
|
||||
},
|
||||
)
|
||||
|
||||
ste.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "Fries Finished Goods Item",
|
||||
"qty": 50,
|
||||
"t_warehouse": wh2,
|
||||
},
|
||||
)
|
||||
|
||||
ste.insert()
|
||||
ste.submit()
|
||||
ste.reload()
|
||||
|
||||
for row in ste.items:
|
||||
if row.t_warehouse:
|
||||
self.assertEqual(row.valuation_rate, 50)
|
||||
|
||||
sles = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_type": ste.doctype, "voucher_no": ste.name, "actual_qty": (">", 0)},
|
||||
pluck="stock_value_difference",
|
||||
)
|
||||
|
||||
self.assertEqual(sles, [2500.0, 2500.0])
|
||||
|
||||
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=2000 * -1)
|
||||
|
||||
ste.reload()
|
||||
|
||||
for row in ste.items:
|
||||
if row.t_warehouse:
|
||||
self.assertEqual(row.valuation_rate, 30)
|
||||
|
||||
sles = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_type": ste.doctype, "voucher_no": ste.name, "actual_qty": (">", 0)},
|
||||
pluck="stock_value_difference",
|
||||
)
|
||||
|
||||
self.assertEqual(sles, [1500.0, 1500.0])
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr, floor, flt, nowdate
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
|
||||
|
||||
@@ -119,13 +120,19 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
item = frappe._dict(item)
|
||||
|
||||
source_warehouse = item.get("s_warehouse")
|
||||
serial_nos = []
|
||||
if item.get("serial_no"):
|
||||
serial_nos = get_serial_nos(item.get("serial_no"))
|
||||
|
||||
item.conversion_factor = flt(item.conversion_factor) or 1.0
|
||||
pending_qty, item_code = flt(item.qty), item.item_code
|
||||
pending_stock_qty = flt(item.transfer_qty) if doctype == "Stock Entry" else flt(item.stock_qty)
|
||||
uom_must_be_whole_number = frappe.db.get_value("UOM", item.uom, "must_be_whole_number")
|
||||
|
||||
if not pending_qty or not item_code:
|
||||
updated_table = add_row(item, pending_qty, source_warehouse or item.warehouse, updated_table)
|
||||
updated_table = add_row(
|
||||
item, pending_qty, source_warehouse or item.warehouse, updated_table, serial_nos=serial_nos
|
||||
)
|
||||
continue
|
||||
|
||||
at_capacity, rules = get_ordered_putaway_rules(item_code, company, source_warehouse=source_warehouse)
|
||||
@@ -141,7 +148,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
# rules available, but no free space
|
||||
items_not_accomodated.append([item_code, pending_qty])
|
||||
else:
|
||||
updated_table = add_row(item, pending_qty, warehouse, updated_table)
|
||||
updated_table = add_row(item, pending_qty, warehouse, updated_table, serial_nos=serial_nos)
|
||||
continue
|
||||
|
||||
# maintain item/item-warehouse wise rules, to handle if item is entered twice
|
||||
@@ -167,7 +174,9 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
|
||||
if not qty_to_allocate:
|
||||
break
|
||||
|
||||
updated_table = add_row(item, qty_to_allocate, rule.warehouse, updated_table, rule.name)
|
||||
updated_table = add_row(
|
||||
item, qty_to_allocate, rule.warehouse, updated_table, rule.name, serial_nos=serial_nos
|
||||
)
|
||||
|
||||
pending_stock_qty -= stock_qty_to_allocate
|
||||
pending_qty -= qty_to_allocate
|
||||
@@ -265,7 +274,7 @@ def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
|
||||
return False, vacant_rules
|
||||
|
||||
|
||||
def add_row(item, to_allocate, warehouse, updated_table, rule=None):
|
||||
def add_row(item, to_allocate, warehouse, updated_table, rule=None, serial_nos=None):
|
||||
new_updated_table_row = copy.deepcopy(item)
|
||||
new_updated_table_row.idx = 1 if not updated_table else cint(updated_table[-1].idx) + 1
|
||||
new_updated_table_row.name = None
|
||||
@@ -283,6 +292,9 @@ def add_row(item, to_allocate, warehouse, updated_table, rule=None):
|
||||
if rule:
|
||||
new_updated_table_row.putaway_rule = rule
|
||||
|
||||
if serial_nos:
|
||||
new_updated_table_row.serial_no = get_serial_nos_to_allocate(serial_nos, to_allocate)
|
||||
|
||||
new_updated_table_row.serial_and_batch_bundle = ""
|
||||
|
||||
updated_table.append(new_updated_table_row)
|
||||
@@ -311,3 +323,12 @@ def show_unassigned_items_message(items_not_accomodated):
|
||||
""".format(_("Item"), _("Unassigned Qty"), formatted_item_rows)
|
||||
|
||||
frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True)
|
||||
|
||||
|
||||
def get_serial_nos_to_allocate(serial_nos, to_allocate):
|
||||
if serial_nos:
|
||||
allocated_serial_nos = serial_nos[0 : cint(to_allocate)]
|
||||
serial_nos[:] = serial_nos[cint(to_allocate) :] # pop out allocated serial nos and modify list
|
||||
return "\n".join(allocated_serial_nos) if allocated_serial_nos else ""
|
||||
else:
|
||||
return ""
|
||||
|
||||
@@ -9,7 +9,7 @@ frappe.ui.form.on("Quality Inspection", {
|
||||
},
|
||||
|
||||
set_default_company(frm) {
|
||||
if (!frm.doc.company) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.company) {
|
||||
frm.set_value("company", frappe.defaults.get_default("company"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2008,6 +2008,8 @@ def get_available_serial_nos(kwargs):
|
||||
filters["name"] = ("in", time_based_serial_nos)
|
||||
elif ignore_serial_nos:
|
||||
filters["name"] = ("not in", ignore_serial_nos)
|
||||
elif kwargs.get("serial_nos"):
|
||||
filters["name"] = ("in", kwargs.get("serial_nos"))
|
||||
|
||||
if kwargs.get("batches"):
|
||||
batches = get_non_expired_batches(kwargs.get("batches"))
|
||||
@@ -2532,6 +2534,9 @@ def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> dict[str, dict]:
|
||||
child_row = group_by_voucher[key]
|
||||
if row.serial_no:
|
||||
child_row["serial_nos"].append(row.serial_no)
|
||||
child_row["item_row"].qty = len(child_row["serial_nos"]) * (
|
||||
-1 if row.type_of_transaction == "Outward" else 1
|
||||
)
|
||||
|
||||
if row.batch_no:
|
||||
child_row["batch_nos"][row.batch_no] += row.qty
|
||||
|
||||
@@ -571,6 +571,14 @@ frappe.ui.form.on("Stock Entry", {
|
||||
}
|
||||
},
|
||||
|
||||
set_rate_and_fg_qty: function (frm, cdt, cdn) {
|
||||
frm.events.set_basic_rate(frm, cdt, cdn);
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
if (item.is_finished_item) {
|
||||
frm.events.set_fg_completed_qty(frm);
|
||||
}
|
||||
},
|
||||
|
||||
get_warehouse_details: function (frm, cdt, cdn) {
|
||||
var child = locals[cdt][cdn];
|
||||
if (!child.bom_no) {
|
||||
@@ -833,7 +841,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
|
||||
frm.doc.items.forEach((item) => {
|
||||
if (item.is_finished_item) {
|
||||
fg_completed_qty += flt(item.qty);
|
||||
fg_completed_qty += flt(item.transfer_qty);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -859,15 +867,11 @@ frappe.ui.form.on("Stock Entry Detail", {
|
||||
},
|
||||
|
||||
qty(frm, cdt, cdn) {
|
||||
frm.events.set_basic_rate(frm, cdt, cdn);
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
if (item.is_finished_item) {
|
||||
frm.events.set_fg_completed_qty(frm);
|
||||
}
|
||||
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
|
||||
},
|
||||
|
||||
conversion_factor(frm, cdt, cdn) {
|
||||
frm.events.set_basic_rate(frm, cdt, cdn);
|
||||
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
|
||||
},
|
||||
|
||||
s_warehouse(frm, cdt, cdn) {
|
||||
|
||||
@@ -8,6 +8,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
@@ -243,7 +244,66 @@ class StockEntry(StockController):
|
||||
|
||||
self.validate_same_source_target_warehouse_during_material_transfer()
|
||||
|
||||
def set_serial_batch_for_disassembly(self):
|
||||
if self.purpose != "Disassemble":
|
||||
return
|
||||
|
||||
available_materials = get_available_materials(self.work_order, self)
|
||||
for row in self.items:
|
||||
warehouse = row.s_warehouse or row.t_warehouse
|
||||
materials = available_materials.get((row.item_code, warehouse))
|
||||
if not materials:
|
||||
continue
|
||||
|
||||
batches = defaultdict(float)
|
||||
serial_nos = []
|
||||
qty = row.transfer_qty
|
||||
for batch_no, batch_qty in materials.batch_details.items():
|
||||
if qty <= 0:
|
||||
break
|
||||
|
||||
batch_qty = abs(batch_qty)
|
||||
if batch_qty <= qty:
|
||||
batches[batch_no] = batch_qty
|
||||
qty -= batch_qty
|
||||
else:
|
||||
batches[batch_no] = qty
|
||||
qty = 0
|
||||
|
||||
if materials.serial_nos:
|
||||
serial_nos = materials.serial_nos[: int(row.transfer_qty)]
|
||||
|
||||
if not serial_nos and not batches:
|
||||
continue
|
||||
|
||||
bundle_doc = SerialBatchCreation(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"voucher_detail_no": row.name,
|
||||
"qty": row.transfer_qty,
|
||||
"type_of_transaction": "Inward" if row.t_warehouse else "Outward",
|
||||
"company": self.company,
|
||||
"do_not_submit": True,
|
||||
}
|
||||
).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
|
||||
|
||||
row.serial_and_batch_bundle = bundle_doc.name
|
||||
row.use_serial_batch_fields = 0
|
||||
|
||||
row.db_set(
|
||||
{
|
||||
"serial_and_batch_bundle": bundle_doc.name,
|
||||
"use_serial_batch_fields": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.set_serial_batch_for_disassembly()
|
||||
self.validate_closed_subcontracting_order()
|
||||
self.make_bundle_using_old_serial_batch_fields()
|
||||
self.update_disassembled_order()
|
||||
@@ -1846,7 +1906,7 @@ class StockEntry(StockController):
|
||||
},
|
||||
)
|
||||
|
||||
def get_items_for_disassembly(self, disassemble_qty, production_item):
|
||||
def get_items_for_disassembly(self):
|
||||
"""Get items for Disassembly Order"""
|
||||
|
||||
if not self.work_order:
|
||||
@@ -1856,7 +1916,13 @@ class StockEntry(StockController):
|
||||
|
||||
s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse")
|
||||
|
||||
items_dict = get_bom_items_as_dict(self.bom_no, self.company, disassemble_qty)
|
||||
items_dict = get_bom_items_as_dict(
|
||||
self.bom_no,
|
||||
self.company,
|
||||
self.fg_completed_qty,
|
||||
fetch_exploded=self.use_multi_level_bom,
|
||||
fetch_qty_in_stock_uom=False,
|
||||
)
|
||||
|
||||
for row in items:
|
||||
child_row = self.append("items", {})
|
||||
@@ -1870,11 +1936,11 @@ class StockEntry(StockController):
|
||||
child_row.qty = bom_items.get("qty", child_row.qty)
|
||||
child_row.amount = bom_items.get("amount", child_row.amount)
|
||||
|
||||
if row.item_code == production_item:
|
||||
child_row.qty = disassemble_qty
|
||||
if row.is_finished_item:
|
||||
child_row.qty = self.fg_completed_qty
|
||||
|
||||
child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else ""
|
||||
child_row.t_warehouse = self.to_warehouse if not row.is_finished_item else ""
|
||||
child_row.t_warehouse = row.s_warehouse
|
||||
child_row.is_finished_item = 0 if row.is_finished_item else 1
|
||||
|
||||
def get_items_from_manufacture_entry(self):
|
||||
@@ -1893,6 +1959,8 @@ class StockEntry(StockController):
|
||||
"`tabStock Entry Detail`.`is_finished_item`",
|
||||
"`tabStock Entry Detail`.`batch_no`",
|
||||
"`tabStock Entry Detail`.`serial_no`",
|
||||
"`tabStock Entry Detail`.`s_warehouse`",
|
||||
"`tabStock Entry Detail`.`t_warehouse`",
|
||||
"`tabStock Entry Detail`.`use_serial_batch_fields`",
|
||||
],
|
||||
filters=[
|
||||
@@ -1905,12 +1973,12 @@ class StockEntry(StockController):
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items(self, qty=None, production_item=None):
|
||||
def get_items(self):
|
||||
self.set("items", [])
|
||||
self.validate_work_order()
|
||||
|
||||
if self.purpose == "Disassemble" and qty is not None:
|
||||
return self.get_items_for_disassembly(qty, production_item)
|
||||
if self.purpose == "Disassemble":
|
||||
return self.get_items_for_disassembly()
|
||||
|
||||
if not self.posting_date or not self.posting_time:
|
||||
frappe.throw(_("Posting date and posting time is mandatory"))
|
||||
@@ -2748,6 +2816,17 @@ class StockEntry(StockController):
|
||||
},
|
||||
)
|
||||
|
||||
if d.docstatus == 1:
|
||||
transfer_qty = frappe.get_value("Stock Entry Detail", d.ste_detail, "transfer_qty")
|
||||
|
||||
if transferred_qty and transferred_qty[0]:
|
||||
if transferred_qty[0].qty > transfer_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Transferred quantity cannot be greater than the requested quantity."
|
||||
).format(d.idx)
|
||||
)
|
||||
|
||||
stock_entries[(d.against_stock_entry, d.ste_detail)] = (
|
||||
transferred_qty[0].qty if transferred_qty and transferred_qty[0] else 0.0
|
||||
) or 0.0
|
||||
@@ -2811,7 +2890,7 @@ class StockEntry(StockController):
|
||||
parent_se = frappe.get_value("Stock Entry", self.outgoing_stock_entry, "add_to_transit")
|
||||
|
||||
for item in self.items:
|
||||
material_request = item.material_request or None
|
||||
material_request = item.get("material_request")
|
||||
if self.purpose == "Material Transfer" and material_request not in material_requests:
|
||||
if self.outgoing_stock_entry and parent_se:
|
||||
material_request = frappe.get_value(
|
||||
@@ -2820,6 +2899,11 @@ class StockEntry(StockController):
|
||||
|
||||
if material_request and material_request not in material_requests:
|
||||
material_requests.append(material_request)
|
||||
if status == "Completed":
|
||||
qty = get_transferred_qty(material_request)
|
||||
if qty.get("transfer_qty") > qty.get("transferred_qty"):
|
||||
status = "In Transit"
|
||||
|
||||
frappe.db.set_value("Material Request", material_request, "transfer_status", status)
|
||||
|
||||
def set_serial_no_batch_for_finished_good(self):
|
||||
@@ -3259,8 +3343,8 @@ def get_items_from_subcontract_order(source_name, target_doc=None):
|
||||
return target_doc
|
||||
|
||||
|
||||
def get_available_materials(work_order) -> dict:
|
||||
data = get_stock_entry_data(work_order)
|
||||
def get_available_materials(work_order, stock_entry_doc=None) -> dict:
|
||||
data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc)
|
||||
|
||||
available_materials = {}
|
||||
for row in data:
|
||||
@@ -3268,6 +3352,9 @@ def get_available_materials(work_order) -> dict:
|
||||
if row.purpose != "Material Transfer for Manufacture":
|
||||
key = (row.item_code, row.s_warehouse)
|
||||
|
||||
if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
|
||||
key = (row.item_code, row.s_warehouse or row.warehouse)
|
||||
|
||||
if key not in available_materials:
|
||||
available_materials.setdefault(
|
||||
key,
|
||||
@@ -3278,7 +3365,9 @@ def get_available_materials(work_order) -> dict:
|
||||
|
||||
item_data = available_materials[key]
|
||||
|
||||
if row.purpose == "Material Transfer for Manufacture":
|
||||
if row.purpose == "Material Transfer for Manufacture" or (
|
||||
stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture"
|
||||
):
|
||||
item_data.qty += row.qty
|
||||
if row.batch_no:
|
||||
item_data.batch_details[row.batch_no] += row.qty
|
||||
@@ -3318,7 +3407,7 @@ def get_available_materials(work_order) -> dict:
|
||||
return available_materials
|
||||
|
||||
|
||||
def get_stock_entry_data(work_order):
|
||||
def get_stock_entry_data(work_order, stock_entry_doc=None):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_voucher_wise_serial_batch_from_bundle,
|
||||
)
|
||||
@@ -3350,19 +3439,35 @@ def get_stock_entry_data(work_order):
|
||||
(stock_entry.name == stock_entry_detail.parent)
|
||||
& (stock_entry.work_order == work_order)
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry_detail.s_warehouse.isnotnull())
|
||||
& (
|
||||
stock_entry.purpose.isin(
|
||||
[
|
||||
"Manufacture",
|
||||
"Material Consumption for Manufacture",
|
||||
"Material Transfer for Manufacture",
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx)
|
||||
).run(as_dict=1)
|
||||
)
|
||||
|
||||
if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
|
||||
data = data.where(
|
||||
stock_entry.purpose.isin(
|
||||
[
|
||||
"Disassemble",
|
||||
"Manufacture",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
data = data.where(stock_entry.name != stock_entry_doc.name)
|
||||
else:
|
||||
data = data.where(
|
||||
stock_entry.purpose.isin(
|
||||
[
|
||||
"Manufacture",
|
||||
"Material Consumption for Manufacture",
|
||||
"Material Transfer for Manufacture",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
data = data.where(stock_entry_detail.s_warehouse.isnotnull())
|
||||
|
||||
data = data.run(as_dict=1)
|
||||
|
||||
if not data:
|
||||
return []
|
||||
@@ -3375,6 +3480,9 @@ def get_stock_entry_data(work_order):
|
||||
if row.purpose != "Material Transfer for Manufacture":
|
||||
key = (row.item_code, row.s_warehouse, row.name)
|
||||
|
||||
if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
|
||||
key = (row.item_code, row.s_warehouse or row.warehouse, row.name)
|
||||
|
||||
if bundle_data.get(key):
|
||||
row.update(bundle_data.get(key))
|
||||
|
||||
@@ -3454,3 +3562,18 @@ def get_batchwise_serial_nos(item_code, row):
|
||||
batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos])
|
||||
|
||||
return batchwise_serial_nos
|
||||
|
||||
|
||||
def get_transferred_qty(material_request):
|
||||
sed = DocType("Stock Entry Detail")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sed)
|
||||
.select(
|
||||
Sum(sed.transfer_qty).as_("transfer_qty"),
|
||||
Sum(sed.transferred_qty).as_("transferred_qty"),
|
||||
)
|
||||
.where((sed.material_request == material_request) & (sed.docstatus == 1))
|
||||
).run(as_dict=True)
|
||||
|
||||
return query[0]
|
||||
|
||||
@@ -113,17 +113,15 @@ class StockLedgerEntry(Document):
|
||||
return
|
||||
|
||||
flt_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
for dimension, values in dimensions.items():
|
||||
dimension_value = values.get("value")
|
||||
available_qty = self.get_available_qty_after_prev_transaction(dimension, dimension_value)
|
||||
available_qty = self.get_available_qty_after_prev_transaction(dimensions)
|
||||
|
||||
diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
self.throw_validation_error(diff, dimension, dimension_value)
|
||||
diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
self.throw_validation_error(diff, dimensions)
|
||||
|
||||
def get_available_qty_after_prev_transaction(self, dimension, dimension_value):
|
||||
def get_available_qty_after_prev_transaction(self, dimensions):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
available_qty = (
|
||||
available_qty_query = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.actual_qty))
|
||||
.where(
|
||||
@@ -132,21 +130,27 @@ class StockLedgerEntry(Document):
|
||||
& (sle.posting_datetime < self.posting_datetime)
|
||||
& (sle.company == self.company)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (sle[dimension] == dimension_value)
|
||||
)
|
||||
).run()
|
||||
)
|
||||
|
||||
for dimension, values in dimensions.items():
|
||||
dimension_value = values.get("value")
|
||||
available_qty_query = available_qty_query.where(sle[dimension] == dimension_value)
|
||||
|
||||
available_qty = available_qty_query.run()
|
||||
|
||||
return available_qty[0][0] or 0
|
||||
|
||||
def throw_validation_error(self, diff, dimension, dimension_value):
|
||||
def throw_validation_error(self, diff, dimensions):
|
||||
msg = _(
|
||||
"{0} units of {1} are required in {2} with the inventory dimension: {3} ({4}) on {5} {6} for {7} to complete the transaction."
|
||||
"{0} units of {1} are required in {2} with the inventory dimension: {3} on {4} {5} for {6} to complete the transaction."
|
||||
).format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Item", self.item_code),
|
||||
frappe.get_desk_link("Warehouse", self.warehouse),
|
||||
frappe.bold(dimension),
|
||||
frappe.bold(dimension_value),
|
||||
frappe.bold(
|
||||
", ".join([f"{dimension}: {values.get('value')}" for dimension, values in dimensions.items()])
|
||||
),
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
frappe.get_desk_link(self.voucher_type, self.voucher_no),
|
||||
|
||||
@@ -1227,32 +1227,23 @@ class StockReconciliation(StockController):
|
||||
def get_batch_qty_for_stock_reco(
|
||||
item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation
|
||||
):
|
||||
ledger = frappe.qb.DocType("Stock Ledger Entry")
|
||||
posting_datetime = get_combine_datetime(posting_date, posting_time)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ledger)
|
||||
.select(
|
||||
Sum(ledger.actual_qty).as_("batch_qty"),
|
||||
qty = (
|
||||
get_batch_qty(
|
||||
batch_no,
|
||||
warehouse,
|
||||
item_code,
|
||||
creation=sle_creation,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
ignore_voucher_nos=[voucher_no],
|
||||
for_stock_levels=True,
|
||||
consider_negative_batches=True,
|
||||
do_not_check_future_batches=True,
|
||||
)
|
||||
.where(
|
||||
(ledger.item_code == item_code)
|
||||
& (ledger.warehouse == warehouse)
|
||||
& (ledger.docstatus == 1)
|
||||
& (ledger.is_cancelled == 0)
|
||||
& (ledger.batch_no == batch_no)
|
||||
& (ledger.voucher_no != voucher_no)
|
||||
& (
|
||||
(ledger.posting_datetime < posting_datetime)
|
||||
| ((ledger.posting_datetime == posting_datetime) & (ledger.creation < sle_creation))
|
||||
)
|
||||
)
|
||||
.groupby(ledger.batch_no)
|
||||
or 0
|
||||
)
|
||||
|
||||
sle = query.run(as_dict=True)
|
||||
|
||||
return flt(sle[0].batch_qty) if sle else 0
|
||||
return flt(qty)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -20,6 +20,9 @@ def execute(filters=None):
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
@@ -36,6 +39,9 @@ def get_chart_data(data, filters):
|
||||
data = data[:10]
|
||||
|
||||
for row in data:
|
||||
if row[0] == wrap_in_quotes(_("Total")):
|
||||
continue
|
||||
|
||||
labels.append(row[0])
|
||||
datapoints.append(row[-1])
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ class FIFOSlots:
|
||||
else:
|
||||
serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) or []
|
||||
|
||||
serial_nos = self.uppercase_serial_nos(serial_nos)
|
||||
if d.actual_qty > 0:
|
||||
self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
|
||||
else:
|
||||
@@ -289,6 +290,10 @@ class FIFOSlots:
|
||||
|
||||
return self.item_details
|
||||
|
||||
def uppercase_serial_nos(self, serial_nos):
|
||||
"Convert serial nos to uppercase for uniformity."
|
||||
return [sn.upper() for sn in serial_nos]
|
||||
|
||||
def __init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
|
||||
@@ -647,10 +647,32 @@ class update_entries_after:
|
||||
|
||||
if sle.dependant_sle_voucher_detail_no:
|
||||
entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle)
|
||||
if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no):
|
||||
# for repack entries, we need to repost both source and target warehouses
|
||||
self.update_distinct_item_warehouses_for_repack(sle)
|
||||
|
||||
if self.exceptions:
|
||||
self.raise_exceptions()
|
||||
|
||||
def update_distinct_item_warehouses_for_repack(self, sle):
|
||||
sles = (
|
||||
frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={
|
||||
"voucher_type": "Stock Entry",
|
||||
"voucher_no": sle.voucher_no,
|
||||
"actual_qty": (">", 0),
|
||||
"is_cancelled": 0,
|
||||
"voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no),
|
||||
},
|
||||
fields=["*"],
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
for dependant_sle in sles:
|
||||
self.update_distinct_item_warehouses(dependant_sle)
|
||||
|
||||
def has_stock_reco_with_serial_batch(self, sle):
|
||||
if (
|
||||
sle.voucher_type == "Stock Reconciliation"
|
||||
@@ -696,6 +718,13 @@ class update_entries_after:
|
||||
{"item_code": self.item_code, "warehouse": self.args.warehouse}
|
||||
)
|
||||
|
||||
key = (self.item_code, self.args.warehouse)
|
||||
if key in self.distinct_item_warehouses and self.distinct_item_warehouses[key].get(
|
||||
"transfer_entry_to_repost"
|
||||
):
|
||||
# only repost stock entries
|
||||
args["filter_voucher_type"] = "Stock Entry"
|
||||
|
||||
return list(self.get_sle_after_datetime(args))
|
||||
|
||||
def get_dependent_entries_to_fix(self, entries_to_fix, sle):
|
||||
@@ -729,8 +758,10 @@ class update_entries_after:
|
||||
if getdate(existing_sle.get("posting_date")) > getdate(dependant_sle.posting_date):
|
||||
self.distinct_item_warehouses[key] = val
|
||||
self.new_items_found = True
|
||||
elif dependant_sle.voucher_type == "Stock Entry" and is_transfer_stock_entry(
|
||||
dependant_sle.voucher_no
|
||||
elif (
|
||||
dependant_sle.actual_qty > 0
|
||||
and dependant_sle.voucher_type == "Stock Entry"
|
||||
and is_transfer_stock_entry(dependant_sle.voucher_no)
|
||||
):
|
||||
if self.distinct_item_warehouses[key].get("transfer_entry_to_repost"):
|
||||
return
|
||||
@@ -1156,7 +1187,11 @@ class update_entries_after:
|
||||
|
||||
def get_dynamic_incoming_outgoing_rate(self, sle):
|
||||
# Get updated incoming/outgoing rate from transaction
|
||||
if sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle):
|
||||
if (
|
||||
sle.recalculate_rate
|
||||
or self.has_landed_cost_based_on_pi(sle)
|
||||
or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no))
|
||||
):
|
||||
rate = self.get_incoming_outgoing_rate_from_transaction(sle)
|
||||
|
||||
if flt(sle.actual_qty) >= 0:
|
||||
@@ -1813,6 +1848,9 @@ def get_stock_ledger_entries(
|
||||
if operator in (">", "<=") and previous_sle.get("name"):
|
||||
conditions += " and name!=%(name)s"
|
||||
|
||||
if previous_sle.get("filter_voucher_type"):
|
||||
conditions += " and voucher_type = %(filter_voucher_type)s"
|
||||
|
||||
if extra_cond:
|
||||
conditions += f"{extra_cond}"
|
||||
|
||||
@@ -1908,8 +1946,7 @@ def get_valuation_rate(
|
||||
& (table.warehouse == warehouse)
|
||||
& (table.batch_no == batch_no)
|
||||
& (table.is_cancelled == 0)
|
||||
& (table.voucher_no != voucher_no)
|
||||
& (table.voucher_type != voucher_type)
|
||||
& ((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2454,3 +2491,8 @@ def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj):
|
||||
incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(row.batch_no)))
|
||||
|
||||
return incoming_rate
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def is_repack_entry(stock_entry_id):
|
||||
return frappe.get_cached_value("Stock Entry", stock_entry_id, "purpose") == "Repack"
|
||||
|
||||
@@ -543,6 +543,12 @@ class SubcontractingReceipt(SubcontractingController):
|
||||
|
||||
for row in self.items:
|
||||
precision = row.precision("qty")
|
||||
|
||||
# if allow alternative item, ignore the validation as per BOM required qty
|
||||
is_allow_alternative_item = frappe.db.get_value("BOM", row.bom, "allow_alternative_item")
|
||||
if is_allow_alternative_item:
|
||||
continue
|
||||
|
||||
for bom_item in self._get_materials_from_bom(
|
||||
row.item_code, row.bom, row.get("include_exploded_items")
|
||||
):
|
||||
|
||||
@@ -199,7 +199,7 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord
|
||||
customer = contact_doc.get_link_for("Customer")
|
||||
|
||||
ignore_permissions = False
|
||||
if is_website_user():
|
||||
if is_website_user() and user != "Guest":
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
|
||||
@@ -16,9 +16,30 @@ def transaction_processing(data, from_doctype, to_doctype):
|
||||
else:
|
||||
deserialized_data = data
|
||||
|
||||
skipped_records = [d for d in deserialized_data if d.get("status") in ("On Hold", "Closed")]
|
||||
|
||||
deserialized_data = [d for d in deserialized_data if d.get("status") not in ("On Hold", "Closed")]
|
||||
|
||||
length_of_data = len(deserialized_data)
|
||||
|
||||
frappe.msgprint(_("Started a background job to create {1} {0}").format(to_doctype, length_of_data))
|
||||
skipped_msg = ""
|
||||
|
||||
if skipped_records:
|
||||
skipped_msg = _("{0} creation for the following records will be skipped.").format(to_doctype)
|
||||
|
||||
skipped_msg += (
|
||||
"<br><br><ul>"
|
||||
+ "".join(_("<li>{}</li>").format(frappe.bold(row.get("name"))) for row in skipped_records)
|
||||
+ "</ul>"
|
||||
)
|
||||
|
||||
if not length_of_data:
|
||||
frappe.msgprint(skipped_msg)
|
||||
return
|
||||
|
||||
frappe.msgprint(
|
||||
_("Started a background job to create {1} {0}. {2}").format(to_doctype, length_of_data, skipped_msg)
|
||||
)
|
||||
frappe.enqueue(
|
||||
job,
|
||||
deserialized_data=deserialized_data,
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.ui.form.on("Rename Tool", {
|
||||
onload: function (frm) {
|
||||
return frappe.call({
|
||||
method: "erpnext.utilities.doctype.rename_tool.rename_tool.get_doctypes",
|
||||
callback: function (r) {
|
||||
frm.set_df_property("select_doctype", "options", r.message);
|
||||
},
|
||||
});
|
||||
},
|
||||
refresh: function (frm) {
|
||||
frm.disable_save();
|
||||
|
||||
|
||||
@@ -8,27 +8,13 @@
|
||||
"doctype": "DocType",
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"description": "Type of document to rename.",
|
||||
"fieldname": "select_doctype",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 0,
|
||||
"label": "Select DocType",
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"description": "Type of document to rename.",
|
||||
"fieldname": "select_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Select DocType",
|
||||
"link_filters": "[[\"DocType\",\"allow_rename\",\"=\",1],[\"DocType\",\"module\",\"!=\",\"Core\"]]",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
@@ -72,22 +58,18 @@
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
}
|
||||
],
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 1,
|
||||
"icon": "fa fa-magic",
|
||||
"idx": 1,
|
||||
"in_create": 0,
|
||||
|
||||
"is_submittable": 0,
|
||||
"issingle": 1,
|
||||
"istable": 0,
|
||||
"max_attachments": 1,
|
||||
"modified": "2015-10-19 03:04:49.097140",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Utilities",
|
||||
"name": "Rename Tool",
|
||||
"owner": "Administrator",
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "fa fa-magic",
|
||||
"idx": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2025-12-09 14:18:33.838623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Utilities",
|
||||
"name": "Rename Tool",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"amend": 0,
|
||||
@@ -112,4 +94,4 @@
|
||||
],
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.rename_doc import bulk_rename
|
||||
from frappe.utils.deprecations import deprecated
|
||||
|
||||
|
||||
class RenameTool(Document):
|
||||
@@ -19,13 +20,14 @@ class RenameTool(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
file_to_rename: DF.Attach | None
|
||||
select_doctype: DF.Literal
|
||||
select_doctype: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@deprecated
|
||||
def get_doctypes():
|
||||
return frappe.db.sql_list(
|
||||
"""select name from tabDocType
|
||||
|
||||
Reference in New Issue
Block a user