mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-07 11:43:04 +00:00
Compare commits
220 Commits
v16.29.0
...
l10n_versi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72865d87c | ||
|
|
5dd6c0169b | ||
|
|
aa70d9bbc3 | ||
|
|
88f66af4ea | ||
|
|
da0b874508 | ||
|
|
8b92317eac | ||
|
|
d1e5fa6bb0 | ||
|
|
626e35135f | ||
|
|
0e26f9b1db | ||
|
|
243266f5ef | ||
|
|
af3184c8b4 | ||
|
|
eeb3cd238e | ||
|
|
adfa6768c9 | ||
|
|
970a3f403d | ||
|
|
61154e22ed | ||
|
|
dc907add40 | ||
|
|
b5700831d8 | ||
|
|
a703e7a462 | ||
|
|
abc76eb49d | ||
|
|
37e96f931d | ||
|
|
a9f969e942 | ||
|
|
cd65a6d9ff | ||
|
|
ee6955d56c | ||
|
|
02f407b82a | ||
|
|
281e92fb6e | ||
|
|
285aec3164 | ||
|
|
824ae57e44 | ||
|
|
4193a441e6 | ||
|
|
b4dfca9ef1 | ||
|
|
6153202231 | ||
|
|
4babce436f | ||
|
|
aaa99f775d | ||
|
|
4ed03748fe | ||
|
|
667b012065 | ||
|
|
81e24442e3 | ||
|
|
00139081f6 | ||
|
|
a5544d0bfb | ||
|
|
ca6065398c | ||
|
|
eeab2a833f | ||
|
|
af4aea171b | ||
|
|
a2dfc9e50a | ||
|
|
f2a53247c5 | ||
|
|
aa71cd695b | ||
|
|
697f68d1d2 | ||
|
|
246ffee17c | ||
|
|
e7757f6d0b | ||
|
|
10229700c0 | ||
|
|
6cbf73a326 | ||
|
|
3a0f988a9e | ||
|
|
4713ddd55b | ||
|
|
3f3292ca4a | ||
|
|
656db1c2fe | ||
|
|
444dd9e817 | ||
|
|
fccf1220f6 | ||
|
|
113b5ecaec | ||
|
|
e2466780b9 | ||
|
|
3b7fb6851a | ||
|
|
b0f2704bde | ||
|
|
032b922f0c | ||
|
|
833ccd3358 | ||
|
|
4a5c416ee0 | ||
|
|
4b6a4cc9c5 | ||
|
|
e5f8d0c84b | ||
|
|
7f81502cde | ||
|
|
666b6167a1 | ||
|
|
65a53a7012 | ||
|
|
e98471d9c9 | ||
|
|
187840b559 | ||
|
|
2c9db13041 | ||
|
|
9b647bed5c | ||
|
|
93331a1cf0 | ||
|
|
380ee3b013 | ||
|
|
ea770f6a8e | ||
|
|
06a753faf3 | ||
|
|
e71cef02b3 | ||
|
|
6096e761b0 | ||
|
|
d6ee5436b8 | ||
|
|
810b9ae28f | ||
|
|
6b056ebb36 | ||
|
|
1adeb66bdc | ||
|
|
5aaefec747 | ||
|
|
d2fe4b623c | ||
|
|
d718110216 | ||
|
|
f99d66d578 | ||
|
|
f19aa957cb | ||
|
|
2c8c375ca6 | ||
|
|
f4d3b2771b | ||
|
|
95f7810948 | ||
|
|
5463bd93aa | ||
|
|
e5999b22c7 | ||
|
|
1fbccd9823 | ||
|
|
9457cae327 | ||
|
|
ecccedf0ed | ||
|
|
5633c29223 | ||
|
|
fcbbb251cf | ||
|
|
c7cf9d868b | ||
|
|
99f249b1a0 | ||
|
|
38f7c824f1 | ||
|
|
46317b063a | ||
|
|
2ce88af3c9 | ||
|
|
684ae4d762 | ||
|
|
d09c04a623 | ||
|
|
624a236f88 | ||
|
|
7bc04752fc | ||
|
|
810da8f542 | ||
|
|
283ee6e07b | ||
|
|
e3d8336213 | ||
|
|
5595d1ed2f | ||
|
|
ebba4e9958 | ||
|
|
59bb56aa8d | ||
|
|
eb969a5866 | ||
|
|
9b452f12b7 | ||
|
|
c0ac8aaf86 | ||
|
|
0e46937f60 | ||
|
|
0c0350110e | ||
|
|
a620648471 | ||
|
|
5a75be871a | ||
|
|
3ba83134af | ||
|
|
76fce556c8 | ||
|
|
d104d8e723 | ||
|
|
249acdd7e2 | ||
|
|
17aeb0b55b | ||
|
|
2d03d80269 | ||
|
|
fb6c87dd0c | ||
|
|
34cbd3c8d5 | ||
|
|
9c86f98e5f | ||
|
|
d1d214ddee | ||
|
|
4ffa950aa3 | ||
|
|
43fd439866 | ||
|
|
aa60192ab7 | ||
|
|
b556b012f0 | ||
|
|
49924ddd1d | ||
|
|
1ea1ce15d0 | ||
|
|
04e1ca8226 | ||
|
|
16be0f0944 | ||
|
|
7daa1dacc3 | ||
|
|
87735b1f68 | ||
|
|
5e584d1cfb | ||
|
|
123e205bbd | ||
|
|
52f61c088e | ||
|
|
9c946eb168 | ||
|
|
8f36753705 | ||
|
|
7a606ab91c | ||
|
|
2471c5ccbb | ||
|
|
0ce50407f9 | ||
|
|
ee12f8d2d4 | ||
|
|
c690ed0058 | ||
|
|
b432a10222 | ||
|
|
ca657d2629 | ||
|
|
083ef9baa7 | ||
|
|
f05e8ed0ce | ||
|
|
feec193d7d | ||
|
|
861c50e727 | ||
|
|
f1a0a5e1bf | ||
|
|
7eb0eb77aa | ||
|
|
ca15a14666 | ||
|
|
afbe1f7e53 | ||
|
|
339bb0b4ea | ||
|
|
fe6534b888 | ||
|
|
ebc8482310 | ||
|
|
c411b8e471 | ||
|
|
06ba783267 | ||
|
|
a30aac87bf | ||
|
|
c7ffe822f1 | ||
|
|
9e5f77b57c | ||
|
|
0863c1e05c | ||
|
|
39d5fd84db | ||
|
|
68caa60dfa | ||
|
|
f9b3e42dcd | ||
|
|
59efe7299a | ||
|
|
3ea19d8eb1 | ||
|
|
1f9c4bc933 | ||
|
|
484ff8e349 | ||
|
|
ad9870acc1 | ||
|
|
8925585895 | ||
|
|
8d511c9b71 | ||
|
|
85925680ac | ||
|
|
5763378ee1 | ||
|
|
c3eac77ee1 | ||
|
|
008c3b145d | ||
|
|
ee75fac9d7 | ||
|
|
220d005cc2 | ||
|
|
ed1a58aa2a | ||
|
|
2fd1dbd23a | ||
|
|
68dcc96527 | ||
|
|
708d889036 | ||
|
|
20e2728037 | ||
|
|
2359e1610a | ||
|
|
2e5fc38179 | ||
|
|
6d856fa632 | ||
|
|
1802c75ed6 | ||
|
|
c182b4085b | ||
|
|
a0af717234 | ||
|
|
d3c5e866f0 | ||
|
|
2b8b6a09dd | ||
|
|
c79f2e45a8 | ||
|
|
ab4b5abb91 | ||
|
|
3f6501b4ff | ||
|
|
b9c4f790bd | ||
|
|
2206e9fe2a | ||
|
|
bbe7580c9d | ||
|
|
ef21a9ba9b | ||
|
|
88f9039d1a | ||
|
|
2e6b4b5838 | ||
|
|
85d793b690 | ||
|
|
8a50572786 | ||
|
|
bbd942c600 | ||
|
|
84813d7f46 | ||
|
|
1132eb1a0f | ||
|
|
2323f2c978 | ||
|
|
e277647f7b | ||
|
|
c587f4934a | ||
|
|
e0020b478a | ||
|
|
95de2374ed | ||
|
|
d70e005412 | ||
|
|
32baf6a47c | ||
|
|
3f87836536 | ||
|
|
da3844c4df | ||
|
|
1f5281d3b8 | ||
|
|
04e7457cea |
@@ -19,13 +19,22 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import _ from "@/lib/translate"
|
||||
import { selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useFrappeGetDocList } from "frappe-react-sdk"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
|
||||
const { data: companies, error } = useFrappeGetDocList("Company", {
|
||||
limit: 0,
|
||||
fields: ["name"],
|
||||
}, 'company_list', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const options = companies?.map((company: { name: string }) => company.name) || []
|
||||
|
||||
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
|
||||
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
|
||||
@@ -42,6 +51,10 @@ const CompanySelector = ({ onChange }: { onChange?: (company: string) => void })
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
|
||||
return (<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
|
||||
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '', undefined, {
|
||||
getOnInit: true,
|
||||
})
|
||||
|
||||
export const useCurrentCompany = () => {
|
||||
const selectedCompany = useAtomValue(selectedCompanyAtom)
|
||||
|
||||
@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
|
||||
|
||||
def link_address(self):
|
||||
"""Link address based on owner"""
|
||||
if self.is_your_company_address:
|
||||
if self.get("is_your_company_address"):
|
||||
return
|
||||
|
||||
return super().link_address()
|
||||
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
|
||||
self.is_your_company_address = 1
|
||||
|
||||
def validate_reference(self):
|
||||
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
|
||||
if self.get("is_your_company_address") and not [
|
||||
row for row in self.links if row.link_doctype == "Company"
|
||||
]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Address needs to be linked to a Company. Please add a row for Company in the Links table."
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"account_number": "11530"
|
||||
},
|
||||
"account_number": "115",
|
||||
"is_group": 1
|
||||
"is_group": 1,
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Trade Receivables": {
|
||||
"Trade Debtors": {
|
||||
@@ -529,6 +530,13 @@
|
||||
"account_number": "630",
|
||||
"is_group": 1
|
||||
},
|
||||
"Accrued Manufacturing Expenses": {
|
||||
"Accrued Expenses - Manufacturing": {
|
||||
"account_number": "63510"
|
||||
},
|
||||
"account_number": "635",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "63",
|
||||
"is_group": 1
|
||||
},
|
||||
@@ -814,4 +822,4 @@
|
||||
"root_type": "Expense"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"enable_common_party_accounting",
|
||||
"allow_multi_currency_invoices_against_single_party_account",
|
||||
"confirm_before_resetting_posting_date",
|
||||
"stock_expense_section",
|
||||
"book_stock_expense_gl_entries",
|
||||
"analytics_section",
|
||||
"enable_discounts_and_margin",
|
||||
"enable_accounting_dimensions",
|
||||
@@ -75,6 +77,8 @@
|
||||
"over_billing_allowance",
|
||||
"credit_controller",
|
||||
"role_allowed_to_over_bill",
|
||||
"enable_overdue_billing_threshold",
|
||||
"role_allowed_to_bypass_overdue_billing",
|
||||
"column_break_11",
|
||||
"assets_tab",
|
||||
"asset_settings_section",
|
||||
@@ -271,6 +275,21 @@
|
||||
"label": "Role Allowed to over bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Customer Over Billing"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
|
||||
"fieldname": "role_allowed_to_bypass_overdue_billing",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Bypass Over Billing Restriction",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -749,6 +768,18 @@
|
||||
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
|
||||
"fieldname": "column_break_mfor",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_expense_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Stock Expense Accounting"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher",
|
||||
"fieldname": "book_stock_expense_gl_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Stock Expense GL Entries"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -757,7 +788,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-24 12:59:41.868865",
|
||||
"modified": "2026-07-27 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -62,6 +62,7 @@ class AccountsSettings(Document):
|
||||
book_asset_depreciation_entry_automatically: DF.Check
|
||||
book_deferred_entries_based_on: DF.Literal["Days", "Months"]
|
||||
book_deferred_entries_via_journal_entry: DF.Check
|
||||
book_stock_expense_gl_entries: DF.Check
|
||||
book_tax_discount_loss: DF.Check
|
||||
calculate_depr_using_total_days: DF.Check
|
||||
check_supplier_invoice_uniqueness: DF.Check
|
||||
@@ -77,6 +78,7 @@ class AccountsSettings(Document):
|
||||
enable_fuzzy_matching: DF.Check
|
||||
enable_immutable_ledger: DF.Check
|
||||
enable_loyalty_point_program: DF.Check
|
||||
enable_overdue_billing_threshold: DF.Check
|
||||
enable_party_matching: DF.Check
|
||||
enable_subscription: DF.Check
|
||||
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
|
||||
@@ -95,6 +97,7 @@ class AccountsSettings(Document):
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
reconciliation_queue_size: DF.Int
|
||||
repost_allowed_types: DF.Table[RepostAllowedTypes]
|
||||
role_allowed_to_bypass_overdue_billing: DF.Link | None
|
||||
role_allowed_to_over_bill: DF.Link | None
|
||||
role_to_notify_on_depreciation_failure: DF.Link | None
|
||||
role_to_override_stop_action: DF.Link | None
|
||||
@@ -150,6 +153,10 @@ class AccountsSettings(Document):
|
||||
toggle_subscription_sections(not self.enable_subscription)
|
||||
clear_cache = True
|
||||
|
||||
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
|
||||
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
|
||||
clear_cache = True
|
||||
|
||||
if clear_cache:
|
||||
frappe.clear_cache()
|
||||
|
||||
@@ -241,6 +248,10 @@ def toggle_subscription_sections(hide):
|
||||
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
|
||||
|
||||
|
||||
def toggle_overdue_billing_threshold_field(hide):
|
||||
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
|
||||
|
||||
|
||||
def create_property_setter_for_hiding_field(doctype, field_name, hide):
|
||||
make_property_setter(
|
||||
doctype,
|
||||
|
||||
@@ -143,6 +143,30 @@ def preprocess_mt940_content(content: str) -> str:
|
||||
return processed_content
|
||||
|
||||
|
||||
MT940_CUSTOMER_REFERENCE_MAX_LEN = 16
|
||||
|
||||
|
||||
def get_transaction_reference(txn_data: dict) -> str:
|
||||
"""Extract the per-transaction reference from an MT940 :61: tag.
|
||||
|
||||
The mt940 library exposes ``transaction_reference`` from the :20: tag, which is the
|
||||
statement-level reference and identical for every transaction in a statement. The
|
||||
real per-transaction reference is ``customer_reference`` (with any overflow captured
|
||||
into ``extra_details`` when a bank emits a single-line :61: longer than 16 chars).
|
||||
"""
|
||||
customer_reference = (txn_data.get("customer_reference") or "").strip()
|
||||
|
||||
if len(customer_reference) == MT940_CUSTOMER_REFERENCE_MAX_LEN:
|
||||
customer_reference += (txn_data.get("extra_details") or "").strip()
|
||||
|
||||
if customer_reference and customer_reference.upper() != "NONREF":
|
||||
return customer_reference
|
||||
|
||||
return (txn_data.get("bank_reference") or "").strip() or (
|
||||
txn_data.get("transaction_reference") or ""
|
||||
).strip()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
doc = frappe.get_doc("Bank Statement Import", data_import)
|
||||
@@ -190,8 +214,8 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
|
||||
deposit = amount_value if amount_value > 0 else ""
|
||||
withdrawal = abs(amount_value) if amount_value < 0 else ""
|
||||
description = txn.data.get("extra_details") or ""
|
||||
reference = txn.data.get("transaction_reference") or ""
|
||||
description = txn.data.get("transaction_details") or txn.data.get("extra_details") or ""
|
||||
reference = get_transaction_reference(txn.data)
|
||||
currency = txn.data.get("currency", "")
|
||||
|
||||
writer.writerow([date_str, deposit, withdrawal, description, reference, doc.bank_account, currency])
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Copyright (c) 2020, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
import mt940
|
||||
|
||||
from erpnext.accounts.doctype.bank_statement_import.bank_statement_import import (
|
||||
get_transaction_reference,
|
||||
is_mt940_format,
|
||||
preprocess_mt940_content,
|
||||
)
|
||||
@@ -188,6 +191,135 @@ class TestBankStatementImport(ERPNextTestSuite):
|
||||
self.assertIn(":20:STMTREF167619", result) # Reference should remain unchanged
|
||||
self.assertIn("UPI/TEST USER/123456789/PaidViaTestApp", result)
|
||||
|
||||
def test_get_transaction_reference_uses_customer_reference(self):
|
||||
"""Per-transaction reference must come from :61: customer_reference, not :20:."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{"customer_reference": "UPI-100000000001", "transaction_reference": "STMTREF12345"}
|
||||
),
|
||||
"UPI-100000000001",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_rejoins_overflow(self):
|
||||
"""When a bank emits a single-line :61: with >16-char reference, the regex
|
||||
splits the tail into extra_details. We must rejoin them."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NEFTINW-12345678",
|
||||
"extra_details": "90",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"NEFTINW-1234567890",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref(self):
|
||||
"""NONREF is the MT940 'no customer reference' sentinel; prefer bank_reference."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NONREF",
|
||||
"bank_reference": "1234567890123456",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"1234567890123456",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref_with_extra_details(self):
|
||||
"""NONREF sentinel must trigger the bank_reference fallback even when
|
||||
extra_details is populated. Without the 16-char gate, the old naive concat
|
||||
would produce a junk reference like 'NONREFsome info' and bypass the check."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NONREF",
|
||||
"extra_details": "some info",
|
||||
"bank_reference": "1234567890123456",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"1234567890123456",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_does_not_append_extra_details_below_16_chars(self):
|
||||
"""When customer_reference is below the 16-char cap, extra_details is a
|
||||
genuine supplementary-info field from :61: — not overflow — and must not
|
||||
be appended to the reference."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "TBMS-123456789",
|
||||
"extra_details": "note field",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"TBMS-123456789",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_keeps_noref_literal(self):
|
||||
"""Bare 'NOREF' (without bank_reference) stays as-is; still better than the
|
||||
statement-level reference which is identical across all transactions."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NOREF",
|
||||
"bank_reference": None,
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"NOREF",
|
||||
)
|
||||
|
||||
def test_mt940_parse_per_transaction_reference_mapping(self):
|
||||
"""End-to-end: every transaction in a statement must get its own distinct
|
||||
reference from :61: customer_reference, never the statement-level :20: reference."""
|
||||
mt940_content = """{1:F0112345678901X0000000000}{2:I94012345678901XN}{4:
|
||||
:20:STMTREF12345
|
||||
:25:1234567890
|
||||
:28C:12345/1
|
||||
:60F:C250716INR88123,38
|
||||
:61:2509280928D5000,00NMSCUPI-100000000001
|
||||
:86:UPI/TEST PAYEE ONE/111111111111/TestApp
|
||||
:61:2509190919D2606,00NMSCUPI-100000000002
|
||||
:86:UPI/TEST PAYEE TWO/222222222222/TestApp
|
||||
:61:2509190919D900,00NMSCUPI-100000000003
|
||||
:86:UPI/TEST PAYEE THREE/333333333333/TestApp
|
||||
:61:2508140814D5000,00NMSCUPI-100000000004
|
||||
:86:UPI/TEST PAYEE FOUR/444444444444/TestApp
|
||||
:61:2508060806D2000,00NMSCUPI-100000000005
|
||||
:86:UPI/TEST PAYEE FIVE/555555555555/TestApp
|
||||
:61:2508030803D1066,00NMSC123456789012
|
||||
:86:PCD/1234/TEST MERCHANT/01234567890123/12:00
|
||||
:61:2507310731D305,62NMSCTBMS-123456789
|
||||
:86:Chrg: Debit Card Annual Fee 1234 for 2025
|
||||
:61:2507240724C1,00NMSCNEFTINW-1234567890
|
||||
:86:NEFT TEST123456789 TEST SERVICES
|
||||
:61:2507170717C100000,00NMSCNOREF
|
||||
:86:BY CLG INST 123456/01-01-25/TESTBANK/TESTCITY
|
||||
:62F:C250930INR100000,00
|
||||
-}"""
|
||||
transactions = list(mt940.parse(preprocess_mt940_content(mt940_content)))
|
||||
references = [get_transaction_reference(t.data) for t in transactions]
|
||||
|
||||
self.assertEqual(
|
||||
references,
|
||||
[
|
||||
"UPI-100000000001",
|
||||
"UPI-100000000002",
|
||||
"UPI-100000000003",
|
||||
"UPI-100000000004",
|
||||
"UPI-100000000005",
|
||||
"123456789012",
|
||||
"TBMS-123456789",
|
||||
"NEFTINW-1234567890",
|
||||
"NOREF",
|
||||
],
|
||||
)
|
||||
# No transaction should carry the statement-level reference from :20:
|
||||
self.assertNotIn("STMTREF12345", references)
|
||||
|
||||
def test_preprocess_mt940_content_whitespace_variants(self):
|
||||
"""Test handling of whitespace and different line endings"""
|
||||
# Test with trailing spaces
|
||||
|
||||
@@ -616,15 +616,27 @@ class ExchangeRateRevaluation(Document):
|
||||
if journals:
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.submit()
|
||||
frappe.msgprint(
|
||||
_("Revaluation journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
if drafts := frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={"docstatus": 0, "reversal_of": ["in", journals]},
|
||||
pluck="name",
|
||||
as_list=1,
|
||||
):
|
||||
part = "journals are" if len(drafts) > 1 else "journal is"
|
||||
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
|
||||
frappe.throw(
|
||||
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
|
||||
)
|
||||
else:
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.save()
|
||||
frappe.msgprint(
|
||||
_("A draft reverse journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
|
||||
@@ -9,11 +9,10 @@ from frappe.utils import add_days, flt, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.set_system_and_company_settings()
|
||||
|
||||
def set_system_and_company_settings(self):
|
||||
# set number and currency precision
|
||||
system_settings = frappe.get_doc("System Settings")
|
||||
system_settings.float_precision = 2
|
||||
system_settings.currency_precision = 2
|
||||
system_settings.language = "en"
|
||||
system_settings.time_zone = "Asia/Kolkata"
|
||||
system_settings.save()
|
||||
|
||||
# Using Exchange Gain/Loss account for unrealized as well.
|
||||
company_doc = frappe.get_doc("Company", self.company)
|
||||
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
|
||||
@@ -300,3 +291,97 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
|
||||
for key, _val in expected_data.items():
|
||||
self.assertEqual(expected_data.get(key), account_details.get(key))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
|
||||
)
|
||||
def test_05_revaluation_journal_reversal(self):
|
||||
"""
|
||||
Test reversing of revaluation journals
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer="_Test Customer 1",
|
||||
debit_to=self.debtors_usd,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_submit=1,
|
||||
)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.save().submit()
|
||||
|
||||
err = frappe.new_doc("Exchange Rate Revaluation")
|
||||
err.company = self.company
|
||||
err.posting_date = today()
|
||||
err.fetch_and_calculate_accounts_data()
|
||||
self.assertEqual(len(err.accounts), 1)
|
||||
err.save().submit()
|
||||
|
||||
gain_loss_account = err.get_for_unrealized_gain_loss_account()
|
||||
usd_account = err.accounts[0].account
|
||||
old_balance = err.accounts[0].balance_in_base_currency
|
||||
new_balance = err.accounts[0].new_balance_in_base_currency
|
||||
total_gain_loss = err.total_gain_loss
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
|
||||
je = je.submit()
|
||||
|
||||
je.reload()
|
||||
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
|
||||
self.assertEqual(len(je.accounts), 3)
|
||||
# A gain is credited to the gain/loss account, a loss is debited. The current
|
||||
# exchange rate (from master data) may sit either side of the booked rate, so
|
||||
# derive the column from the sign instead of assuming a gain.
|
||||
gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0
|
||||
gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0
|
||||
expected = [
|
||||
(usd_account, new_balance, 0.0, 100.0, 0.0),
|
||||
(usd_account, 0.0, old_balance, 0.0, 100.0),
|
||||
(gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit),
|
||||
]
|
||||
actual = []
|
||||
for acc in je.accounts:
|
||||
actual.append(
|
||||
(
|
||||
acc.account,
|
||||
acc.debit,
|
||||
acc.credit,
|
||||
acc.debit_in_account_currency,
|
||||
acc.credit_in_account_currency,
|
||||
)
|
||||
)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
# Assert reversals are not posted
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertFalse(ret.get("reversals_posted"))
|
||||
|
||||
err.make_reverse_journal()
|
||||
# submit
|
||||
draft = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
|
||||
pluck="name",
|
||||
as_list=1,
|
||||
)
|
||||
self.assertIsNotNone(draft)
|
||||
frappe.get_doc("Journal Entry", draft[0]).submit()
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertTrue(ret.get("reversals_posted"))
|
||||
|
||||
reverse_jv = frappe.db.get_all(
|
||||
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
|
||||
)
|
||||
self.assertIsNotNone(reverse_jv)
|
||||
|
||||
@@ -1853,28 +1853,51 @@ class GrowthViewTransformer:
|
||||
self.formatted_rows = context.raw_data.get("formatted_data", [])
|
||||
self.period_list = context.period_list
|
||||
|
||||
def transform(self) -> None:
|
||||
def transform(self):
|
||||
for row_data in self.formatted_rows:
|
||||
if row_data.get("is_blank_line"):
|
||||
continue
|
||||
|
||||
transformed_values = {}
|
||||
for i in range(len(self.period_list)):
|
||||
current_period = self.period_list[i]["key"]
|
||||
if row_data.get("segment_values"):
|
||||
self._transform_segmented_row(row_data)
|
||||
else:
|
||||
self._transform_single_row(row_data)
|
||||
|
||||
current_value = row_data[current_period]
|
||||
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
|
||||
def _compute_growth_values(self, source: dict) -> dict:
|
||||
transformed = {}
|
||||
|
||||
if i == 0:
|
||||
transformed_values[current_period] = current_value
|
||||
else:
|
||||
growth_percent = self._calculate_growth(previous_value, current_value)
|
||||
transformed_values[current_period] = growth_percent
|
||||
for i, period in enumerate(self.period_list):
|
||||
current_period = period["key"]
|
||||
current_value = source.get(current_period)
|
||||
|
||||
row_data.update(transformed_values)
|
||||
if current_value in (None, ""):
|
||||
continue
|
||||
|
||||
if i == 0:
|
||||
transformed[current_period] = current_value
|
||||
else:
|
||||
previous_period = self.period_list[i - 1]["key"]
|
||||
previous_value = source.get(previous_period) or 0
|
||||
transformed[current_period] = self._calculate_growth(previous_value, current_value)
|
||||
|
||||
return transformed
|
||||
|
||||
def _transform_single_row(self, row_data: dict):
|
||||
row_data.update(self._compute_growth_values(row_data))
|
||||
|
||||
def _transform_segmented_row(self, row_data: dict):
|
||||
for seg_id, seg_data in row_data.get("segment_values", {}).items():
|
||||
if seg_data.get("is_blank_line"):
|
||||
continue
|
||||
|
||||
transformed = self._compute_growth_values(seg_data)
|
||||
seg_data.update(transformed)
|
||||
|
||||
for period_key, value in transformed.items():
|
||||
row_data[f"{seg_id}_{period_key}"] = value
|
||||
|
||||
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
|
||||
if current_value is None:
|
||||
if current_value in (None, ""):
|
||||
return None
|
||||
|
||||
if previous_value == 0 and current_value > 0:
|
||||
|
||||
@@ -567,6 +567,7 @@ $.extend(erpnext.journal_entry, {
|
||||
lock_reversal_entry: function (frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
@@ -3326,13 +3326,11 @@ def set_paid_amount_and_received_amount(
|
||||
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
|
||||
if bank and company_currency != bank.account_currency:
|
||||
# doc currency can be different from bank currency
|
||||
posting_date = doc.get("posting_date") or doc.get("transaction_date")
|
||||
conversion_rate = get_exchange_rate(
|
||||
bank.account_currency, party_account_currency, posting_date
|
||||
)
|
||||
conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
|
||||
received_amount = paid_amount / conversion_rate
|
||||
else:
|
||||
received_amount = paid_amount * doc.get("conversion_rate", 1)
|
||||
conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
|
||||
received_amount = paid_amount * conversion_rate
|
||||
|
||||
# if payment type is pay, then paid amount and received amount are swapped
|
||||
if payment_type == "Pay":
|
||||
|
||||
@@ -2405,6 +2405,86 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
|
||||
transaction_date = nowdate()
|
||||
self.supplier = "_Test Supplier USD"
|
||||
amount = 100
|
||||
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
|
||||
|
||||
# Pay USD 100 at an exchange rate of 90.
|
||||
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
pe.payment_type = "Pay"
|
||||
pe.party_type = "Supplier"
|
||||
pe.party = self.supplier
|
||||
pe.paid_from = self.cash
|
||||
pe.paid_from_account_currency = "INR"
|
||||
pe.target_exchange_rate = 90
|
||||
pe.paid_amount = 90 * amount
|
||||
pe.received_amount = amount
|
||||
pe.paid_to = self.creditors_usd
|
||||
pe.paid_to_account_currency = "USD"
|
||||
pe.department = department
|
||||
pe = pe.save().submit()
|
||||
|
||||
# Receive USD 100 from the supplier at an exchange rate of 100.
|
||||
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
reverse_pe.payment_type = "Receive"
|
||||
reverse_pe.party_type = "Supplier"
|
||||
reverse_pe.party = self.supplier
|
||||
reverse_pe.paid_from = self.creditors_usd
|
||||
reverse_pe.paid_from_account_currency = "USD"
|
||||
reverse_pe.source_exchange_rate = 100
|
||||
reverse_pe.paid_amount = amount
|
||||
reverse_pe.received_amount = 100 * amount
|
||||
reverse_pe.paid_to = self.cash
|
||||
reverse_pe.paid_to_account_currency = "INR"
|
||||
reverse_pe.department = department
|
||||
reverse_pe = reverse_pe.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation(party_is_customer=False)
|
||||
pr.party = self.supplier
|
||||
pr.receivable_payable_account = self.creditors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
for row in pr.allocation:
|
||||
row.department = department
|
||||
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
|
||||
pr.reconcile()
|
||||
|
||||
gain_loss_journal = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{
|
||||
"reference_type": reverse_pe.doctype,
|
||||
"reference_name": reverse_pe.name,
|
||||
"party": self.supplier,
|
||||
"docstatus": 1,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
party_row = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{"parent": gain_loss_journal, "party": self.supplier},
|
||||
["debit", "credit"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(party_row.debit), 1000)
|
||||
self.assertEqual(flt(party_row.credit), 0)
|
||||
|
||||
party_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
|
||||
"account": self.creditors_usd,
|
||||
"party": self.supplier,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["debit", "credit"],
|
||||
)
|
||||
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
|
||||
|
||||
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
|
||||
transaction_date = nowdate()
|
||||
customer = self.customer_usd
|
||||
|
||||
@@ -37,6 +37,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
|
||||
frm.set_intro(__("Failure: {0}", [frm.doc.failed_reason]), "red");
|
||||
}
|
||||
|
||||
let sending_email = false;
|
||||
|
||||
if (
|
||||
frm.doc.payment_request_type == "Inward" &&
|
||||
frm.doc.payment_channel !== "Phone" &&
|
||||
@@ -45,16 +47,16 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
|
||||
frm.doc.docstatus == 1
|
||||
) {
|
||||
frm.add_custom_button(__("Resend Payment Email"), function () {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email",
|
||||
args: { docname: frm.doc.name },
|
||||
freeze: true,
|
||||
freeze_message: __("Sending"),
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
frappe.msgprint(__("Message Sent"));
|
||||
}
|
||||
},
|
||||
if (sending_email) {
|
||||
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
|
||||
return;
|
||||
}
|
||||
sending_email = true;
|
||||
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
|
||||
frm.call("resend_payment_email").then((r) => {
|
||||
const msg = !r.exc ? __("Email Sent") : __("Email couldn't be sent.");
|
||||
frappe.show_alert({ message: msg, indicator: !r.exc ? "green" : "red" });
|
||||
sending_email = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -423,6 +423,18 @@ class PaymentRequest(Document):
|
||||
|
||||
return payment_entry
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resend_payment_email(self):
|
||||
if not (
|
||||
self.docstatus == 1
|
||||
and self.payment_request_type == "Inward"
|
||||
and self.payment_channel != "Phone"
|
||||
and self.status not in ["Initiated", "Paid"]
|
||||
):
|
||||
frappe.throw(_("Payment Link couldn't be sent."))
|
||||
|
||||
self.send_email()
|
||||
|
||||
def send_email(self):
|
||||
"""send email with payment link"""
|
||||
email_args = {
|
||||
@@ -440,11 +452,14 @@ class PaymentRequest(Document):
|
||||
)
|
||||
],
|
||||
}
|
||||
job_id = f"send_payment_email::{self.name}"
|
||||
enqueue(
|
||||
method=frappe.sendmail,
|
||||
queue="short",
|
||||
timeout=300,
|
||||
is_async=True,
|
||||
job_id=job_id,
|
||||
deduplicate=True,
|
||||
enqueue_after_commit=True,
|
||||
**email_args,
|
||||
)
|
||||
@@ -951,11 +966,6 @@ def get_print_format_list(ref_doctype):
|
||||
return {"print_format": print_format_list}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def resend_payment_email(docname):
|
||||
return frappe.get_doc("Payment Request", docname).send_email()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_payment_entry(docname):
|
||||
doc = frappe.get_doc("Payment Request", docname)
|
||||
|
||||
@@ -6,17 +6,21 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, flt, formatdate, getdate
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
|
||||
|
||||
class PeriodClosingVoucher(AccountsController):
|
||||
@@ -46,6 +50,14 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.check_closing_account_type()
|
||||
self.check_closing_account_currency()
|
||||
self.validate_accounts_not_frozen()
|
||||
|
||||
def validate_accounts_not_frozen(self, for_cancellation=False):
|
||||
posting_date = self.period_end_date
|
||||
if for_cancellation and is_immutable_ledger_enabled():
|
||||
posting_date = getdate()
|
||||
|
||||
check_freezing_date(posting_date, self.company)
|
||||
|
||||
def validate_start_and_end_date(self):
|
||||
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
|
||||
@@ -130,6 +142,121 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if account_currency != company_currency:
|
||||
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
|
||||
|
||||
def before_submit(self):
|
||||
if not self.has_stock_transactions():
|
||||
return
|
||||
|
||||
self.validate_stock_accounts_balance()
|
||||
self.validate_stock_closing_entry()
|
||||
|
||||
def has_stock_transactions(self):
|
||||
if not is_perpetual_inventory_enabled(self.company):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"company": self.company,
|
||||
"is_cancelled": 0,
|
||||
"posting_date": ("<=", self.period_end_date),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_stock_accounts_balance(self):
|
||||
precision = frappe.get_precision("GL Entry", "debit")
|
||||
account_balance = flt(self.get_stock_accounts_balance(), precision)
|
||||
stock_value = flt(
|
||||
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
|
||||
)
|
||||
|
||||
if account_balance == stock_value:
|
||||
return
|
||||
|
||||
currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
frappe.throw(
|
||||
_(
|
||||
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
|
||||
).format(
|
||||
frappe.bold(fmt_money(account_balance, currency=currency)),
|
||||
frappe.bold(fmt_money(stock_value, currency=currency)),
|
||||
frappe.bold(formatdate(self.period_end_date)),
|
||||
),
|
||||
title=_("Stock Value Mismatch"),
|
||||
)
|
||||
|
||||
def get_stock_accounts_balance(self):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
stock_accounts = (
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(
|
||||
(account.account_type == "Stock")
|
||||
& (account.company == self.company)
|
||||
& (account.is_group == 0)
|
||||
)
|
||||
)
|
||||
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit - gle.credit))
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (gle.posting_date <= self.period_end_date)
|
||||
& gle.account.isin(stock_accounts)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(balance[0][0]) if balance else 0.0
|
||||
|
||||
def validate_stock_closing_entry(self):
|
||||
closing_entry = frappe.db.get_value(
|
||||
"Stock Closing Entry",
|
||||
apply_unscoped_filters(
|
||||
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
|
||||
),
|
||||
["name", "status", "modified"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not closing_entry:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry Required"),
|
||||
)
|
||||
|
||||
if closing_entry.status != "Completed":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry In Progress"),
|
||||
)
|
||||
|
||||
self.validate_stock_closing_entry_is_fresh(closing_entry)
|
||||
|
||||
def validate_stock_closing_entry_is_fresh(self, closing_entry):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
last_change = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Max(sle.modified))
|
||||
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
|
||||
).run()
|
||||
|
||||
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
|
||||
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
|
||||
title=_("Stock Closing Entry Outdated"),
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
@@ -147,6 +274,7 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Process Period Closing Voucher",
|
||||
)
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.validate_accounts_not_frozen(for_cancellation=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
@@ -307,6 +307,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
repost_doc.posting_date = today()
|
||||
repost_doc.save()
|
||||
|
||||
def test_stock_validations_before_period_closing(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Stock Closing Entry": [
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
"label": "Warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": pcv.period_start_date,
|
||||
"to_date": pcv.period_end_date,
|
||||
"warehouse": "Stores - TPC",
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name},
|
||||
["name", "stock_value_difference"],
|
||||
as_dict=1,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
"Test PCV Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TPCVB.####",
|
||||
},
|
||||
)
|
||||
se1 = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=200,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-06-15",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
from_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2022-04-01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
)
|
||||
self.assertEqual(flt(stock_value_difference, 2), -750.0)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"frozen",
|
||||
make_stock_entry,
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
|
||||
|
||||
def test_period_closing_blocks_stale_stock_closing_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def make_completed_stock_closing_entry(self, from_date, to_date):
|
||||
from unittest.mock import patch
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
return sce
|
||||
|
||||
def rebuild_stock_closing_balance(self, sce):
|
||||
sce.remove_stock_closing()
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
@@ -217,7 +217,8 @@ class POSClosingEntry(StatusUpdater):
|
||||
self.update_sales_invoices_closing_entry()
|
||||
|
||||
def before_cancel(self):
|
||||
self.check_pce_is_cancellable()
|
||||
if self.status != "Failed":
|
||||
self.check_pce_is_cancellable()
|
||||
|
||||
def on_cancel(self):
|
||||
unconsolidate_pos_invoices(closing_entry=self)
|
||||
|
||||
@@ -234,15 +234,18 @@ def get_item_groups(pos_profile):
|
||||
for data in pos_profile.get("item_groups"):
|
||||
item_groups.extend(
|
||||
[
|
||||
"%s" % frappe.db.escape(d.name)
|
||||
d.name
|
||||
for d in get_child_nodes("Item Group", data.item_group)
|
||||
if not permitted_item_groups or d.name in permitted_item_groups
|
||||
]
|
||||
)
|
||||
|
||||
if not item_groups and permitted_item_groups:
|
||||
item_groups = ["%s" % frappe.db.escape(d) for d in permitted_item_groups]
|
||||
item_groups = list(permitted_item_groups)
|
||||
|
||||
# Return raw Item Group names; the callers parameterize them via the query builder
|
||||
# (item_group.isin(...)) / frappe.get_all, which escapes them once. Pre-escaping here would
|
||||
# double-escape (item_group IN ('''X''')) and match nothing.
|
||||
return list(set(item_groups))
|
||||
|
||||
|
||||
|
||||
@@ -1380,7 +1380,20 @@ class PurchaseInvoice(BuyingController):
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
|
||||
stock_asset_rbnb = (
|
||||
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
fallback_account = (
|
||||
(item.expense_account or stock_asset_rbnb)
|
||||
if self.is_return
|
||||
else (stock_asset_rbnb or item.expense_account)
|
||||
)
|
||||
cost_of_goods_sold_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
or fallback_account
|
||||
)
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
@@ -1405,7 +1418,20 @@ class PurchaseInvoice(BuyingController):
|
||||
and warehouse_debit_amount
|
||||
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
):
|
||||
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
|
||||
stock_asset_rbnb = (
|
||||
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
fallback_account = (
|
||||
(item.expense_account or stock_asset_rbnb)
|
||||
if self.is_return
|
||||
else (stock_asset_rbnb or item.expense_account)
|
||||
)
|
||||
cost_of_goods_sold_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
or fallback_account
|
||||
)
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
|
||||
@@ -1490,6 +1490,96 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
|
||||
|
||||
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
|
||||
|
||||
class StockAdjustmentInvoice:
|
||||
company = "_Test Company"
|
||||
conversion_rate = 1
|
||||
update_stock = 1
|
||||
is_internal_supplier = 0
|
||||
return_against = None
|
||||
project = None
|
||||
|
||||
def __init__(self, is_return, defaults):
|
||||
self.is_return = is_return
|
||||
self.defaults = defaults
|
||||
|
||||
def get(self, fieldname):
|
||||
return None
|
||||
|
||||
def get_company_default(self, fieldname, ignore_validation=False):
|
||||
return self.defaults.get(fieldname)
|
||||
|
||||
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
|
||||
return frappe._dict(args)
|
||||
|
||||
def make_invoice(is_return, defaults):
|
||||
return StockAdjustmentInvoice(is_return, defaults)
|
||||
|
||||
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
|
||||
return frappe._dict(
|
||||
{
|
||||
"name": "row-1",
|
||||
"warehouse": "Stores - _TC",
|
||||
"valuation_rate": 10,
|
||||
"qty": 10,
|
||||
"conversion_factor": 1,
|
||||
"base_net_amount": 100,
|
||||
"item_tax_amount": 0,
|
||||
"landed_cost_voucher_amount": 0,
|
||||
"sales_incoming_rate": 0,
|
||||
"is_fixed_asset": is_fixed_asset,
|
||||
"expense_account": expense_account,
|
||||
"cost_center": "Main - _TC",
|
||||
"project": None,
|
||||
"precision": lambda fieldname: 2,
|
||||
}
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"default_expense_account": None,
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
|
||||
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
|
||||
}
|
||||
test_cases = (
|
||||
(
|
||||
"company default expense",
|
||||
0,
|
||||
make_item(),
|
||||
{**defaults, "default_expense_account": "Default Expense - _TC"},
|
||||
"Default Expense - _TC",
|
||||
),
|
||||
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
|
||||
(
|
||||
"asset rbnb",
|
||||
0,
|
||||
make_item(is_fixed_asset=1),
|
||||
defaults,
|
||||
"Asset Received But Not Billed - _TC",
|
||||
),
|
||||
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
|
||||
(
|
||||
"return without item expense",
|
||||
1,
|
||||
make_item(expense_account=None),
|
||||
defaults,
|
||||
"Stock Received But Not Billed - _TC",
|
||||
),
|
||||
)
|
||||
|
||||
for label, is_return, item, company_defaults, expected_account in test_cases:
|
||||
with self.subTest(label=label):
|
||||
invoice = make_invoice(is_return, company_defaults)
|
||||
gl_entries = []
|
||||
PurchaseInvoice.make_stock_adjustment_entry(
|
||||
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
|
||||
)
|
||||
|
||||
self.assertEqual(gl_entries[0].account, expected_account)
|
||||
self.assertEqual(gl_entries[0].debit, 10)
|
||||
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
|
||||
def test_purchase_invoice_advance_taxes(self):
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
|
||||
@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.add_custom_button(__("Show Preview"), () => {
|
||||
frm.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Generating Preview"),
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let content = r.message;
|
||||
let opts = {
|
||||
title: "Preview",
|
||||
subtitle: "preview",
|
||||
content: content,
|
||||
print_settings: { orientation: "landscape" },
|
||||
columns: [],
|
||||
data: [],
|
||||
};
|
||||
frappe.render_grid(opts);
|
||||
}
|
||||
},
|
||||
// the server refuses only while the job is alive, so a dead one can be restarted here
|
||||
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
|
||||
frm.add_custom_button(__("Start Reposting"), () => {
|
||||
frm.events.start_repost(frm);
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus != 2) {
|
||||
frm.add_custom_button(__("Show Preview"), () => {
|
||||
frm.events.generate_preview(frm);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
generate_preview: function (frm) {
|
||||
frm.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Generating Preview"),
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let content = r.message;
|
||||
let opts = {
|
||||
title: "Preview",
|
||||
subtitle: "preview",
|
||||
content: content,
|
||||
print_settings: { orientation: "landscape" },
|
||||
columns: [],
|
||||
data: [],
|
||||
};
|
||||
frappe.render_grid(opts);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
start_repost: function (frm) {
|
||||
frm.call({
|
||||
method: "start_repost",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2023-07-04 13:07:32.923675",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
@@ -7,16 +8,24 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
"column_break_vpup",
|
||||
"delete_cancelled_entries",
|
||||
"column_break_vpup",
|
||||
"status",
|
||||
"section_break_metl",
|
||||
"vouchers",
|
||||
"amended_from"
|
||||
"error_section",
|
||||
"error_log",
|
||||
"miscellaneous_section",
|
||||
"amended_from",
|
||||
"column_break_hrah",
|
||||
"scheduled_job"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Company",
|
||||
"options": "Company"
|
||||
},
|
||||
@@ -48,12 +57,54 @@
|
||||
"fieldname": "delete_cancelled_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete Cancelled Ledger Entries"
|
||||
},
|
||||
{
|
||||
"fieldname": "error_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Error"
|
||||
},
|
||||
{
|
||||
"fieldname": "error_log",
|
||||
"fieldtype": "Code",
|
||||
"label": "Error Log",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "miscellaneous_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Miscellaneous"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hrah",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus >= 1;",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scheduled_job",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Scheduled Job",
|
||||
"no_copy": 1,
|
||||
"options": "RQ Job",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-03 17:30:37.012593",
|
||||
"modified": "2026-07-28 00:56:50.290314",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger",
|
||||
@@ -76,8 +127,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
|
||||
from frappe.utils.data import comma_and
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
# a batch has to finish well within the timeout of the job reposting it
|
||||
MAX_VOUCHERS_PER_REPOST = 50
|
||||
|
||||
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
|
||||
|
||||
|
||||
class RepostAccountingLedger(Document):
|
||||
@@ -28,6 +33,11 @@ class RepostAccountingLedger(Document):
|
||||
amended_from: DF.Link | None
|
||||
company: DF.Link | None
|
||||
delete_cancelled_entries: DF.Check
|
||||
error_log: DF.Code | None
|
||||
scheduled_job: DF.Link | None
|
||||
status: DF.Literal[
|
||||
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
|
||||
]
|
||||
vouchers: DF.Table[RepostAccountingLedgerItems]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -37,6 +47,11 @@ class RepostAccountingLedger(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_vouchers()
|
||||
self.validate_repost_preconditions()
|
||||
|
||||
def validate_repost_preconditions(self):
|
||||
"""The checks a repost queued days ago could have outlived, re-run before it touches
|
||||
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
|
||||
self.validate_for_closed_fiscal_year()
|
||||
self.validate_for_deferred_accounting()
|
||||
|
||||
@@ -73,8 +88,52 @@ class RepostAccountingLedger(Document):
|
||||
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
|
||||
|
||||
def validate_vouchers(self):
|
||||
if self.vouchers:
|
||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||
if not self.vouchers:
|
||||
frappe.throw(_("Add atleast one voucher to repost."))
|
||||
|
||||
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
|
||||
frappe.throw(
|
||||
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
|
||||
MAX_VOUCHERS_PER_REPOST
|
||||
)
|
||||
)
|
||||
|
||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||
|
||||
self.validate_no_duplicate_vouchers()
|
||||
self.validate_vouchers_are_submitted()
|
||||
|
||||
def validate_no_duplicate_vouchers(self):
|
||||
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
|
||||
|
||||
if len(vouchers) != len(set(vouchers)):
|
||||
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
|
||||
|
||||
def validate_vouchers_are_submitted(self):
|
||||
voucher_type_wise_map = {}
|
||||
for d in self.vouchers:
|
||||
voucher_type_wise_map.setdefault(d.voucher_type, [])
|
||||
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
|
||||
|
||||
non_submitted_vouchers = []
|
||||
for key in voucher_type_wise_map.keys():
|
||||
non_submitted_vouchers.extend(
|
||||
frappe.get_all(
|
||||
key,
|
||||
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
if non_submitted_vouchers:
|
||||
frappe.throw(
|
||||
_("The following vouchers are not submitted: {0}").format(
|
||||
comma_and(non_submitted_vouchers, add_quotes=True)
|
||||
)
|
||||
)
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def get_existing_ledger_entries(self):
|
||||
vouchers = [x.voucher_no for x in self.vouchers]
|
||||
@@ -139,80 +198,245 @@ class RepostAccountingLedger(Document):
|
||||
return rendered_page
|
||||
|
||||
def on_submit(self):
|
||||
if len(self.vouchers) > 5:
|
||||
job_name = "repost_accounting_ledger_" + self.name
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
|
||||
account_repost_doc=self.name,
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
self.start_repost()
|
||||
|
||||
def before_cancel(self):
|
||||
self._raise_error_if_reposting_in_progress()
|
||||
|
||||
def on_cancel(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def _raise_error_if_reposting_in_progress(self):
|
||||
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
|
||||
frappe.throw(_("Reposting is still in progress in background."))
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(self):
|
||||
if self.docstatus != 1:
|
||||
frappe.throw(_("Reposting can be started only for submitted document."))
|
||||
|
||||
# under a row lock, so two concurrent starts cannot both get past here
|
||||
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
|
||||
if status in ("Completed", "Cancelled"):
|
||||
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
|
||||
|
||||
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
|
||||
# died leaves the status behind and the document has to stay restartable
|
||||
self._raise_error_if_reposting_in_progress()
|
||||
|
||||
self.check_permission("write")
|
||||
|
||||
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
|
||||
if is_scheduler_inactive():
|
||||
frappe.msgprint(
|
||||
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
|
||||
alert=True,
|
||||
indicator="orange",
|
||||
)
|
||||
frappe.msgprint(_("Repost has started in the background"))
|
||||
else:
|
||||
start_repost(self.name)
|
||||
|
||||
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
|
||||
_enqueue_repost(self.name)
|
||||
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(account_repost_doc: str | None = None) -> None:
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
def _repost_job_id(repost_doc_name: str) -> str:
|
||||
"""Derived from the document, so a repost can only ever have one job."""
|
||||
return f"repost_accounting_ledger::{repost_doc_name}"
|
||||
|
||||
|
||||
def _enqueue_repost(repost_doc_name: str) -> None:
|
||||
"""Hand the repost to a background worker.
|
||||
|
||||
Tests run it in the foreground, inside their own transaction: documents edited after submit
|
||||
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
|
||||
ledger right after doing so.
|
||||
"""
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
|
||||
repost_doc_name=repost_doc_name,
|
||||
commit=not frappe.in_test,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
job_id=_repost_job_id(repost_doc_name),
|
||||
deduplicate=True,
|
||||
enqueue_after_commit=True,
|
||||
now=frappe.in_test,
|
||||
)
|
||||
|
||||
|
||||
def _lock_vouchers(vouchers) -> dict:
|
||||
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
|
||||
|
||||
Returns them keyed by voucher, so reposting does not load them again. These are file locks
|
||||
under the site directory: they serialise nothing across hosts that do not share it, and a
|
||||
worker killed outright leaves them behind until they expire.
|
||||
"""
|
||||
locked_docs = {}
|
||||
try:
|
||||
for x in vouchers:
|
||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||
doc.lock()
|
||||
locked_docs[(x.voucher_type, x.voucher_no)] = doc
|
||||
except Exception:
|
||||
for doc in locked_docs.values():
|
||||
doc.unlock()
|
||||
raise
|
||||
return locked_docs
|
||||
|
||||
|
||||
def repost(repost_doc_name: str, commit: bool = True):
|
||||
"""Repost every voucher of the document, one transaction at a time.
|
||||
|
||||
`commit` says whether this call owns the transaction. The background job does, and commits
|
||||
after every voucher so progress survives a crash; a caller inside its own passes `False`.
|
||||
"""
|
||||
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
|
||||
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
if account_repost_doc:
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
||||
repost_doc.check_permission("write")
|
||||
|
||||
if repost_doc.docstatus == 1:
|
||||
# Prevent repost on invoices with deferred accounting
|
||||
repost_doc.validate_for_deferred_accounting()
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
|
||||
locked_docs = {}
|
||||
|
||||
for x in repost_doc.vouchers:
|
||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||
try:
|
||||
repost_doc.validate_repost_preconditions()
|
||||
|
||||
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
|
||||
# and not reposted again
|
||||
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
|
||||
locked_docs = _lock_vouchers(pending)
|
||||
|
||||
repost_doc.db_set("status", "In Progress", commit=commit)
|
||||
|
||||
for position, x in enumerate(pending, start=1):
|
||||
frappe.publish_progress(
|
||||
position * 100 / len(pending),
|
||||
doctype=repost_doc.doctype,
|
||||
docname=repost_doc.name,
|
||||
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
|
||||
)
|
||||
|
||||
save_point = "reposting"
|
||||
frappe.db.savepoint(save_point=save_point)
|
||||
try:
|
||||
doc = locked_docs[(x.voucher_type, x.voucher_no)]
|
||||
|
||||
if doc.docstatus == 2:
|
||||
x.db_set({"status": "Skipped", "traceback": ""})
|
||||
continue
|
||||
|
||||
if repost_doc.delete_cancelled_entries:
|
||||
frappe.db.delete(
|
||||
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
||||
)
|
||||
frappe.db.delete(
|
||||
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
||||
)
|
||||
frappe.db.delete(
|
||||
"Advance Payment Ledger Entry",
|
||||
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
|
||||
)
|
||||
_delete_accounting_ledger_entries(doc.doctype, doc.name)
|
||||
_delete_adv_pl_entries(doc.doctype, doc.name)
|
||||
|
||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.docstatus = 2
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point=save_point)
|
||||
|
||||
doc.docstatus = 1
|
||||
if doc.doctype == "Sales Invoice":
|
||||
doc.force_set_against_income_account()
|
||||
else:
|
||||
doc.force_set_against_expense_account()
|
||||
doc.make_gl_entries()
|
||||
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
|
||||
else:
|
||||
x.db_set({"status": "Reposted", "traceback": ""})
|
||||
finally:
|
||||
if commit:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
elif doc.doctype == "Purchase Receipt":
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.docstatus = 2
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
except Exception:
|
||||
if commit:
|
||||
frappe.db.rollback()
|
||||
|
||||
doc.docstatus = 1
|
||||
doc.make_gl_entries(from_repost=True)
|
||||
_record_repost_failure(repost_doc, commit=commit)
|
||||
raise
|
||||
else:
|
||||
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
|
||||
finally:
|
||||
for doc in locked_docs.values():
|
||||
doc.unlock()
|
||||
if commit:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.make_gl_entries(1)
|
||||
doc.make_gl_entries()
|
||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
|
||||
doc.make_gl_entries(cancel=1)
|
||||
else:
|
||||
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||
doc.make_gl_entries()
|
||||
|
||||
def _derive_status(repost_doc) -> str:
|
||||
"""Vouchers are committed one by one, so the status follows what was actually handled."""
|
||||
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
|
||||
|
||||
if handled == len(repost_doc.vouchers):
|
||||
return "Completed"
|
||||
elif handled == 0:
|
||||
return "Failed"
|
||||
|
||||
return "Partially Reposted"
|
||||
|
||||
|
||||
def _record_repost_failure(repost_doc, commit=False) -> None:
|
||||
"""Persist the traceback of a run that could not finish, without discarding its progress."""
|
||||
# the traceback with frame locals goes to the Error Log, which is permissioned separately
|
||||
traceback = frappe.get_traceback()
|
||||
|
||||
frappe.log_error(
|
||||
title=_("Unable to Repost Accounting Ledger"),
|
||||
reference_doctype=repost_doc.doctype,
|
||||
reference_name=repost_doc.name,
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
|
||||
)
|
||||
|
||||
if commit:
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
|
||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
_repost_invoices(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype == "Purchase Receipt":
|
||||
_repost_purchase_receipt(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
|
||||
_repost_pe_je(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
|
||||
|
||||
|
||||
def _repost_invoices(invoice_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
invoice_doc.docstatus = 2
|
||||
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
invoice_doc.docstatus = 1
|
||||
if invoice_doc.doctype == "Sales Invoice":
|
||||
invoice_doc.force_set_against_income_account()
|
||||
else:
|
||||
invoice_doc.force_set_against_expense_account()
|
||||
invoice_doc.make_gl_entries()
|
||||
|
||||
|
||||
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
receipt_doc.docstatus = 2
|
||||
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
receipt_doc.docstatus = 1
|
||||
receipt_doc.make_gl_entries(from_repost=True)
|
||||
|
||||
|
||||
def _repost_pe_je(entry_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
entry_doc.make_gl_entries(cancel=1)
|
||||
entry_doc.make_gl_entries()
|
||||
|
||||
|
||||
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
|
||||
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
|
||||
if not delete_cancelled_entries:
|
||||
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
|
||||
repost_doc.make_gl_entries(cancel=1)
|
||||
else:
|
||||
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
|
||||
repost_doc.make_gl_entries()
|
||||
|
||||
|
||||
def get_allowed_types_from_settings(child_doc: bool = False):
|
||||
@@ -243,19 +467,24 @@ def get_child_docs(doc: list) -> list:
|
||||
|
||||
|
||||
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
|
||||
docs_with_deferred_revenue = frappe.db.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
docs_with_deferred_revenue = ()
|
||||
docs_with_deferred_expense = ()
|
||||
|
||||
docs_with_deferred_expense = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
if sales_docs:
|
||||
docs_with_deferred_revenue = frappe.db.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if purchase_docs:
|
||||
docs_with_deferred_expense = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
|
||||
fields=["parent"],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if docs_with_deferred_revenue or docs_with_deferred_expense:
|
||||
frappe.throw(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
frappe.listview_settings["Repost Accounting Ledger"] = {
|
||||
add_fields: ["status"],
|
||||
// drafts and cancelled documents are coloured by the framework before it gets here
|
||||
get_indicator: function (doc) {
|
||||
if (!doc.status) return;
|
||||
|
||||
const status_color = {
|
||||
Queued: "yellow",
|
||||
"In Progress": "blue",
|
||||
"Partially Reposted": "orange",
|
||||
Completed: "green",
|
||||
Failed: "red",
|
||||
};
|
||||
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
@@ -1,27 +1,42 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, nowdate, today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
|
||||
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
|
||||
_lock_vouchers,
|
||||
_record_repost_failure,
|
||||
_repost_allowed_hook_doctypes,
|
||||
_repost_job_id,
|
||||
_repost_vouchers,
|
||||
repost,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
|
||||
SIMULATED_FAILURE = "Simulated repost failure"
|
||||
|
||||
|
||||
class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
|
||||
update_repost_settings()
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = create_sales_invoice(
|
||||
def make_invoice(self, **kwargs):
|
||||
return create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def make_invoice_and_payment(self):
|
||||
si = self.make_invoice()
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
return si, pe
|
||||
|
||||
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.delete_cancelled_entries = delete_cancelled_entries
|
||||
for voucher in vouchers:
|
||||
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
|
||||
|
||||
ral.save()
|
||||
if submit:
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
return ral
|
||||
|
||||
@contextmanager
|
||||
def patched_repost(self, fail_for=()):
|
||||
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
|
||||
reposted = []
|
||||
|
||||
def repost_voucher(doc, delete_cancelled_entries):
|
||||
reposted.append(doc.name)
|
||||
if doc.doctype in fail_for:
|
||||
frappe.throw(SIMULATED_FAILURE)
|
||||
_repost_vouchers(doc, delete_cancelled_entries)
|
||||
|
||||
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
|
||||
yield reposted
|
||||
|
||||
def make_period_closing_voucher(self):
|
||||
fy = get_fiscal_year(today(), company="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": "Main - _TC",
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
return pcv.save().submit()
|
||||
|
||||
def get_gl_totals(self, voucher_no, is_cancelled=0):
|
||||
gl = qb.DocType("GL Entry")
|
||||
return (
|
||||
qb.from_(gl)
|
||||
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
|
||||
.run()
|
||||
)[0]
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = self.make_invoice()
|
||||
|
||||
preq = frappe.get_doc(
|
||||
make_payment_request(
|
||||
dt=si.doctype,
|
||||
@@ -64,51 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
|
||||
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
|
||||
|
||||
gl = qb.DocType("GL Entry")
|
||||
res = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
# Assert incorrect ledger balance
|
||||
self.assertNotEqual(res[0], (si.name, 100, 100))
|
||||
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
|
||||
|
||||
# Submit repost document
|
||||
ral.save().submit()
|
||||
|
||||
res = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
# Ledger should reflect correct amount post repost
|
||||
self.assertEqual(res[0], (si.name, 100, 100))
|
||||
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
|
||||
|
||||
def test_02_deferred_accounting_valiations(self):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si = self.make_invoice(do_not_submit=True)
|
||||
si.items[0].enable_deferred_revenue = True
|
||||
si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
|
||||
si.items[0].service_start_date = nowdate()
|
||||
si.items[0].service_end_date = add_days(nowdate(), 90)
|
||||
si.save().submit()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
self.assertRaises(frappe.ValidationError, ral.save)
|
||||
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_04_pcv_validation(self):
|
||||
@@ -116,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
fy = get_fiscal_year(today(), company="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": "Main - _TC",
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
pcv.save().submit()
|
||||
si = self.make_invoice()
|
||||
pcv = self.make_period_closing_voucher()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
self.assertRaises(frappe.ValidationError, ral.save)
|
||||
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||
|
||||
pcv.reload()
|
||||
pcv.cancel()
|
||||
pcv.delete()
|
||||
|
||||
def test_03_deletion_flag_and_preview_function(self):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
# with deletion flag set
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.delete_cancelled_entries = True
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||
ral.save().submit()
|
||||
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
|
||||
|
||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||
|
||||
def test_05_without_deletion_flag(self):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
# without deletion flag set
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.delete_cancelled_entries = False
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||
ral.save().submit()
|
||||
self.create_repost_doc([si, pe], submit=True)
|
||||
|
||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||
@@ -246,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
another_provisional_account,
|
||||
)
|
||||
|
||||
repost_doc = frappe.new_doc("Repost Accounting Ledger")
|
||||
repost_doc.company = "_Test Company"
|
||||
repost_doc.delete_cancelled_entries = True
|
||||
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
|
||||
repost_doc.save().submit()
|
||||
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
|
||||
|
||||
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
|
||||
expected_pr_gles_after_repost = [
|
||||
@@ -271,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
company.default_provisional_account = None
|
||||
company.save()
|
||||
|
||||
def test_07_voucher_validations(self):
|
||||
submitted_si = self.make_invoice()
|
||||
draft_si = self.make_invoice(do_not_submit=True)
|
||||
cancelled_si = self.make_invoice()
|
||||
cancelled_si.cancel()
|
||||
|
||||
for vouchers, exception, message in (
|
||||
([], frappe.ValidationError, "Add atleast one voucher"),
|
||||
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
|
||||
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
|
||||
# cancelled vouchers don't make it past link validation
|
||||
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
|
||||
):
|
||||
with self.subTest(vouchers=[x.name for x in vouchers]):
|
||||
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
|
||||
|
||||
self.create_repost_doc([submitted_si])
|
||||
|
||||
def test_08_voucher_count_limit(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
another_si = self.make_invoice()
|
||||
|
||||
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
|
||||
self.create_repost_doc([si, pe])
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot repost more than 2 vouchers",
|
||||
self.create_repost_doc,
|
||||
[si, pe, another_si],
|
||||
)
|
||||
|
||||
def test_09_status_lifecycle(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
self.assertEqual(ral.status, "")
|
||||
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertFalse(ral.error_log)
|
||||
for voucher in ral.vouchers:
|
||||
self.assertEqual(voucher.status, "Reposted")
|
||||
self.assertFalse(voucher.traceback)
|
||||
|
||||
ral.cancel()
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Cancelled")
|
||||
|
||||
discarded = self.create_repost_doc([si])
|
||||
discarded.discard()
|
||||
discarded.reload()
|
||||
self.assertEqual(discarded.status, "Cancelled")
|
||||
|
||||
def test_10_start_repost_guards(self):
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si])
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
|
||||
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
|
||||
)
|
||||
|
||||
# a document left behind by a worker that died mid-repost
|
||||
ral.db_set("status", "In Progress")
|
||||
|
||||
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "still in progress in background", ral.start_repost
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
|
||||
|
||||
# `cancel` flips docstatus in memory before running `before_cancel`
|
||||
ral.reload()
|
||||
|
||||
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
|
||||
# the job is gone, so `In Progress` must not keep the document stuck
|
||||
ral.start_repost()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
|
||||
def test_11_repost_job_is_tied_to_the_document(self):
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si], submit=True)
|
||||
ral.db_set("status", "Failed")
|
||||
|
||||
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
|
||||
ral.start_repost()
|
||||
|
||||
kwargs = enqueue.call_args.kwargs
|
||||
self.assertEqual(kwargs["repost_doc_name"], ral.name)
|
||||
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
|
||||
# a second start cannot queue a second job for the same document
|
||||
self.assertTrue(kwargs["deduplicate"])
|
||||
|
||||
def test_12_voucher_failures_are_isolated_and_retried(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
|
||||
|
||||
# the deletion flag drops the existing entries before reposting them
|
||||
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
|
||||
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Partially Reposted")
|
||||
|
||||
si_row, pe_row = ral.vouchers
|
||||
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
|
||||
self.assertFalse(si_row.traceback)
|
||||
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
|
||||
|
||||
# the failed voucher is rolled back to its savepoint, so its entries are back
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
|
||||
|
||||
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
|
||||
# alone entirely: they are not locked or loaded either
|
||||
with (
|
||||
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
|
||||
self.patched_repost() as retried,
|
||||
):
|
||||
ral.start_repost()
|
||||
|
||||
self.assertEqual(retried, [pe.name])
|
||||
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
for voucher in ral.vouchers:
|
||||
self.assertEqual(voucher.status, "Reposted")
|
||||
self.assertFalse(voucher.traceback)
|
||||
|
||||
def test_13_status_of_a_run_that_could_not_finish(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
|
||||
# the job dies after the loop committed the invoice, e.g. killed or timed out
|
||||
try:
|
||||
frappe.throw(SIMULATED_FAILURE)
|
||||
except frappe.ValidationError:
|
||||
_record_repost_failure(ral)
|
||||
|
||||
ral.reload()
|
||||
|
||||
# progress already committed must not be reported as a total failure
|
||||
self.assertEqual(ral.status, "Partially Reposted")
|
||||
self.assertIn(SIMULATED_FAILURE, ral.error_log)
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
|
||||
)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_14_period_closed_after_the_repost_was_started(self):
|
||||
gl = qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
|
||||
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si], submit=True)
|
||||
ral.db_set("status", "Failed")
|
||||
ral.vouchers[0].db_set("status", "Pending")
|
||||
|
||||
# the period is closed between the repost being started and the job running
|
||||
self.make_period_closing_voucher()
|
||||
|
||||
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Failed")
|
||||
self.assertIn("Closed fiscal year", ral.error_log)
|
||||
|
||||
# the ledger is left exactly as it was
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
|
||||
self.assertEqual(ral.vouchers[0].status, "Pending")
|
||||
|
||||
def test_15_failed_repost_skips_cancelled_voucher(self):
|
||||
si = self.make_invoice()
|
||||
|
||||
ral = self.create_repost_doc([si])
|
||||
with self.patched_repost(fail_for=["Sales Invoice"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Failed")
|
||||
|
||||
si.reload()
|
||||
si.cancel()
|
||||
|
||||
ral.start_repost()
|
||||
ral.reload()
|
||||
|
||||
# nothing was reposted, but there is nothing left to repost either
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertEqual(ral.vouchers[0].status, "Skipped")
|
||||
self.assertFalse(ral.vouchers[0].traceback)
|
||||
|
||||
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
|
||||
# a concurrent repost holding the lock on the second voucher
|
||||
locked_pe = frappe.get_doc(pe.doctype, pe.name)
|
||||
locked_pe.lock()
|
||||
try:
|
||||
self.assertRaises(frappe.DocumentLockedError, ral.submit)
|
||||
|
||||
# vouchers locked before the failure are released again
|
||||
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
|
||||
finally:
|
||||
locked_pe.unlock()
|
||||
|
||||
def test_17_journal_entry_repost(self):
|
||||
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
|
||||
je = frappe.get_doc("Journal Entry", je.name)
|
||||
|
||||
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||
|
||||
# without the deletion flag the 2 original entries are marked as cancelled,
|
||||
# along with the 2 reverse entries booked against them
|
||||
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
|
||||
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
|
||||
ral = self.create_repost_doc(
|
||||
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
|
||||
)
|
||||
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||
self.assertEqual(
|
||||
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
|
||||
cancelled_entries,
|
||||
)
|
||||
|
||||
def test_18_hook_allowed_doctype_repost(self):
|
||||
class VoucherWithCancelArg:
|
||||
doctype = "Test Repost Voucher"
|
||||
name = "TRV-00001"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def make_gl_entries(self, cancel=0):
|
||||
self.calls.append(cancel)
|
||||
|
||||
class VoucherWithoutCancelArg(VoucherWithCancelArg):
|
||||
def make_gl_entries(self):
|
||||
self.calls.append("repost")
|
||||
|
||||
# vouchers that can reverse their own entries are asked to do so first
|
||||
doc = VoucherWithCancelArg()
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||
self.assertEqual(doc.calls, [1, 0])
|
||||
|
||||
# nothing to reverse when the old entries are deleted
|
||||
doc = VoucherWithCancelArg()
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
|
||||
self.assertEqual(doc.calls, [0])
|
||||
|
||||
# the rest fall back to the generic reversal
|
||||
doc = VoucherWithoutCancelArg()
|
||||
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||
|
||||
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||
self.assertEqual(doc.calls, ["repost"])
|
||||
|
||||
|
||||
def update_repost_settings():
|
||||
allowed_types = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-07-04 14:14:01.243848",
|
||||
"doctype": "DocType",
|
||||
@@ -7,34 +8,70 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"voucher_type",
|
||||
"voucher_no"
|
||||
"column_break_ndex",
|
||||
"voucher_no",
|
||||
"reposting_status_section",
|
||||
"status",
|
||||
"traceback"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"columns": 5,
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher Type",
|
||||
"options": "DocType"
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ndex",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"columns": 5,
|
||||
"fieldname": "voucher_no",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher No",
|
||||
"options": "voucher_type"
|
||||
"options": "voucher_type",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reposting_status_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Reposting Status"
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"default": "Pending",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Pending\nReposted\nSkipped\nFailed",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "traceback",
|
||||
"fieldtype": "Code",
|
||||
"label": "Traceback",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:32.170897",
|
||||
"modified": "2026-07-29 02:41:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger Items",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
voucher_no: DF.DynamicLink | None
|
||||
voucher_type: DF.Link | None
|
||||
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
|
||||
traceback: DF.Code | None
|
||||
voucher_no: DF.DynamicLink
|
||||
voucher_type: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -1180,7 +1180,16 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
|
||||
// metadata, so this always reflects the original (Customize Form) hidden value.
|
||||
const hidden_by_customization = cint(
|
||||
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
|
||||
);
|
||||
frm.set_df_property(
|
||||
"update_stock",
|
||||
"hidden",
|
||||
cint(frm.doc.has_subcontracted) || hidden_by_customization
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
|
||||
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
|
||||
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
|
||||
self.check_credit_limit()
|
||||
self.check_overdue_billing_threshold()
|
||||
|
||||
if cint(self.is_pos) != 1 and not self.is_return:
|
||||
self.update_against_document_in_jv()
|
||||
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
|
||||
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
|
||||
pos_invoice_doc.cancel()
|
||||
|
||||
def check_overdue_billing_threshold(self):
|
||||
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
|
||||
|
||||
check_overdue_billing_threshold(self.customer, self.company)
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_missing_values(self, for_validate=False):
|
||||
pos = self.set_pos_fields(for_validate)
|
||||
|
||||
@@ -82,8 +82,7 @@
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"reqd": 1
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "shipping_amount_section",
|
||||
@@ -141,19 +140,20 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:41.653314",
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -197,7 +197,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,17 @@ class ShippingRule(Document):
|
||||
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
|
||||
ShippingRuleCondition,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
|
||||
ShippingRuleCountry,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
|
||||
|
||||
account: DF.Link
|
||||
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
|
||||
company: DF.Link
|
||||
conditions: DF.Table[ShippingRuleCondition]
|
||||
cost_center: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
countries: DF.Table[ShippingRuleCountry]
|
||||
disabled: DF.Check
|
||||
label: DF.Data
|
||||
project: DF.Link | None
|
||||
shipping_amount: DF.Currency
|
||||
shipping_rule_type: DF.Literal["Selling", "Buying"]
|
||||
# end: auto-generated types
|
||||
@@ -162,7 +161,14 @@ class ShippingRule(Document):
|
||||
)
|
||||
shipping_charge["add_deduct_tax"] = "Add"
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
|
||||
shipping_charge_filters = shipping_charge.copy()
|
||||
if not self.cost_center:
|
||||
shipping_charge_filters["cost_center"] = (
|
||||
"in",
|
||||
(None, "", erpnext.get_default_cost_center(doc.company)),
|
||||
)
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
|
||||
if existing_shipping_charge:
|
||||
# take the last record found
|
||||
existing_shipping_charge[-1].tax_amount = shipping_amount
|
||||
|
||||
@@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Subscription Plan Detail", {
|
||||
plan: function (frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (!row.plan) return;
|
||||
const requested_plan = row.plan;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
|
||||
args: {
|
||||
plan: requested_plan,
|
||||
company: frm.doc.company,
|
||||
party_type: frm.doc.party_type,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
|
||||
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
|
||||
for (const [dimension, value] of Object.entries(r.message)) {
|
||||
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
|
||||
frm.set_value(dimension, value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
|
||||
class InvoiceCancelled(frappe.ValidationError):
|
||||
@@ -253,6 +254,9 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
return
|
||||
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
elif (
|
||||
@@ -604,6 +608,11 @@ class Subscription(Document):
|
||||
1. `process_for_active`
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
# Snapshot before update_subscription_period() below can roll this forward,
|
||||
# so the cancel_at_period_end check further down still targets the period
|
||||
# that just ended, not the next one.
|
||||
current_period_end = self.current_invoice_end
|
||||
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
@@ -624,8 +633,8 @@ class Subscription(Document):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
getdate(posting_date) >= getdate(current_period_end)
|
||||
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
|
||||
):
|
||||
self.cancel_subscription()
|
||||
|
||||
@@ -801,6 +810,39 @@ def get_prorata_factor(
|
||||
return diff / plan_days
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_plan_dimensions(
|
||||
plan: str, company: str | None = None, party_type: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
|
||||
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
|
||||
|
||||
dimensions = {}
|
||||
for dimension in ["cost_center", *get_accounting_dimensions()]:
|
||||
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
|
||||
if value:
|
||||
dimensions[dimension] = value
|
||||
|
||||
return dimensions
|
||||
|
||||
|
||||
def get_item_dimension(
|
||||
item_code: str, dimension: str, company: str | None, party_type: str | None
|
||||
) -> str | None:
|
||||
if not company:
|
||||
return None
|
||||
|
||||
item_defaults = get_item_defaults(item_code, company)
|
||||
if dimension != "cost_center":
|
||||
return item_defaults.get(dimension)
|
||||
|
||||
selling = item_defaults.get("selling_cost_center")
|
||||
buying = item_defaults.get("buying_cost_center")
|
||||
if party_type == "Supplier":
|
||||
return buying or selling
|
||||
return selling or buying
|
||||
|
||||
|
||||
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
|
||||
"""
|
||||
Task to updates the status of all `Subscription` apart from those that are cancelled
|
||||
|
||||
@@ -17,7 +17,12 @@ from frappe.utils.data import (
|
||||
)
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
|
||||
from erpnext.accounts.doctype.subscription.subscription import (
|
||||
Subscription,
|
||||
get_plan_dimensions,
|
||||
get_prorata_factor,
|
||||
process_all,
|
||||
)
|
||||
from erpnext.accounts.utils import update_subscription_on_invoice_update
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -609,6 +614,32 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_subscription_cancels_at_period_end_without_end_date(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls
|
||||
# current_invoice_end forward to the next period before this check runs, so
|
||||
# with no end_date to fall back on, cancel_at_period_end must compare
|
||||
# against the period that just ended, not the (already advanced) next one.
|
||||
create_plan(
|
||||
plan_name="_Test plan name 11",
|
||||
cost=80,
|
||||
currency="INR",
|
||||
billing_interval="Day",
|
||||
billing_interval_count=3,
|
||||
)
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
cancel_at_period_end=1,
|
||||
generate_invoice_at="End of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 11", "qty": 1}],
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
period_end = subscription.current_invoice_end
|
||||
|
||||
subscription.process(posting_date=period_end)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
@@ -769,6 +800,38 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
cancel_at_period_end=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
cancelation_date = getdate(subscription.cancelation_date)
|
||||
self.assertIsNotNone(cancelation_date)
|
||||
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
|
||||
|
||||
invoice_count = len(subscription.invoices)
|
||||
subscription.process()
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoice_count)
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
@@ -804,6 +867,48 @@ class TestSubscription(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_plan_dimensions_resolve_from_plan_then_item(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
# Plan-level cost center takes precedence.
|
||||
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
|
||||
frappe.db.set_value(
|
||||
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
|
||||
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
|
||||
item = make_item(
|
||||
"_Test Sub Dimension Item",
|
||||
{
|
||||
"is_stock_item": 0,
|
||||
"item_defaults": [
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"default_warehouse": "_Test Warehouse - _TC",
|
||||
"selling_cost_center": "_Test Cost Center - _TC",
|
||||
"buying_cost_center": "_Test Cost Center 2 - _TC",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
|
||||
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
|
||||
"_Test Cost Center 2 - _TC",
|
||||
)
|
||||
|
||||
# Without a company the item fallback is skipped.
|
||||
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -854,11 +854,13 @@ def validate_account_party_type(self):
|
||||
|
||||
|
||||
def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
if not frappe.has_permission(doctype, "read"):
|
||||
return None
|
||||
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
|
||||
companies = frappe.get_all(
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
@@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
{
|
||||
fieldname: "supplier_group",
|
||||
label: __("Supplier Group"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Supplier Group",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Supplier Group", txt);
|
||||
},
|
||||
hidden: 1,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -117,6 +117,36 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_supplier_group_filter(self):
|
||||
pi = self.create_purchase_invoice()
|
||||
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
|
||||
other_group = frappe.get_doc(
|
||||
doctype="Supplier Group",
|
||||
supplier_group_name="_Test Supplier Group AP",
|
||||
parent_supplier_group="All Supplier Groups",
|
||||
).insert()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"supplier_group": supplier_group,
|
||||
}
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": [other_group.name]})
|
||||
self.assertEqual(len(execute(filters)[1]), 0)
|
||||
|
||||
filters.update({"supplier_group": [supplier_group, other_group.name]})
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": ["All Supplier Groups"]})
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": ["_Test Supplier Group Mars"]})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
@@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
{
|
||||
fieldname: "supplier_group",
|
||||
label: __("Supplier Group"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Supplier Group",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Supplier Group", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "based_on_payment_terms",
|
||||
|
||||
@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
@@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
{
|
||||
fieldname: "territory",
|
||||
label: __("Territory"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Territory",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Territory", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "group_by_party",
|
||||
|
||||
@@ -55,8 +55,7 @@ class ReceivablePayableReport:
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
if "calculate_ageing_with" not in self.filters
|
||||
or self.filters.calculate_ageing_with == "Today Date"
|
||||
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
|
||||
else self.filters.report_date
|
||||
)
|
||||
|
||||
@@ -109,6 +108,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_data(self):
|
||||
self.get_sales_invoices_or_customers_based_on_sales_person()
|
||||
self.get_invoices_based_on_sales_partner()
|
||||
|
||||
# Get invoice details like bill_no, due_date etc for all invoices
|
||||
self.get_invoice_details()
|
||||
@@ -244,6 +244,12 @@ class ReceivablePayableReport:
|
||||
):
|
||||
return
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
# a return is folded onto the invoice it settles, so match that invoice's
|
||||
# partner (like the sales_person filter above), not the return's own
|
||||
if ple.against_voucher_no not in self.sales_partner_invoices:
|
||||
return
|
||||
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
|
||||
else:
|
||||
@@ -472,7 +478,7 @@ class ReceivablePayableReport:
|
||||
"company": self.filters.company,
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["name", "due_date", "po_no"],
|
||||
fields=["name", "due_date", "po_no", "sales_partner"],
|
||||
)
|
||||
for d in si_list:
|
||||
self.invoice_details.setdefault(d.name, d)
|
||||
@@ -910,6 +916,22 @@ class ReceivablePayableReport:
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
return
|
||||
|
||||
self.sales_partner_invoices = set(
|
||||
frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={
|
||||
"sales_partner": self.filters.get("sales_partner"),
|
||||
"docstatus": 1,
|
||||
"company": self.filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
def prepare_conditions(self):
|
||||
self.qb_selection_filter = []
|
||||
self.or_filters = []
|
||||
@@ -997,7 +1019,13 @@ class ReceivablePayableReport:
|
||||
self.qb_selection_filter.append(self.ple.party.isin(customers))
|
||||
|
||||
if self.filters.get("territory"):
|
||||
self.get_hierarchical_filters("Territory", "territory")
|
||||
territories = get_nested_set_children("Territory", self.filters.territory)
|
||||
customers = (
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer["territory"].isin(territories))
|
||||
)
|
||||
self.qb_selection_filter.append(self.ple.party.isin(customers))
|
||||
|
||||
if self.filters.get("payment_terms_template"):
|
||||
customer_ptt = self.ple.party.isin(
|
||||
@@ -1012,26 +1040,16 @@ class ReceivablePayableReport:
|
||||
|
||||
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
|
||||
)
|
||||
)
|
||||
|
||||
def exclude_employee_transaction(self):
|
||||
self.qb_selection_filter.append(self.ple.party_type != "Employee")
|
||||
|
||||
def add_supplier_filters(self):
|
||||
supplier = qb.DocType("Supplier")
|
||||
if self.filters.get("supplier_group"):
|
||||
groups = get_party_group_with_children("Supplier", self.filters.supplier_group)
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.supplier_group == self.filters.get("supplier_group"))
|
||||
qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1083,16 +1101,6 @@ class ReceivablePayableReport:
|
||||
|
||||
return ptt
|
||||
|
||||
def get_hierarchical_filters(self, doctype, key):
|
||||
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
|
||||
|
||||
doc = qb.DocType(doctype)
|
||||
ple = self.ple
|
||||
customer = self.customer
|
||||
groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
|
||||
customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
|
||||
self.qb_selection_filter.append(ple.party.isin(customers))
|
||||
|
||||
def add_accounting_dimensions_filters(self):
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
|
||||
@@ -1120,9 +1128,6 @@ class ReceivablePayableReport:
|
||||
if self.account_type == "Receivable":
|
||||
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
fields.append("default_sales_partner")
|
||||
|
||||
self.party_details[party] = frappe.db.get_value(
|
||||
"Customer",
|
||||
party,
|
||||
@@ -1252,7 +1257,7 @@ class ReceivablePayableReport:
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
if self.filters.account_type == "Payable":
|
||||
self.add_column(
|
||||
@@ -1339,19 +1344,23 @@ def get_party_group_with_children(party, party_groups):
|
||||
if party not in ("Customer", "Supplier"):
|
||||
return []
|
||||
|
||||
group_dtype = f"{party} Group"
|
||||
if not isinstance(party_groups, list):
|
||||
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
|
||||
return get_nested_set_children(f"{party} Group", party_groups)
|
||||
|
||||
all_party_groups = []
|
||||
for d in party_groups:
|
||||
if frappe.db.exists(group_dtype, d):
|
||||
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(
|
||||
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
|
||||
)
|
||||
all_party_groups += children
|
||||
|
||||
def get_nested_set_children(doctype, values):
|
||||
if not isinstance(values, list):
|
||||
values = [d.strip() for d in values.split(",") if d.strip()]
|
||||
|
||||
if not values:
|
||||
frappe.throw(_("Please select a valid {0}").format(_(doctype)))
|
||||
|
||||
all_values = []
|
||||
for d in values:
|
||||
if frappe.db.exists(doctype, d):
|
||||
lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name")
|
||||
all_values += children
|
||||
else:
|
||||
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
|
||||
frappe.throw(_("{0}: {1} does not exist").format(doctype, d))
|
||||
|
||||
return list(set(all_party_groups))
|
||||
return list(set(all_values))
|
||||
|
||||
@@ -6,6 +6,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -778,6 +779,38 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
# Assert that the customer group of each row is in the list of customer groups
|
||||
self.assertIn(row.customer_group, cus_groups_list)
|
||||
|
||||
def test_territory_filter(self):
|
||||
self.create_sales_invoice()
|
||||
territory = frappe.db.get_value("Customer", self.customer, "territory")
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"territory": territory,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
self.assertEqual(
|
||||
[100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory]
|
||||
)
|
||||
|
||||
filters.update({"territory": ["_Test Territory United States"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 0)
|
||||
|
||||
filters.update({"territory": [territory, "_Test Territory United States"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 1)
|
||||
|
||||
frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra")
|
||||
filters.update({"territory": ["_Test Territory India"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 1)
|
||||
|
||||
filters.update({"territory": ["_Test Territory Mars"]})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
filters.update({"territory": " "})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
def test_party_account_filter(self):
|
||||
si1 = self.create_sales_invoice()
|
||||
jane = frappe.get_doc(
|
||||
@@ -1292,3 +1325,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIn(original_customer, parties)
|
||||
self.assertNotIn(second_customer, parties)
|
||||
self.assertEqual(allowed_invoice.customer, original_customer)
|
||||
|
||||
def test_receivable_filtered_by_sales_partner(self):
|
||||
frappe.set_user("Administrator")
|
||||
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
|
||||
for partner in (partner_a, partner_b):
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _si(sales_partner):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
|
||||
si.sales_partner = sales_partner
|
||||
return si.save().submit()
|
||||
|
||||
partner_a_si = _si(partner_a)
|
||||
partner_b_si = _si(partner_b)
|
||||
no_partner_si = _si(None)
|
||||
|
||||
# a return is folded onto the invoice it settles, so it nets against that
|
||||
# invoice's partner even when the return's own partner is cleared
|
||||
no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
|
||||
no_partner_return.sales_partner = None
|
||||
no_partner_return.items[0].qty = -1
|
||||
no_partner_return.update_outstanding_for_self = 0
|
||||
no_partner_return.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
def rows_for(partner):
|
||||
return {
|
||||
r.voucher_no: r
|
||||
for r in execute({**filters, "sales_partner": partner})[1]
|
||||
if r.get("voucher_no")
|
||||
}
|
||||
|
||||
rows_a = rows_for(partner_a)
|
||||
self.assertIn(partner_a_si.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
|
||||
self.assertNotIn(partner_b_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_return.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
|
||||
self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
|
||||
|
||||
rows_b = rows_for(partner_b)
|
||||
self.assertIn(partner_b_si.name, rows_b)
|
||||
self.assertNotIn(partner_a_si.name, rows_b)
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
@@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
{
|
||||
fieldname: "territory",
|
||||
label: __("Territory"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Territory",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Territory", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "sales_partner",
|
||||
|
||||
@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
if row.sales_person:
|
||||
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
|
||||
if self.filters.sales_partner and row.get("sales_partner"):
|
||||
self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
|
||||
|
||||
def get_columns(self):
|
||||
self.columns = []
|
||||
@@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
else:
|
||||
self.add_column(
|
||||
|
||||
@@ -191,3 +191,42 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
report = execute(filters)
|
||||
rpt_output = report[1]
|
||||
self.assertEqual(len(rpt_output), 0)
|
||||
|
||||
def test_03_summary_sales_partner_column(self):
|
||||
partner = "_Test AR Summary Sales Partner"
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=200,
|
||||
price_list_rate=200,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si.sales_partner = partner
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"customer": self.customer,
|
||||
"posting_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"sales_partner": partner,
|
||||
}
|
||||
|
||||
rpt_output = execute(filters)[1]
|
||||
self.assertEqual(len(rpt_output), 1)
|
||||
self.assertEqual(rpt_output[0].get("sales_partner"), partner)
|
||||
|
||||
@@ -80,6 +80,7 @@ def execute(filters=None):
|
||||
"parent_section": None,
|
||||
"indent": 0.0,
|
||||
"section": cash_flow_section["section_header"],
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
)
|
||||
if total_base_amount
|
||||
else 0,
|
||||
"currency": filters.currency,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
||||
"buying_amount": total_buying_amount,
|
||||
"gross_profit": total_gross_profit,
|
||||
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
|
||||
"currency": filters.currency,
|
||||
}
|
||||
|
||||
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]
|
||||
|
||||
@@ -5,6 +5,8 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import IfNull
|
||||
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
|
||||
|
||||
class TaxWithholdingDetailsReport:
|
||||
party_types = ("Customer", "Supplier")
|
||||
@@ -25,11 +27,7 @@ class TaxWithholdingDetailsReport:
|
||||
return self.get_columns(), self.get_data()
|
||||
|
||||
def validate_filters(self):
|
||||
if not self.filters.from_date or not self.filters.to_date:
|
||||
frappe.throw(_("From Date and To Date are required"))
|
||||
|
||||
if self.filters.from_date > self.filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(self.filters)
|
||||
|
||||
def get_data(self):
|
||||
self.entries = self.get_entries_query().run(as_dict=True)
|
||||
|
||||
@@ -21,8 +21,7 @@ class TDSComputationSummaryReport(TaxWithholdingDetailsReport):
|
||||
AGGREGATE_FIELDS = ("total_amount", "tax_amount")
|
||||
|
||||
def validate_filters(self):
|
||||
if self.filters.from_date > self.filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
super().validate_filters()
|
||||
|
||||
from_year = get_fiscal_year(self.filters.from_date)[0]
|
||||
to_year = get_fiscal_year(self.filters.to_date)[0]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, formatdate, get_datetime_str, get_table_name
|
||||
@@ -16,6 +17,19 @@ from erpnext.setup.utils import get_exchange_rate
|
||||
__exchange_rates = {}
|
||||
|
||||
|
||||
def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"):
|
||||
from_date = filters.get(from_field)
|
||||
to_date = filters.get(to_field)
|
||||
|
||||
if not from_date or not to_date:
|
||||
frappe.throw(
|
||||
_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))
|
||||
)
|
||||
|
||||
if from_date > to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
|
||||
|
||||
def get_currency(filters):
|
||||
"""
|
||||
Returns a dictionary containing currency information. The keys of the dict are
|
||||
|
||||
@@ -1332,7 +1332,7 @@ def has_active_capitalization(asset):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
def get_values_from_purchase_doc(purchase_doc_name: str, item_code: str, doctype: str):
|
||||
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
|
||||
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
|
||||
|
||||
@@ -1344,7 +1344,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
"purchase_date": purchase_doc.get("posting_date"),
|
||||
"net_purchase_amount": flt(first_item.base_net_amount),
|
||||
"net_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
"asset_location": first_item.get("asset_location"),
|
||||
|
||||
@@ -668,11 +668,13 @@ def get_target_asset_details(asset: str | None = None, company: str | None = Non
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
|
||||
frappe.has_permission("Stock Ledger Entry", throw=True)
|
||||
out = frappe._dict()
|
||||
|
||||
item = frappe._dict()
|
||||
if ctx.item_code:
|
||||
item = frappe.get_cached_doc("Item", ctx.item_code)
|
||||
item.check_permission()
|
||||
|
||||
out.item_name = item.item_name
|
||||
out.batch_no = None
|
||||
@@ -682,6 +684,8 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
|
||||
out.stock_uom = item.stock_uom
|
||||
|
||||
out.warehouse = get_item_warehouse_(ctx, item, overwrite_warehouse=True) if item else None
|
||||
if out.warehouse:
|
||||
frappe.has_permission("Warehouse", doc=out.warehouse, throw=True)
|
||||
|
||||
# Cost Center
|
||||
item_defaults = get_item_defaults(item.name, ctx.company)
|
||||
@@ -722,6 +726,9 @@ def get_warehouse_details(args):
|
||||
|
||||
out = {}
|
||||
if args.warehouse and args.item_code:
|
||||
frappe.has_permission("Item", doc=args.item_code, throw=True)
|
||||
frappe.has_permission("Warehouse", doc=args.warehouse, throw=True)
|
||||
frappe.has_permission("Stock Ledger Entry", throw=True)
|
||||
out = {
|
||||
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
|
||||
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),
|
||||
|
||||
@@ -162,6 +162,21 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po2.items[0].qty = 110
|
||||
self.assertRaises(OverAllowanceError, po2.submit)
|
||||
|
||||
# Stock over-delivery role must not bypass over-ordering against Material Request.
|
||||
with self.change_settings(
|
||||
"Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"}
|
||||
):
|
||||
test_user = frappe.get_doc("User", "test@example.com")
|
||||
test_user.add_roles("Stock Manager")
|
||||
|
||||
mr3 = make_material_request(qty=100)
|
||||
po3 = make_purchase_order(mr3.name)
|
||||
po3.supplier = "_Test Supplier"
|
||||
po3.items[0].qty = 110
|
||||
with self.set_user("test@example.com"):
|
||||
po3.flags.ignore_permissions = True
|
||||
self.assertRaises(OverAllowanceError, po3.submit)
|
||||
|
||||
# cleanup
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
|
||||
@@ -996,6 +1011,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
|
||||
compare_payment_schedules(self, po, pi)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1})
|
||||
def test_internal_transfer_flow(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
@@ -1007,9 +1024,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
)
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
|
||||
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
supplier = "_Test Internal Supplier 2"
|
||||
|
||||
@@ -1448,6 +1462,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
self.assertEqual(pi_2.status, "Paid")
|
||||
self.assertEqual(po.status, "Completed")
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0})
|
||||
def test_purchase_order_over_billing_missing_item(self):
|
||||
item1 = make_item(
|
||||
"_Test Item for Overbilling",
|
||||
|
||||
@@ -14,7 +14,6 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Purchase Order")
|
||||
data = get_data(filters, conditions)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
@@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -1893,7 +1893,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
def is_payable_account(self, reference_doctype, account):
|
||||
if reference_doctype == "Purchase Invoice" or (
|
||||
reference_doctype == "Journal Entry"
|
||||
reference_doctype in ("Journal Entry", "Payment Entry")
|
||||
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
|
||||
):
|
||||
return True
|
||||
@@ -3873,6 +3873,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
d.flags.ignore_permissions = True
|
||||
d.cancel()
|
||||
d.delete()
|
||||
|
||||
|
||||
@@ -331,32 +331,51 @@ class BuyingController(SubcontractingController):
|
||||
address_display_field, render_address(self.get(address_field), check_permissions=False)
|
||||
)
|
||||
|
||||
def get_validated_purchase_expense_details(self, item_code):
|
||||
fields = ("purchase_expense_account", "purchase_expense_contra_account")
|
||||
details = get_purchase_expense_account(item_code, self.company)
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
details[field] = frappe.get_cached_value("Company", self.company, field)
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
frappe.throw(
|
||||
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
|
||||
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
|
||||
)
|
||||
)
|
||||
|
||||
return details
|
||||
|
||||
def set_gl_entry_for_purchase_expense(self, gl_entries):
|
||||
if not cint(frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")):
|
||||
return
|
||||
|
||||
if self.doctype == "Purchase Invoice" and not self.update_stock:
|
||||
return
|
||||
|
||||
stock_items = self.get_stock_items()
|
||||
|
||||
for row in self.items:
|
||||
details = get_purchase_expense_account(row.item_code, self.company)
|
||||
# A service item holds no stock value, so there is nothing to book against it - and it
|
||||
# must not make the expense accounts mandatory either.
|
||||
if row.item_code not in stock_items:
|
||||
continue
|
||||
|
||||
if not details.purchase_expense_account:
|
||||
details.purchase_expense_account = frappe.get_cached_value(
|
||||
"Company", self.company, "purchase_expense_account"
|
||||
)
|
||||
|
||||
if not details.purchase_expense_account:
|
||||
return
|
||||
|
||||
if not details.purchase_expense_contra_account:
|
||||
details.purchase_expense_contra_account = frappe.get_cached_value(
|
||||
"Company", self.company, "purchase_expense_contra_account"
|
||||
)
|
||||
|
||||
if not details.purchase_expense_contra_account:
|
||||
frappe.throw(
|
||||
_("Please set Purchase Expense Contra Account in Company {0}").format(self.company)
|
||||
)
|
||||
details = self.get_validated_purchase_expense_details(row.item_code)
|
||||
if not details:
|
||||
continue
|
||||
|
||||
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount"))
|
||||
if row.landed_cost_voucher_amount:
|
||||
amount -= flt(row.landed_cost_voucher_amount, row.precision("base_amount"))
|
||||
|
||||
if not amount:
|
||||
# GL Entry rejects a row with neither a debit nor a credit.
|
||||
continue
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=details.purchase_expense_account,
|
||||
@@ -448,7 +467,7 @@ class BuyingController(SubcontractingController):
|
||||
self.precision("item_tax_amount", item),
|
||||
)
|
||||
|
||||
self.round_floats_in(item)
|
||||
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
|
||||
if flt(item.conversion_factor) == 0.0:
|
||||
item.conversion_factor = (
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
|
||||
@@ -336,6 +336,7 @@ def create_variant(item, args, use_template_image=False):
|
||||
|
||||
@frappe.whitelist()
|
||||
def enqueue_multiple_variant_creation(item, args, use_template_image=False):
|
||||
frappe.has_permission("Item", ptype="create", throw=True)
|
||||
use_template_image = frappe.parse_json(use_template_image)
|
||||
# There can be innumerable attribute combinations, enqueue
|
||||
if isinstance(args, str):
|
||||
|
||||
@@ -332,7 +332,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_project_name(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
|
||||
):
|
||||
proj = qb.DocType("Project")
|
||||
qb_filter_and_conditions = []
|
||||
qb_filter_or_conditions = []
|
||||
@@ -347,7 +349,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
if filters.get("company"):
|
||||
qb_filter_and_conditions.append(proj.company == filters.get("company"))
|
||||
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
|
||||
|
||||
q = qb.from_(proj)
|
||||
|
||||
|
||||
@@ -159,10 +159,28 @@ def validate_returned_items(doc):
|
||||
):
|
||||
frappe.throw(_("Warehouse is mandatory"))
|
||||
|
||||
items_returned = True
|
||||
if doc.doctype in (
|
||||
"Purchase Invoice",
|
||||
"Purchase Receipt",
|
||||
"Subcontracting Receipt",
|
||||
"Sales Invoice",
|
||||
"Delivery Note",
|
||||
"POS Invoice",
|
||||
):
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
|
||||
elif d.item_name:
|
||||
items_returned = True
|
||||
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
|
||||
# No item_code here means no linked Item, so there's no accepted/rejected
|
||||
# split to speak of - received_qty isn't a meaningful independent signal.
|
||||
# Only a negative qty (i.e. a real negative billing amount) counts.
|
||||
if flt(d.qty) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
|
||||
if not items_returned:
|
||||
frappe.throw(_("At least one item should be entered with negative quantity in return document"))
|
||||
|
||||
@@ -445,11 +445,12 @@ class StatusUpdater(Document):
|
||||
else (0, {}, None, None)
|
||||
)
|
||||
|
||||
role_allowed_to_over_deliver_receive = frappe.get_single_value(
|
||||
"Stock Settings", "role_allowed_to_over_deliver_receive"
|
||||
)
|
||||
role_allowed_to_over_bill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
|
||||
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
|
||||
role = None
|
||||
if qty_or_amount == "qty":
|
||||
if args.get("overflow_type") in ("delivery", "receipt"):
|
||||
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
|
||||
else:
|
||||
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
|
||||
|
||||
overflow_percent = (
|
||||
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]
|
||||
|
||||
@@ -70,9 +70,23 @@ QI_OUTGOING_PURPOSES = (
|
||||
)
|
||||
|
||||
|
||||
SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
|
||||
|
||||
|
||||
def is_inspection_exempt_secondary_row(doc, row) -> bool:
|
||||
"""Whether the row is a secondary item on a document that produces secondary items."""
|
||||
if not (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
return False
|
||||
|
||||
if doc.doctype == "Stock Entry":
|
||||
return doc.purpose in SECONDARY_ITEM_PURPOSES
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def stock_entry_row_requires_inspection(purpose, row):
|
||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
return False
|
||||
if purpose == "Manufacture":
|
||||
return bool(row.is_finished_item)
|
||||
@@ -84,6 +98,11 @@ def stock_entry_row_requires_inspection(purpose, row):
|
||||
|
||||
|
||||
class StockController(AccountsController):
|
||||
#: Vouchers whose stock value change should also be booked to the Expenses Added To Stock
|
||||
#: account pair (Stock Entry, Stock Reconciliation). Purchase Receipt books its own, against
|
||||
#: the landed cost amount rather than the stock value difference.
|
||||
book_expenses_added_to_stock = False
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
|
||||
@@ -858,10 +877,90 @@ class StockController(AccountsController):
|
||||
).format(wh, self.company)
|
||||
)
|
||||
|
||||
if self.book_expenses_added_to_stock:
|
||||
self.append_expenses_added_to_stock_entries(gl_list, voucher_details, sle_map)
|
||||
|
||||
return process_gl_map(
|
||||
gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation
|
||||
)
|
||||
|
||||
def book_stock_expense_enabled(self):
|
||||
if not hasattr(self, "_book_stock_expense_enabled"):
|
||||
self._book_stock_expense_enabled = cint(
|
||||
frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
|
||||
)
|
||||
|
||||
return self._book_stock_expense_enabled
|
||||
|
||||
def append_expenses_added_to_stock_entries(self, gl_list, voucher_details, sle_map):
|
||||
if not self.book_stock_expense_enabled():
|
||||
return
|
||||
|
||||
precision = self.get_debit_field_precision()
|
||||
|
||||
for item_row in voucher_details:
|
||||
sle_list = sle_map.get(item_row.name)
|
||||
if not sle_list:
|
||||
continue
|
||||
|
||||
amount = flt(sum(flt(sle.stock_value_difference) for sle in sle_list), precision)
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
item_code = item_row.get("item_code") or sle_list[0].item_code
|
||||
self.append_expenses_added_to_stock_pair(gl_list, item_code, amount, item_row)
|
||||
|
||||
def append_expenses_added_to_stock_pair(self, gl_list, item_code, amount, item_row):
|
||||
# A service item holds no stock value, so there is nothing to book against it - and it must
|
||||
# not make the expense accounts mandatory either. A zero pair would be rejected by GL Entry
|
||||
# anyway, which needs a debit or a credit on every row.
|
||||
if not amount or not frappe.get_cached_value("Item", item_code, "is_stock_item"):
|
||||
return
|
||||
|
||||
fields = ("expenses_added_to_stock_account", "expenses_added_to_stock_contra_account")
|
||||
details = get_expenses_added_to_stock_accounts(item_code, self.company)
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
frappe.throw(
|
||||
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
|
||||
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
|
||||
)
|
||||
)
|
||||
|
||||
cost_center = item_row.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", self.company, "cost_center"
|
||||
)
|
||||
remarks = _("Expenses Added To Stock for Item {0}").format(item_code)
|
||||
common_args = {
|
||||
"cost_center": cost_center,
|
||||
"project": item_row.get("project") or self.get("project"),
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
gl_list.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": details.expenses_added_to_stock_account,
|
||||
"against": details.expenses_added_to_stock_contra_account,
|
||||
"debit": amount,
|
||||
**common_args,
|
||||
},
|
||||
item=item_row,
|
||||
)
|
||||
)
|
||||
gl_list.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": details.expenses_added_to_stock_contra_account,
|
||||
"against": details.expenses_added_to_stock_account,
|
||||
"debit": -1 * amount,
|
||||
**common_args,
|
||||
},
|
||||
item=item_row,
|
||||
)
|
||||
)
|
||||
|
||||
def get_debit_field_precision(self):
|
||||
if not frappe.flags.debit_field_precision:
|
||||
frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency")
|
||||
@@ -1371,8 +1470,9 @@ class StockController(AccountsController):
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "qty")
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, 6) <= 0:
|
||||
if flt(reserved_qty, precision) <= 0:
|
||||
continue
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
@@ -1383,7 +1483,7 @@ class StockController(AccountsController):
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
|
||||
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
@@ -1518,7 +1618,7 @@ class StockController(AccountsController):
|
||||
elif self.doctype == "Stock Entry":
|
||||
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
|
||||
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
if is_inspection_exempt_secondary_row(self, row):
|
||||
continue
|
||||
|
||||
if qi_required: # validate row only if inspection is required on item level
|
||||
@@ -2567,3 +2667,31 @@ def get_item_wise_inventory_account_map(rows, company):
|
||||
)
|
||||
|
||||
return inventory_map
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_expenses_added_to_stock_accounts(item_code, company):
|
||||
"""Resolves the Expenses Added To Stock account pair for an item, falling back through
|
||||
Item Defaults -> Item Group -> Brand -> Company."""
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
fields = ["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"]
|
||||
defaults = get_item_defaults(item_code, company)
|
||||
|
||||
details = frappe._dict({field: defaults.get(field) for field in fields})
|
||||
|
||||
if not details.expenses_added_to_stock_account:
|
||||
details = frappe.db.get_value(
|
||||
"Item Default", {"parent": defaults.item_group, "company": company}, fields, as_dict=1
|
||||
) or frappe._dict({})
|
||||
|
||||
if not details.expenses_added_to_stock_account and defaults.get("brand"):
|
||||
details = frappe.db.get_value(
|
||||
"Item Default", {"parent": defaults.brand, "company": company}, fields, as_dict=1
|
||||
) or frappe._dict({})
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
details[field] = frappe.get_cached_value("Company", company, field)
|
||||
|
||||
return details
|
||||
|
||||
@@ -227,7 +227,12 @@ class calculate_taxes_and_totals:
|
||||
if self.doc.get("is_consolidated") or self.discount_amount_applied:
|
||||
return
|
||||
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
|
||||
do_not_round_fields = [
|
||||
"valuation_rate",
|
||||
"incoming_rate",
|
||||
"sales_incoming_rate",
|
||||
"conversion_factor",
|
||||
]
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
self.calculate_item_rate(item)
|
||||
|
||||
90
erpnext/controllers/tests/test_sales_and_purchase_return.py
Normal file
90
erpnext/controllers/tests/test_sales_and_purchase_return.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSalesAndPurchaseReturn(ERPNextTestSuite):
|
||||
@staticmethod
|
||||
def _cancel_and_delete(doctype, name):
|
||||
if not frappe.db.exists(doctype, name):
|
||||
return
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
frappe.delete_doc(doctype, name, force=1)
|
||||
|
||||
def test_purchase_invoice_zero_qty_return_is_rejected(self):
|
||||
# A return with every item at qty 0 moves no stock and no value, so it must be
|
||||
# rejected the same way a return with no items at all would be.
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
pi = make_purchase_invoice(qty=10)
|
||||
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=0,
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_pi.save)
|
||||
|
||||
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
|
||||
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
|
||||
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
|
||||
# item_name-only branch, which must also reject an all-zero-qty return instead
|
||||
# of unconditionally treating the row as returned.
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
|
||||
pi.items[0].item_code = ""
|
||||
pi.save()
|
||||
pi.submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
item_name="_Test Item",
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=0,
|
||||
do_not_save=True,
|
||||
)
|
||||
return_pi.items[0].item_code = ""
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_pi.save)
|
||||
|
||||
def test_delivery_note_zero_qty_return_is_rejected(self):
|
||||
# A return with every item at qty 0 moves no stock and no value, so it must be
|
||||
# rejected the same way a return with no items at all would be.
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
|
||||
|
||||
dn = create_delivery_note(qty=5)
|
||||
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_dn.insert)
|
||||
|
||||
def test_sales_invoice_zero_qty_return_is_rejected(self):
|
||||
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
|
||||
# every row must be rejected, not silently accepted as a no-op credit note.
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
si = create_sales_invoice(qty=10)
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
return_si = make_return_doc(si.doctype, si.name)
|
||||
return_si.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_si.save)
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import DateTimeLikeObject, getdate, today
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
@@ -42,6 +43,9 @@ def get_columns(filters, trans):
|
||||
"addl_tables": based_on_details["addl_tables"],
|
||||
"addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""),
|
||||
}
|
||||
conditions["company_currency"] = (
|
||||
erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None
|
||||
)
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -206,7 +210,7 @@ def get_data(filters, conditions):
|
||||
|
||||
data.append(des)
|
||||
|
||||
total_row = calculate_total_row(data1, conditions["columns"])
|
||||
total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency"))
|
||||
data.append(total_row)
|
||||
else:
|
||||
data = frappe.db.sql(
|
||||
@@ -231,19 +235,24 @@ def get_data(filters, conditions):
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
total_row = calculate_total_row(data, conditions["columns"])
|
||||
total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency"))
|
||||
data.append(total_row)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def calculate_total_row(data, columns):
|
||||
def calculate_total_row(data, columns, company_currency=None):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
total_values = {}
|
||||
currency_col_idx = None
|
||||
for i, col in enumerate(columns):
|
||||
if "Float" in col or "Currency/currency" in col:
|
||||
# based-on and group-by columns are dicts, periodic and total columns are strings
|
||||
if isinstance(col, dict):
|
||||
if col.get("fieldtype") == "Link" and col.get("options") == "Currency":
|
||||
currency_col_idx = i
|
||||
elif "Float" in col or "Currency/currency" in col:
|
||||
total_values[i] = 0
|
||||
|
||||
for row in data:
|
||||
@@ -254,6 +263,9 @@ def calculate_total_row(data, columns):
|
||||
for i in range(1, len(columns)):
|
||||
total_row.append(total_values.get(i, None))
|
||||
|
||||
if currency_col_idx is not None:
|
||||
total_row[currency_col_idx] = company_currency
|
||||
|
||||
return total_row
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
|
||||
if not self.title:
|
||||
@@ -144,6 +145,15 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
def on_update(self):
|
||||
self.update_prospect()
|
||||
|
||||
def validate_qty(self):
|
||||
for item in self.items:
|
||||
if flt(item.qty) <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
|
||||
item.idx, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def map_fields(self):
|
||||
for field in self.meta.get_valid_columns():
|
||||
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
|
||||
@@ -279,13 +289,17 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.save()
|
||||
|
||||
else:
|
||||
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
|
||||
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
|
||||
|
||||
def has_active_quotation(self):
|
||||
if not self.get("items", []):
|
||||
return frappe.get_all(
|
||||
"Quotation",
|
||||
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
|
||||
{
|
||||
"opportunity": self.name,
|
||||
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
@@ -294,14 +308,20 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status not in ('Lost', 'Closed')""",
|
||||
and q.status not in ('Lost', 'Cancelled', 'Expired')""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def has_ordered_quotation(self):
|
||||
if not self.get("items", []):
|
||||
return frappe.get_all(
|
||||
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
|
||||
"Quotation",
|
||||
{
|
||||
"opportunity": self.name,
|
||||
"status": ("in", ["Ordered", "Partially Ordered"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
return frappe.db.sql(
|
||||
@@ -309,7 +329,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status = 'Ordered'""",
|
||||
and q.status in ('Ordered', 'Partially Ordered')""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
|
||||
3660
erpnext/locale/ar.po
3660
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/bg.po
3654
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3782
erpnext/locale/bs.po
3782
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/cs.po
3654
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
22193
erpnext/locale/da.po
22193
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3662
erpnext/locale/de.po
3662
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/eo.po
3664
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3660
erpnext/locale/es.po
3660
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3730
erpnext/locale/fa.po
3730
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3658
erpnext/locale/fr.po
3658
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/hi.po
3656
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/hr.po
3664
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/hu.po
3656
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/id.po
3656
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/it.po
3654
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/ko.po
3656
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/my.po
3654
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/nb.po
3656
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/nl.po
3664
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3656
erpnext/locale/pl.po
3656
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3654
erpnext/locale/pt.po
3654
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
63124
erpnext/locale/ro.po
Normal file
63124
erpnext/locale/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
3666
erpnext/locale/ru.po
3666
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3912
erpnext/locale/sl.po
3912
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3662
erpnext/locale/sr.po
3662
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3690
erpnext/locale/sv.po
3690
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3662
erpnext/locale/th.po
3662
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3660
erpnext/locale/tr.po
3660
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/uz.po
3664
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3664
erpnext/locale/vi.po
3664
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
20554
erpnext/locale/zh.po
20554
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user