mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-14 10:48:48 +00:00
Compare commits
174 Commits
v16.26.1
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a38316c26 | ||
|
|
fd6c9a71cd | ||
|
|
d7e9321746 | ||
|
|
e8a532587d | ||
|
|
8d7fe755e3 | ||
|
|
2947f030e8 | ||
|
|
cec1e87c90 | ||
|
|
345c508be7 | ||
|
|
5a99dd6016 | ||
|
|
3b7467daab | ||
|
|
db737da22e | ||
|
|
138a44e450 | ||
|
|
40c85a0087 | ||
|
|
4f59e57151 | ||
|
|
0447a18217 | ||
|
|
5991ecfa3d | ||
|
|
77cca4464d | ||
|
|
0d9ace7ab8 | ||
|
|
00a646ac25 | ||
|
|
aa1d50a914 | ||
|
|
c03d115999 | ||
|
|
97a7a2d6bc | ||
|
|
d784b9a7be | ||
|
|
88b7a38be4 | ||
|
|
21ddb00e20 | ||
|
|
636bcbedc0 | ||
|
|
19ec095ff8 | ||
|
|
2f6ef7b2ec | ||
|
|
d41b9f11ff | ||
|
|
98a65f7529 | ||
|
|
228418b05f | ||
|
|
08e267271e | ||
|
|
bf1b7f2bea | ||
|
|
6bd903e45a | ||
|
|
5281d538ce | ||
|
|
5261566b41 | ||
|
|
5bbb71ef76 | ||
|
|
d453781374 | ||
|
|
ec782ee20d | ||
|
|
703e9a728c | ||
|
|
0d80fef3bf | ||
|
|
7b543142a2 | ||
|
|
90768c42d8 | ||
|
|
fd3bebee02 | ||
|
|
431ae6bc93 | ||
|
|
9d6f15acd4 | ||
|
|
8658039e9a | ||
|
|
4aed77df0c | ||
|
|
9e95bfe472 | ||
|
|
b6821c0ee5 | ||
|
|
51d2077eed | ||
|
|
5642359c10 | ||
|
|
4136ca55a7 | ||
|
|
2e892be1c1 | ||
|
|
edfbc71135 | ||
|
|
62fed1d562 | ||
|
|
e2fd061b3d | ||
|
|
835e32cec7 | ||
|
|
04d982fa0d | ||
|
|
e6a6c13355 | ||
|
|
24f0989ac1 | ||
|
|
240fb2c4b8 | ||
|
|
8eb92b8b18 | ||
|
|
755b9ccbc3 | ||
|
|
67c85ef0af | ||
|
|
bdba4c8091 | ||
|
|
c56eb2635a | ||
|
|
f96166af5b | ||
|
|
b8199d88b6 | ||
|
|
20255a8a7f | ||
|
|
4711a28dd0 | ||
|
|
63e5117182 | ||
|
|
051757760f | ||
|
|
277c651a9f | ||
|
|
39b5e12305 | ||
|
|
e1e6176ddc | ||
|
|
7e46be2a33 | ||
|
|
2264e25cc6 | ||
|
|
7f64dfbccc | ||
|
|
d4e16b0228 | ||
|
|
3bf8dd7297 | ||
|
|
50a40a7819 | ||
|
|
dd264506db | ||
|
|
2e535042b4 | ||
|
|
7dd7d7939f | ||
|
|
f602ee0e73 | ||
|
|
bebe011636 | ||
|
|
b2e6a39743 | ||
|
|
745baad0d1 | ||
|
|
697e2c0b66 | ||
|
|
719439f694 | ||
|
|
97cd1e7144 | ||
|
|
8e133fcf03 | ||
|
|
144f92d58e | ||
|
|
903d78cc43 | ||
|
|
6ecbe6fd4b | ||
|
|
af495ed253 | ||
|
|
6707d8e0ad | ||
|
|
eb76872da9 | ||
|
|
eac3afcd88 | ||
|
|
cb2a930a32 | ||
|
|
dfe4d5ab73 | ||
|
|
d3a8e91cda | ||
|
|
14efd14384 | ||
|
|
a1fae959ed | ||
|
|
7301be3e4c | ||
|
|
393b5d1f74 | ||
|
|
7ce1289c10 | ||
|
|
b32bfab66f | ||
|
|
159a2538da | ||
|
|
20df15b3ac | ||
|
|
5c6631f6af | ||
|
|
d27bc77644 | ||
|
|
5a9d40ce04 | ||
|
|
9cf7f441fb | ||
|
|
d759574f9a | ||
|
|
019b02adcd | ||
|
|
6d9f5fac76 | ||
|
|
8277d62025 | ||
|
|
c964811293 | ||
|
|
a39753ee08 | ||
|
|
3c1f163e3b | ||
|
|
3aad7fee24 | ||
|
|
17f2de42f3 | ||
|
|
1da28f2278 | ||
|
|
149af49d01 | ||
|
|
a1fe6cee5d | ||
|
|
d1d3b241ae | ||
|
|
9cdaa738f1 | ||
|
|
56d065b919 | ||
|
|
66912173bd | ||
|
|
1b1ea7f2aa | ||
|
|
d20b18a209 | ||
|
|
f6916c6f2d | ||
|
|
feb58caf1e | ||
|
|
056195ce07 | ||
|
|
3571512101 | ||
|
|
5034e110cf | ||
|
|
b09514465d | ||
|
|
91a319c9e3 | ||
|
|
17598e2626 | ||
|
|
8c56a5ac0c | ||
|
|
0371e8eaf0 | ||
|
|
fa4d32dcdb | ||
|
|
8907e8a492 | ||
|
|
f72289e27c | ||
|
|
003b6554c4 | ||
|
|
0e25e62750 | ||
|
|
19d03fee46 | ||
|
|
4573cd15a9 | ||
|
|
0f9405646b | ||
|
|
84af478eec | ||
|
|
5b133dcf18 | ||
|
|
16bc78834e | ||
|
|
20f6dd0224 | ||
|
|
d88ca43e52 | ||
|
|
ef162f9a9e | ||
|
|
bc952f40d9 | ||
|
|
eea4756ce9 | ||
|
|
8dafd2b684 | ||
|
|
04c834d6a9 | ||
|
|
025d0cd7f3 | ||
|
|
374b340e73 | ||
|
|
26905bc142 | ||
|
|
d9aa4491f1 | ||
|
|
19c318df68 | ||
|
|
2cb577b912 | ||
|
|
53c17bf331 | ||
|
|
e083d195cf | ||
|
|
7f1ef59dc5 | ||
|
|
710e021638 | ||
|
|
224cf19f33 | ||
|
|
40ca3b5e5d | ||
|
|
6f281356a6 |
@@ -14,35 +14,35 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.15.0",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-router": "^8.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -51,15 +51,15 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
"typescript-eslint": "^8.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
@@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { DateField } from "@/components/ui/form-elements"
|
||||
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankClearanceSummary = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
|
||||
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
)
|
||||
|
||||
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
@@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
|
||||
})
|
||||
} else {
|
||||
|
||||
/**
|
||||
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
|
||||
* So we need to compute the value of the expression
|
||||
* We can use the eval function to do this. But we need to expose certain variables to the expression.
|
||||
* One of them is transaction_amount which is the unallocated amount of the selected transaction
|
||||
* @param expression - The expression to compute
|
||||
* @returns The computed value
|
||||
*/
|
||||
const computeExpression = (expression: string) => {
|
||||
|
||||
const script = `
|
||||
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
|
||||
${expression};
|
||||
`
|
||||
|
||||
let value = 0;
|
||||
|
||||
try {
|
||||
value = window.eval(script);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
|
||||
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
@@ -19,6 +18,7 @@ import _ from "@/lib/translate"
|
||||
import { toast } from "sonner"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankReconciliationStatement = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
|
||||
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
|
||||
}, [data])
|
||||
|
||||
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankTransactions = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
|
||||
|
||||
}, [data, search, amountFilter, typeFilter, status])
|
||||
|
||||
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
@@ -18,6 +17,7 @@ import { PartyPopper } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const IncorrectlyClearedEntries = () => {
|
||||
const companyID = useCurrentCompany()
|
||||
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
[accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
|
||||
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
}} />
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<MarkdownRenderer content={entriesContent} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</Paragraph>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { today } from "@/lib/date"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
@@ -445,11 +446,10 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
|
||||
// If it's a string and cannot be a number, then show it as a formula
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
|
||||
let calculatedValue = "";
|
||||
|
||||
try {
|
||||
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
26
banking/src/lib/amountFormula.ts
Normal file
26
banking/src/lib/amountFormula.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Parser } from 'safe-expr-eval'
|
||||
|
||||
const parser = new Parser()
|
||||
|
||||
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
|
||||
|
||||
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
|
||||
const trimmed = expression.trim()
|
||||
if (!trimmed) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
|
||||
return Number(trimmed)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
|
||||
if (typeof result !== 'number' || !Number.isFinite(result)) {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
2655
banking/yarn.lock
2655
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.26.1"
|
||||
__version__ = "16.26.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Petty Cash Fund": {
|
||||
"account_number": "1200",
|
||||
"account_number": "1110",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Cash",
|
||||
"Petty Cash Fund": {
|
||||
"account_number": "1201",
|
||||
"account_number": "1111",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Cash"
|
||||
@@ -35,10 +35,16 @@
|
||||
}
|
||||
},
|
||||
"Bank Accounts": {
|
||||
"account_number": "1102",
|
||||
"account_number": "1200",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Bank"
|
||||
"account_type": "Bank",
|
||||
"Cash in Bank - Checking Account": {
|
||||
"account_number": "1201",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Bank"
|
||||
}
|
||||
},
|
||||
"Advances to Officers & Employees": {
|
||||
"account_number": "1290",
|
||||
@@ -104,25 +110,20 @@
|
||||
"account_number": "1511",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Factory Overhead Variance": {
|
||||
"account_number": "1512",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Finished Goods": {
|
||||
"account_number": "1520",
|
||||
"account_number": "1540",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"Finished Goods Inventory": {
|
||||
"account_number": "1531",
|
||||
"account_number": "1541",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Inventory in Transit": {
|
||||
"account_number": "1532",
|
||||
"account_number": "1542",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Stock Adjustment"
|
||||
@@ -268,7 +269,7 @@
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"System Development": {
|
||||
"Intangible Assets": {
|
||||
"account_number": "1940",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
@@ -277,6 +278,17 @@
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Accumulated Amortization - Intangible Assets": {
|
||||
"account_number": "1950",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"Accum Amortization - System Development": {
|
||||
"account_number": "1951",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -406,8 +418,7 @@
|
||||
"Customer Deposits": {
|
||||
"account_number": "2500",
|
||||
"is_group": 0,
|
||||
"root_type": "Liability",
|
||||
"account_type": "Payable"
|
||||
"root_type": "Liability"
|
||||
}
|
||||
},
|
||||
"Non Current Liabilities": {
|
||||
@@ -563,6 +574,28 @@
|
||||
"is_group": 0,
|
||||
"root_type": "Income"
|
||||
}
|
||||
},
|
||||
"Exchange Gain": {
|
||||
"account_number": "6030",
|
||||
"is_group": 1,
|
||||
"root_type": "Income",
|
||||
"Exchange Gain - Detail": {
|
||||
"account_number": "6031",
|
||||
"is_group": 0,
|
||||
"root_type": "Income",
|
||||
"account_type": "Indirect Income"
|
||||
}
|
||||
},
|
||||
"Gain on Asset Disposal": {
|
||||
"account_number": "6040",
|
||||
"is_group": 1,
|
||||
"root_type": "Income",
|
||||
"Gain on Asset Disposal - Detail": {
|
||||
"account_number": "6041",
|
||||
"is_group": 0,
|
||||
"root_type": "Income",
|
||||
"account_type": "Indirect Income"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +608,7 @@
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5010",
|
||||
"account_number": "5002",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
@@ -828,20 +861,61 @@
|
||||
"root_type": "Expense"
|
||||
}
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"Other Expenses": {
|
||||
"account_number": "5200",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Bank Charges": {
|
||||
"account_number": "5201",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Interest Expenses Bank": {
|
||||
"account_number": "5202",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "5203",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Exchange Loss": {
|
||||
"account_number": "5204",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Loss on Asset Disposal": {
|
||||
"account_number": "5205",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
}
|
||||
},
|
||||
"Provision For Income Tax": {
|
||||
"account_number": "5300",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "5400",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Round Off": {
|
||||
"account_number": "5300",
|
||||
"account_number": "5500",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Expenses Included In Valuation": {
|
||||
"account_number": "5400",
|
||||
"account_number": "5600",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
|
||||
@@ -107,7 +107,7 @@ def get_party_bank_account(party_type, party):
|
||||
)
|
||||
|
||||
|
||||
def get_default_company_bank_account(company, party_type, party):
|
||||
def get_default_company_bank_account(company, party_type, party, ignore_permissions=True):
|
||||
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
|
||||
if default_company_bank_account:
|
||||
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
|
||||
@@ -118,6 +118,14 @@ def get_default_company_bank_account(company, party_type, party):
|
||||
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
|
||||
)
|
||||
|
||||
if not ignore_permissions:
|
||||
default_company_bank_account = (
|
||||
default_company_bank_account
|
||||
if default_company_bank_account
|
||||
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
|
||||
else None
|
||||
)
|
||||
|
||||
return default_company_bank_account
|
||||
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Closing Balance",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
@@ -191,7 +190,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-08 17:55:25.615942",
|
||||
"modified": "2026-07-09 17:55:25.615942",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import Log",
|
||||
|
||||
@@ -557,7 +557,7 @@ class BankStatementImportLog(Document):
|
||||
docname=self.name,
|
||||
)
|
||||
|
||||
if self.closing_balance and self.closing_balance > 0 and self.end_date:
|
||||
if self.closing_balance is not None and self.end_date:
|
||||
set_closing_balance_as_per_statement(
|
||||
self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance
|
||||
)
|
||||
@@ -829,7 +829,9 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
amount = transaction_row.get("amount")
|
||||
float_amount = get_float_amount(amount)
|
||||
|
||||
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
|
||||
float_amount = abs(get_float_amount(amount) or 0)
|
||||
if "cr" in amount.lower():
|
||||
return 0, float_amount
|
||||
else:
|
||||
@@ -932,14 +934,18 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(io.BytesIO(content))
|
||||
if reader.is_encrypted and (not password or not reader.decrypt(password)):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
if reader.is_encrypted:
|
||||
# Try opening the PDF with a password - if no password is provided, try with a blank password
|
||||
if not password:
|
||||
password = ""
|
||||
if not reader.decrypt(password):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
|
||||
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
|
||||
tables = []
|
||||
|
||||
@@ -104,6 +104,36 @@ class TestBankTransaction(ERPNextTestSuite):
|
||||
self.assertEqual(bank_transaction.unallocated_amount, 1700)
|
||||
self.assertEqual(bank_transaction.payment_entries, [])
|
||||
|
||||
# Amending a reconciled payment entry must not carry over its clearance date
|
||||
def test_clearance_date_cleared_on_amend(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
"Bank Transaction",
|
||||
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
|
||||
)
|
||||
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
|
||||
vouchers = json.dumps(
|
||||
[
|
||||
{
|
||||
"payment_doctype": "Payment Entry",
|
||||
"payment_name": payment.name,
|
||||
"amount": bank_transaction.unallocated_amount,
|
||||
}
|
||||
]
|
||||
)
|
||||
reconcile_vouchers(bank_transaction.name, vouchers)
|
||||
|
||||
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
|
||||
|
||||
payment.reload()
|
||||
payment.cancel()
|
||||
|
||||
amended = frappe.copy_doc(payment)
|
||||
amended.amended_from = payment.name
|
||||
amended.docstatus = 0
|
||||
amended.insert()
|
||||
|
||||
self.assertFalse(amended.clearance_date)
|
||||
|
||||
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
|
||||
def test_debit_credit_output(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
|
||||
@@ -9,6 +9,48 @@ from frappe.model.document import Document
|
||||
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
|
||||
|
||||
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
|
||||
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
|
||||
PYTHON_ONLY_OPERATORS = ("**", "//")
|
||||
|
||||
|
||||
def _is_expr_eval_formula(formula: str) -> bool:
|
||||
position = 0
|
||||
while position < len(formula):
|
||||
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
|
||||
if not match:
|
||||
return False
|
||||
position = match.end()
|
||||
|
||||
return formula.count("(") == formula.count(")")
|
||||
|
||||
|
||||
def validate_amount_formula(formula: str) -> None:
|
||||
if not formula:
|
||||
return
|
||||
|
||||
stripped = formula.strip()
|
||||
if PLAIN_NUMBER_PATTERN.match(stripped):
|
||||
return
|
||||
|
||||
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
if not _is_expr_eval_formula(stripped):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
|
||||
python_formula = stripped.replace("^", "**")
|
||||
|
||||
try:
|
||||
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
|
||||
except Exception:
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
if not isinstance(result, (int | float)):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
|
||||
class BankTransactionRule(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -86,6 +128,11 @@ class BankTransactionRule(Document):
|
||||
frappe.throw(
|
||||
_("The last account row must not have any debit or credit amounts set.")
|
||||
)
|
||||
else:
|
||||
if account.debit:
|
||||
validate_amount_formula(account.debit)
|
||||
if account.credit:
|
||||
validate_amount_formula(account.credit)
|
||||
|
||||
# Validate regex
|
||||
for rule in self.description_rules:
|
||||
|
||||
@@ -231,3 +231,45 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.insert()
|
||||
|
||||
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
|
||||
return self._rule(
|
||||
prefix,
|
||||
[{"check": "Contains", "value": "x"}],
|
||||
classify_as="Bank Entry",
|
||||
bank_entry_type="Multiple Accounts",
|
||||
accounts=accounts,
|
||||
**fields,
|
||||
)
|
||||
|
||||
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
|
||||
doc = self._multiple_accounts_rule(
|
||||
"be_formula",
|
||||
accounts=[
|
||||
{"account": self.bank, "debit": "200", "credit": ""},
|
||||
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
|
||||
{"account": self.cash, "debit": "", "credit": ""},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
|
||||
malicious_formulas = [
|
||||
"__import__('os')",
|
||||
"eval('1+1')",
|
||||
"open('/etc/passwd')",
|
||||
"transaction_amount ** 2",
|
||||
"transaction_amount // 2",
|
||||
]
|
||||
for formula in malicious_formulas:
|
||||
with self.subTest(formula=formula):
|
||||
doc = self._multiple_accounts_rule(
|
||||
"be_bad_formula",
|
||||
accounts=[
|
||||
{"account": self.bank, "debit": formula, "credit": ""},
|
||||
{"account": self.cash, "debit": "", "credit": ""},
|
||||
],
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.insert()
|
||||
|
||||
@@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus == 1) {
|
||||
frappe.call({
|
||||
method: "check_journal_entry_condition",
|
||||
method: "check_journal_and_reversal",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
if (!r.message.journals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
} else if (!r.message.reversals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Reversal Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_reverse_journal(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
},
|
||||
});
|
||||
},
|
||||
make_reverse_journal: function (frm) {
|
||||
frappe.call({
|
||||
method: "make_reverse_journal",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Reversing Journals..."),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Exchange Rate Revaluation Account", {
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Criterion, Order
|
||||
from frappe.query_builder.functions import NullIf, Sum
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
|
||||
@@ -90,25 +90,31 @@ class ExchangeRateRevaluation(Document):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = "GL Entry"
|
||||
self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_journal_entry_condition(self):
|
||||
def check_journal_and_reversal(self):
|
||||
exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
|
||||
|
||||
journals_posted = False
|
||||
reversals_posted = False
|
||||
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(jea)
|
||||
.select(jea.parent)
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run()
|
||||
.run(pluck="name")
|
||||
)
|
||||
|
||||
if journals:
|
||||
gle = qb.DocType("GL Entry")
|
||||
total_amt = (
|
||||
@@ -123,12 +129,31 @@ class ExchangeRateRevaluation(Document):
|
||||
.run()
|
||||
)
|
||||
|
||||
if total_amt and total_amt[0][0] != self.total_gain_loss:
|
||||
return True
|
||||
if total_amt and total_amt[0][0] == self.total_gain_loss:
|
||||
journals_posted = True
|
||||
else:
|
||||
return False
|
||||
journals_posted = False
|
||||
|
||||
return True
|
||||
# reverse journals
|
||||
reverse_journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.notnull())
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if reverse_journals:
|
||||
reversals_posted = True
|
||||
else:
|
||||
reversals_posted = False
|
||||
|
||||
return {"journals_posted": journals_posted, "reversals_posted": reversals_posted}
|
||||
|
||||
def fetch_and_calculate_accounts_data(self):
|
||||
accounts = self.get_accounts_data()
|
||||
@@ -342,6 +367,7 @@ class ExchangeRateRevaluation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_jv_entries(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
zero_balance_jv = self.make_jv_for_zero_balance()
|
||||
if zero_balance_jv:
|
||||
frappe.msgprint(
|
||||
@@ -568,6 +594,38 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.save()
|
||||
return journal_entry
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
"""
|
||||
|
||||
@@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
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("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
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("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
|
||||
@@ -71,7 +71,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
frm.set_read_only();
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
@@ -564,6 +564,13 @@ $.extend(erpnext.journal_entry, {
|
||||
});
|
||||
},
|
||||
|
||||
lock_reversal_entry: function (frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
|
||||
var row = locals[cdt][cdn];
|
||||
|
||||
|
||||
@@ -417,11 +417,12 @@ class JournalEntry(AccountsController):
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book)
|
||||
precision = je_row.precision("debit")
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
frappe.listview_settings["Journal Entry"] = {
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"],
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.docstatus === 1) {
|
||||
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
|
||||
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
|
||||
}
|
||||
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
setTimeout(
|
||||
() => {
|
||||
frm.doc.import_in_progress = false;
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.page.clear_indicator();
|
||||
frm.dashboard.hide_progress();
|
||||
|
||||
if (frm.doc.invoice_type == "Sales") {
|
||||
frappe.msgprint(__("Opening Sales Invoices have been created."));
|
||||
if (!data.errors) {
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
const message =
|
||||
frm.doc.invoice_type == "Sales"
|
||||
? __("Opening Sales Invoice(s) have been created.")
|
||||
: __("Opening Purchase Invoice(s) have been created.");
|
||||
frappe.show_alert({
|
||||
message: message,
|
||||
indicator: "green",
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint(__("Opening Purchase Invoices have been created."));
|
||||
frm.refresh_fields();
|
||||
}
|
||||
},
|
||||
1500,
|
||||
|
||||
@@ -281,12 +281,20 @@ class OpeningInvoiceCreationTool(Document):
|
||||
def start_import(invoices):
|
||||
errors = 0
|
||||
names = []
|
||||
total = len(invoices)
|
||||
for idx, d in enumerate(invoices):
|
||||
# Scope each invoice to a savepoint so a failure only undoes that invoice.
|
||||
# A plain rollback() would discard the whole transaction — including invoices
|
||||
# imported earlier in this batch and the error logs of earlier failures (the
|
||||
# latter only survive on mariadb because the Error Log table is MyISAM; on
|
||||
# postgres they would be lost). Rolling back to a savepoint keeps both.
|
||||
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
|
||||
frappe.db.savepoint(savepoint)
|
||||
is_last = idx == total - 1
|
||||
try:
|
||||
invoice_number = None
|
||||
if d.invoice_number:
|
||||
invoice_number = d.invoice_number
|
||||
publish(idx, len(invoices), d.doctype)
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
@@ -294,10 +302,12 @@ def start_import(invoices):
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
names.append(doc.name)
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
except Exception:
|
||||
errors += 1
|
||||
frappe.db.rollback()
|
||||
doc.log_error("Opening invoice creation failed")
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
if errors:
|
||||
frappe.msgprint(
|
||||
_("You had {} errors while creating opening invoices. Check {} for more details").format(
|
||||
@@ -309,7 +319,7 @@ def start_import(invoices):
|
||||
return names
|
||||
|
||||
|
||||
def publish(index, total, doctype):
|
||||
def publish(index, total, doctype, errors=None):
|
||||
frappe.publish_realtime(
|
||||
"opening_invoice_creation_progress",
|
||||
dict(
|
||||
@@ -317,6 +327,7 @@ def publish(index, total, doctype):
|
||||
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
|
||||
count=index + 1,
|
||||
total=total,
|
||||
errors=errors,
|
||||
),
|
||||
user=frappe.session.user,
|
||||
)
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Outstanding Amount",
|
||||
"options": "Company:company:default_currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -136,7 +137,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-29 17:08:15.617047",
|
||||
"modified": "2026-07-02 15:17:11.938499",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
|
||||
@@ -2712,6 +2712,9 @@ def get_party_details(company, party_type, party, date, cost_center=None):
|
||||
if not frappe.db.exists(party_type, party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
|
||||
frappe.has_permission(party_type, ptype, party, throw=True)
|
||||
|
||||
party_account = get_party_account(party_type, party, company)
|
||||
account_currency = get_account_currency(party_account)
|
||||
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
|
||||
@@ -2719,7 +2722,7 @@ def get_party_details(company, party_type, party, date, cost_center=None):
|
||||
|
||||
if party_type in ["Customer", "Supplier"]:
|
||||
party_bank_account = get_party_bank_account(party_type, party)
|
||||
bank_account = get_default_company_bank_account(company, party_type, party)
|
||||
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
|
||||
|
||||
return {
|
||||
"party_account": party_account,
|
||||
@@ -2811,9 +2814,7 @@ def get_reference_details(
|
||||
exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)
|
||||
else:
|
||||
exchange_rate = 1
|
||||
outstanding_amount, total_amount = get_outstanding_on_journal_entry(
|
||||
reference_name, party_type, party
|
||||
)
|
||||
outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party)
|
||||
|
||||
elif reference_doctype == "Payment Entry":
|
||||
if reverse_payment_details := frappe.db.get_all(
|
||||
|
||||
@@ -6,8 +6,10 @@ import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
|
||||
|
||||
import erpnext
|
||||
@@ -74,6 +76,10 @@ class PaymentReconciliation(Document):
|
||||
self.ple_posting_date_filter = []
|
||||
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
|
||||
|
||||
@property
|
||||
def user_permissions(self):
|
||||
return get_user_permissions(frappe.session.user)
|
||||
|
||||
def load_from_db(self):
|
||||
# 'modified' attribute is required for `run_doc_method` to work properly.
|
||||
doc_dict = frappe._dict(
|
||||
@@ -153,6 +159,22 @@ class PaymentReconciliation(Document):
|
||||
|
||||
self.add_payment_entries(non_reconciled_payments)
|
||||
|
||||
def get_permitted_dimension_values(self, document_type, reference_doctype):
|
||||
return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype)
|
||||
|
||||
def validate_permitted_dimension_value(self, document_type, value, allowed):
|
||||
if value and allowed and value not in allowed:
|
||||
frappe.throw(
|
||||
_("You do not have enough permission to access {0}: {1}").format(_(document_type), value),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
def get_user_permission_dimension_condition(self, field, allowed):
|
||||
value_condition = field.isin(allowed)
|
||||
if frappe.get_system_settings("apply_strict_user_permissions"):
|
||||
return value_condition
|
||||
return (IfNull(field, "") == "") | value_condition
|
||||
|
||||
def get_payment_entries(self):
|
||||
party_account = [self.receivable_payable_account]
|
||||
|
||||
@@ -176,8 +198,13 @@ class PaymentReconciliation(Document):
|
||||
dimensions = {}
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
dimensions.update({dimension: self.get(dimension)})
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
dimensions[dimension] = value
|
||||
elif allowed:
|
||||
dimensions[dimension] = allowed
|
||||
|
||||
condition.update({"accounting_dimensions": dimensions})
|
||||
|
||||
payment_entries = get_advance_payment_entries_for_regional(
|
||||
@@ -201,8 +228,12 @@ class PaymentReconciliation(Document):
|
||||
# Dimension filters
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
conditions.append(jea[dimension] == self.get(dimension))
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
conditions.append(jea[dimension] == value)
|
||||
elif allowed:
|
||||
conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed))
|
||||
|
||||
if self.payment_name:
|
||||
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
|
||||
@@ -746,8 +777,15 @@ class PaymentReconciliation(Document):
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
|
||||
if frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == value)
|
||||
elif allowed:
|
||||
self.accounting_dimension_filter_conditions.append(
|
||||
self.get_user_permission_dimension_condition(ple[dimension], allowed)
|
||||
)
|
||||
|
||||
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
|
||||
self.common_filter_conditions.clear()
|
||||
@@ -796,10 +834,17 @@ class PaymentReconciliation(Document):
|
||||
|
||||
|
||||
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
|
||||
)
|
||||
for inv in dr_cr_notes:
|
||||
if (
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
< inv.allocated_amount
|
||||
flt(
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
- inv.allocated_amount,
|
||||
allocated_amount_precision,
|
||||
)
|
||||
< 0
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
||||
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
|
||||
from frappe.utils.data import getdate as convert_to_date
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
@@ -51,6 +51,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
sinv = create_sales_invoice(
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
posting_date=posting_date,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -1105,6 +1106,101 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
|
||||
|
||||
def test_user_permission_on_accounting_dimension_filters_vouchers(self):
|
||||
test_user = "test@example.com"
|
||||
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
|
||||
restricted_cc = "_Test Write Off Cost Center - _TC"
|
||||
existing_apply_strict_user_permissions = cint(
|
||||
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
|
||||
)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"System Settings",
|
||||
"apply_strict_user_permissions",
|
||||
existing_apply_strict_user_permissions,
|
||||
)
|
||||
transaction_date = nowdate()
|
||||
rate = 100
|
||||
|
||||
def make_invoice(cost_center):
|
||||
si = self.create_sales_invoice(
|
||||
qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
|
||||
)
|
||||
si.cost_center = cost_center
|
||||
for row in si.items:
|
||||
row.cost_center = cost_center
|
||||
return si.submit()
|
||||
|
||||
def make_payment(cost_center):
|
||||
pe = self.create_payment_entry(posting_date=transaction_date, amount=rate)
|
||||
pe.cost_center = cost_center
|
||||
return pe.save().submit()
|
||||
|
||||
def make_journal(cost_center):
|
||||
je = self.create_journal_entry(
|
||||
self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center
|
||||
)
|
||||
je.accounts[1].party_type = "Customer"
|
||||
je.accounts[1].party = self.customer
|
||||
return je.save().submit()
|
||||
|
||||
# Vouchers tagged with the two permitted cost centers
|
||||
si_allowed = make_invoice(permitted_ccs[0])
|
||||
pe_allowed = make_payment(permitted_ccs[1])
|
||||
je_allowed = make_journal(permitted_ccs[0])
|
||||
|
||||
# Vouchers tagged with the restricted cost center
|
||||
si_restricted = make_invoice(restricted_cc)
|
||||
pe_restricted = make_payment(restricted_cc)
|
||||
je_restricted = make_journal(restricted_cc)
|
||||
|
||||
# Payment entry with a BLANK cost center
|
||||
pe_blank = make_payment(None)
|
||||
|
||||
for cc in permitted_ccs:
|
||||
frappe.permissions.add_user_permission("Cost Center", cc, test_user)
|
||||
|
||||
# Without strict user permissions
|
||||
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0)
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertIn(si_allowed.name, invoice_numbers)
|
||||
self.assertIn(pe_allowed.name, payment_vouchers)
|
||||
self.assertIn(je_allowed.name, payment_vouchers)
|
||||
self.assertIn(pe_blank.name, payment_vouchers)
|
||||
self.assertNotIn(si_restricted.name, invoice_numbers)
|
||||
self.assertNotIn(pe_restricted.name, payment_vouchers)
|
||||
self.assertNotIn(je_restricted.name, payment_vouchers)
|
||||
|
||||
# With strict user permissions
|
||||
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1)
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertIn(si_allowed.name, invoice_numbers)
|
||||
self.assertIn(pe_allowed.name, payment_vouchers)
|
||||
self.assertIn(je_allowed.name, payment_vouchers)
|
||||
self.assertNotIn(pe_blank.name, payment_vouchers)
|
||||
self.assertNotIn(si_restricted.name, invoice_numbers)
|
||||
self.assertNotIn(pe_restricted.name, payment_vouchers)
|
||||
self.assertNotIn(je_restricted.name, payment_vouchers)
|
||||
|
||||
# with restricted dimension as a filter
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.cost_center = restricted_cc
|
||||
self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries)
|
||||
|
||||
for cc in permitted_ccs:
|
||||
frappe.permissions.remove_user_permission("Cost Center", cc, test_user)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{
|
||||
@@ -2018,7 +2114,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
pr.reconcile()
|
||||
|
||||
si.reload()
|
||||
self.assertEqual(si.status, "Partly Paid")
|
||||
self.assertEqual(si.status, "Overdue")
|
||||
# check PR tool output post reconciliation
|
||||
self.assertEqual(len(pr.get("invoices")), 1)
|
||||
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
|
||||
@@ -2414,6 +2510,76 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_cr_note_split_across_invoices_floating_point_precision(self):
|
||||
"""Regression: when a credit note is split across multiple invoices, floating-point
|
||||
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
|
||||
|
||||
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
|
||||
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
|
||||
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
|
||||
"""
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
|
||||
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
|
||||
# (invoices are sorted by posting_date ascending, so si_a is processed first).
|
||||
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
|
||||
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
|
||||
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
|
||||
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
|
||||
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
|
||||
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
|
||||
|
||||
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
|
||||
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
|
||||
# by subtracting a decimal-valued payment from the integer total:
|
||||
# 100 - 91.55 = 8.45
|
||||
# 200 - 109.28 = 90.72
|
||||
# 100 - 27.43 = 72.57
|
||||
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.paid_amount = partial_paid
|
||||
pe.received_amount = partial_paid
|
||||
pe.references[0].allocated_amount = partial_paid
|
||||
pe.save().submit()
|
||||
|
||||
cr_note = self.create_sales_invoice(
|
||||
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
cr_note.is_return = 1
|
||||
cr_note = cr_note.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
# Widen date range so all three invoices (oldest is -2 days) are fetched
|
||||
pr.from_invoice_date = add_days(nowdate(), -2)
|
||||
pr.to_invoice_date = nowdate()
|
||||
pr.from_payment_date = nowdate()
|
||||
pr.to_payment_date = nowdate()
|
||||
|
||||
pr.get_unreconciled_entries()
|
||||
self.assertEqual(len(pr.invoices), 3)
|
||||
self.assertEqual(len(pr.payments), 1)
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
|
||||
self.assertEqual(len(pr.allocation), 3)
|
||||
last_row = pr.allocation[-1]
|
||||
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
|
||||
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
|
||||
|
||||
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
|
||||
pr.reconcile()
|
||||
|
||||
si_a.reload()
|
||||
si_b.reload()
|
||||
si_c.reload()
|
||||
self.assertEqual(si_a.outstanding_amount, 0)
|
||||
self.assertEqual(si_b.outstanding_amount, 0)
|
||||
# si_c is only partially settled: 72.57 - 50.83 = 21.74
|
||||
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
|
||||
|
||||
|
||||
def create_fiscal_year(company, year_start_date, year_end_date):
|
||||
fy_docname = frappe.db.exists(
|
||||
|
||||
@@ -379,6 +379,7 @@ class PaymentRequest(Document):
|
||||
bank_amount=bank_amount,
|
||||
created_from_payment_request=True,
|
||||
)
|
||||
payment_entry.set_missing_ref_details(force=True)
|
||||
|
||||
payment_entry.update(
|
||||
{
|
||||
|
||||
@@ -775,6 +775,22 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
pi.load_from_db()
|
||||
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
|
||||
|
||||
def test_payment_entry_reference_details_fetched_from_invoice(self):
|
||||
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
|
||||
pi.submit()
|
||||
|
||||
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
|
||||
pr.grand_total = 94000
|
||||
pr.submit()
|
||||
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
|
||||
self.assertEqual(pe.references[0].reference_name, pi.name)
|
||||
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
|
||||
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
|
||||
self.assertEqual(pe.references[0].allocated_amount, 94000)
|
||||
self.assertEqual(pe.paid_amount, 94000)
|
||||
|
||||
def test_consider_journal_entry_and_return_invoice(self):
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
|
||||
@@ -352,12 +352,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
|
||||
|
||||
try:
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
)
|
||||
finally:
|
||||
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
|
||||
@@ -156,6 +156,24 @@ class PricingRule(Document):
|
||||
if len(values) != len(set(values)):
|
||||
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
|
||||
|
||||
if self.apply_on == "Item Code":
|
||||
self.validate_template_with_variant(values)
|
||||
|
||||
def validate_template_with_variant(self, item_codes):
|
||||
# throws if a template and its variant both exist in one rule
|
||||
variants = frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
|
||||
fields=["name", "variant_of"],
|
||||
)
|
||||
if variants:
|
||||
variant = variants[0]
|
||||
frappe.throw(
|
||||
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
|
||||
frappe.bold(variant.name), frappe.bold(variant.variant_of)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_mandatory(self):
|
||||
if self.has_priority and not self.priority:
|
||||
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))
|
||||
|
||||
@@ -333,6 +333,31 @@ class TestPricingRule(ERPNextTestSuite):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("discount_percentage"), 17.5)
|
||||
|
||||
def test_pricing_rule_with_template_and_its_variant(self):
|
||||
if not frappe.db.exists("Item", "Test Variant PRT"):
|
||||
variant = frappe.new_doc("Item")
|
||||
variant.item_code = "Test Variant PRT"
|
||||
variant.item_name = "Test Variant PRT"
|
||||
variant.item_group = "_Test Item Group"
|
||||
variant.is_stock_item = 1
|
||||
variant.variant_of = "_Test Variant Item"
|
||||
variant.stock_uom = "_Test UOM"
|
||||
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
|
||||
variant.insert()
|
||||
|
||||
rule = frappe.new_doc("Pricing Rule")
|
||||
rule.title = "_Test Pricing Rule Template Variant"
|
||||
rule.apply_on = "Item Code"
|
||||
rule.currency = "USD"
|
||||
rule.selling = 1
|
||||
rule.rate_or_discount = "Discount Percentage"
|
||||
rule.discount_percentage = 10
|
||||
rule.company = "_Test Company"
|
||||
rule.append("items", {"item_code": "_Test Variant Item"})
|
||||
rule.append("items", {"item_code": "Test Variant PRT"})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, rule.insert)
|
||||
|
||||
def test_pricing_rule_for_stock_qty(self):
|
||||
test_record = {
|
||||
"doctype": "Pricing Rule",
|
||||
|
||||
@@ -89,50 +89,55 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
cancel_pcv_processing(self.name)
|
||||
|
||||
|
||||
def initialize_parallel_threads(docname: str):
|
||||
threads = 4
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(threads)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
x.name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=x.name,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(4)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": x.processing_date,
|
||||
"parent": docname,
|
||||
"report_type": x.report_type,
|
||||
"parentfield": x.parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
initialize_parallel_threads(docname)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -250,11 +255,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(1)
|
||||
@@ -264,15 +269,15 @@ def schedule_next_date(docname: str):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": to_process[0].processing_date,
|
||||
"parent": docname,
|
||||
"report_type": to_process[0].report_type,
|
||||
"parentfield": to_process[0].parentfield,
|
||||
},
|
||||
to_process[0].name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
@@ -280,6 +285,7 @@ def schedule_next_date(docname: str):
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=to_process[0].name,
|
||||
date=to_process[0].processing_date,
|
||||
report_type=to_process[0].report_type,
|
||||
parentfield=to_process[0].parentfield,
|
||||
@@ -444,6 +450,11 @@ def summarize_and_post_ledger_entries(docname):
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
@@ -529,10 +540,10 @@ def build_dimension_wise_balance_dict(gl_entries):
|
||||
return dimension_balances
|
||||
|
||||
|
||||
def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
|
||||
current_date_status = frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"status",
|
||||
)
|
||||
if current_date_status != "Running":
|
||||
@@ -579,17 +590,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
# save results
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"closing_balance",
|
||||
frappe.json.dumps(res),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"status",
|
||||
"Completed",
|
||||
)
|
||||
# commit heavy computation before touching PPCV or PPCVD
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
# chain call
|
||||
schedule_next_date(docname)
|
||||
|
||||
@@ -48,18 +48,27 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
ppcv.save()
|
||||
return ppcv
|
||||
|
||||
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
|
||||
def set_processing_date_status(self, row_name, status):
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"status",
|
||||
status,
|
||||
)
|
||||
|
||||
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
|
||||
def get_row_name(self, ppcv_name, rpt_type, parentfield):
|
||||
return frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
|
||||
order_by="report_type, idx",
|
||||
pluck="name",
|
||||
limit=1,
|
||||
)[0]
|
||||
|
||||
def get_processing_date_closing_balance(self, row_name):
|
||||
return frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"closing_balance",
|
||||
)
|
||||
|
||||
@@ -97,11 +106,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
parentfield = "normal_balances"
|
||||
rpt_type = "Profit and Loss"
|
||||
# status has to be set to 'Running' for logic to run
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_pl = {
|
||||
"account": "Sales - _TC",
|
||||
@@ -117,11 +125,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
# Balance sheet balance
|
||||
rpt_type = "Balance Sheet"
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_bs = {
|
||||
"account": "Debtors - _TC",
|
||||
@@ -138,11 +145,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
# Opening balance
|
||||
parentfield = "z_opening_balances"
|
||||
rpt_type = "Balance Sheet"
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.assertEqual(len(bal), 2)
|
||||
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
|
||||
expected_opening_cash = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index(
|
||||
"Process Period Closing Voucher Detail",
|
||||
["parent", "status", "parentfield", "idx", "processing_date"],
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
|
||||
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
|
||||
<div>
|
||||
{% if filters.party[0] == filters.party_name[0] %}
|
||||
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>
|
||||
|
||||
@@ -1388,8 +1388,10 @@
|
||||
"fetch_from": "supplier.represents_company",
|
||||
"fieldname": "represents_company",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Represents Company",
|
||||
"options": "Company"
|
||||
"options": "Company",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.update_stock && doc.is_internal_supplier",
|
||||
@@ -1700,7 +1702,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-13 18:36:46.704623",
|
||||
"modified": "2026-07-12 23:54:21.263951",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -716,13 +716,15 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
if immutable_ledger_enabled:
|
||||
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
else:
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
|
||||
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
@@ -789,7 +791,7 @@ def make_reverse_gl_entries(
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
new_gle["is_cancelled"] = 0
|
||||
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
|
||||
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
elif posting_date:
|
||||
new_gle["posting_date"] = posting_date
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 15:21:48.255627",
|
||||
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead",
|
||||
"modified": "2026-06-24 17:49:52.350750",
|
||||
"modified": "2026-07-12 21:11:44.765083",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"content": "<table class=\"letterhead-container\" style=\"width:100%\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" style=\"width:200px\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"creation": "2026-05-15 15:21:48.373815",
|
||||
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead - Grey",
|
||||
"modified": "2026-06-24 18:23:05.120521",
|
||||
"modified": "2026-07-12 22:03:24.525672",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead - Grey",
|
||||
|
||||
@@ -428,6 +428,17 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
|
||||
Will first search in party (Customer / Supplier) record, if not found,
|
||||
will search in group (Customer Group / Supplier Group),
|
||||
finally will return default."""
|
||||
|
||||
def account_perm_check(account):
|
||||
ptype = "select" if frappe.only_has_select_perm("Account") else "read"
|
||||
if frappe.has_permission("Account", ptype, account):
|
||||
return
|
||||
|
||||
# Using custom message to prevent data leak in case of `apply_strict_permission` is enabled.
|
||||
frappe.throw(
|
||||
_("User don't have permissions to select/read this account."), exc=frappe.PermissionError
|
||||
)
|
||||
|
||||
if not party_type:
|
||||
frappe.throw(_("Party Type is mandatory"))
|
||||
if not company:
|
||||
@@ -438,46 +449,51 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
|
||||
return frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
account = frappe.db.get_value(
|
||||
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
|
||||
)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
|
||||
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
else:
|
||||
account = frappe.db.get_value(
|
||||
"Party Account",
|
||||
{"parenttype": party_group_doctype, "parent": group, "company": company},
|
||||
"account",
|
||||
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
|
||||
)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
default_account_name = (
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
|
||||
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
|
||||
account = frappe.db.get_value(
|
||||
"Party Account",
|
||||
{"parenttype": party_group_doctype, "parent": group, "company": company},
|
||||
"account",
|
||||
)
|
||||
|
||||
existing_gle_currency = get_party_gle_currency(party_type, party, company)
|
||||
if existing_gle_currency:
|
||||
if account:
|
||||
account_currency = frappe.get_cached_value("Account", account, "account_currency")
|
||||
if (account and account_currency != existing_gle_currency) or not account:
|
||||
account = get_party_gle_account(party_type, party, company)
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
default_account_name = (
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
# get default account on the basis of party type
|
||||
if not account:
|
||||
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
|
||||
default_account_name = "default_" + account_type.lower() + "_account"
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
existing_gle_currency = get_party_gle_currency(party_type, party, company)
|
||||
if existing_gle_currency:
|
||||
if account:
|
||||
account_currency = frappe.get_cached_value("Account", account, "account_currency")
|
||||
if (account and account_currency != existing_gle_currency) or not account:
|
||||
account = get_party_gle_account(party_type, party, company)
|
||||
|
||||
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
|
||||
# get default account on the basis of party type
|
||||
if not account:
|
||||
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
|
||||
default_account_name = "default_" + account_type.lower() + "_account"
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
if account:
|
||||
account_perm_check(account)
|
||||
|
||||
if include_advance and party and party_type in ["Customer", "Supplier", "Student"]:
|
||||
advance_account = get_party_advance_account(party_type, party, company)
|
||||
|
||||
if advance_account:
|
||||
account_perm_check(advance_account)
|
||||
return [account, advance_account]
|
||||
else:
|
||||
return [account]
|
||||
|
||||
return [account]
|
||||
|
||||
return account
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2013-04-22 16:16:03",
|
||||
"default_print_format": "Accounts Payable Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:14.716933",
|
||||
"modified": "2026-07-01 13:37:41.185347",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -33,5 +40,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2013-04-16 11:31:13",
|
||||
"default_print_format": "Accounts Receivable Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:34:57.666402",
|
||||
"modified": "2026-07-01 13:37:44.167999",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -27,5 +34,6 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -265,10 +265,12 @@ class ReceivablePayableReport:
|
||||
|
||||
# Build and use a separate row for Employee Advances.
|
||||
# This allows Payments or Journals made against Emp Advance to be processed.
|
||||
if (
|
||||
not row
|
||||
and ple.against_voucher_type == "Employee Advance"
|
||||
and self.filters.handle_employee_advances
|
||||
if not row and (
|
||||
(ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances)
|
||||
or (
|
||||
ple.against_voucher_type == "Exchange Rate Revaluation"
|
||||
and self.filters.for_revaluation_journals
|
||||
)
|
||||
):
|
||||
_d = self.build_voucher_dict(ple)
|
||||
_d.voucher_type = ple.against_voucher_type
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-14 05:24:20.385279",
|
||||
"default_print_format": "Balance Sheet Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:28.187799",
|
||||
"modified": "2026-06-22 13:38:25.236839",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Balance Sheet",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,18 +4,27 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt
|
||||
from frappe.utils import add_days, cint, flt
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FinancialReportEngine,
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
filter_accounts,
|
||||
filter_out_zero_value_rows,
|
||||
get_accounting_entries,
|
||||
get_accounts,
|
||||
get_appropriate_currency,
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,3 +275,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
|
||||
chart["currency"] = currency
|
||||
|
||||
return chart
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
filters.period_start_date = period_list[0]["year_start_date"]
|
||||
|
||||
currency = filters.presentation_currency or frappe.get_cached_value(
|
||||
"Company", filters.company, "default_currency"
|
||||
)
|
||||
|
||||
asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list)
|
||||
liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list)
|
||||
equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list)
|
||||
|
||||
provisional_profit_loss, total_credit = get_provisional_profit_loss(
|
||||
asset, liability, equity, period_list, filters.company, currency
|
||||
)
|
||||
message, opening_balance = check_opening_balance(asset, liability, equity)
|
||||
|
||||
data = []
|
||||
data.extend(asset or [])
|
||||
data.extend(liability or [])
|
||||
data.extend(equity or [])
|
||||
if opening_balance and round(opening_balance, 2) != 0:
|
||||
unclosed = {
|
||||
"account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
|
||||
"account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
|
||||
"warn_if_negative": True,
|
||||
"currency": currency,
|
||||
}
|
||||
for period in period_list:
|
||||
unclosed[period.key] = opening_balance
|
||||
if provisional_profit_loss:
|
||||
provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance
|
||||
unclosed["total"] = opening_balance
|
||||
data.append(unclosed)
|
||||
|
||||
if provisional_profit_loss:
|
||||
data.append(provisional_profit_loss)
|
||||
if total_credit:
|
||||
data.append(total_credit)
|
||||
|
||||
columns = get_columns(
|
||||
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
|
||||
)
|
||||
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
|
||||
report_summary, primitive_summary = get_report_summary(
|
||||
period_list, asset, liability, equity, provisional_profit_loss, currency, filters
|
||||
)
|
||||
|
||||
if filters.get("selected_view") == "Growth":
|
||||
compute_growth_view_data(data, period_list)
|
||||
|
||||
return columns, data, message, chart, report_summary, primitive_summary
|
||||
|
||||
|
||||
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
|
||||
accounts = get_accounts(filters.company, root_type)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
|
||||
company_currency = get_appropriate_currency(filters.company, filters)
|
||||
|
||||
gl_entries_by_account = {}
|
||||
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type)
|
||||
|
||||
calculate_values(
|
||||
accounts_by_name,
|
||||
gl_entries_by_account,
|
||||
period_list,
|
||||
filters.accumulated_values,
|
||||
False,
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
|
||||
|
||||
out = prepare_data(
|
||||
accounts,
|
||||
balance_must_be,
|
||||
period_list,
|
||||
company_currency,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
)
|
||||
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
|
||||
|
||||
if out:
|
||||
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type):
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import (
|
||||
_extra_gl_conditions,
|
||||
_fetch_gl_rows_duckdb,
|
||||
)
|
||||
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
|
||||
|
||||
company = filters.company
|
||||
year_start_date = period_list[0]["year_start_date"]
|
||||
last_to_date = period_list[-1]["to_date"]
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
|
||||
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
|
||||
if not leaf_accounts:
|
||||
return
|
||||
|
||||
opening_from_date = None
|
||||
ignore_opening_entries = False
|
||||
|
||||
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
|
||||
if not ignore_closing_balances:
|
||||
last_pcv_list = frappe.db.get_all(
|
||||
"Period Closing Voucher",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": company,
|
||||
"period_end_date": ("<", filters.get("period_start_date") or year_start_date),
|
||||
},
|
||||
fields=["period_end_date", "name"],
|
||||
order_by="period_end_date desc",
|
||||
limit=1,
|
||||
)
|
||||
if last_pcv_list:
|
||||
last_pcv = last_pcv_list[0]
|
||||
pcv_entries = get_accounting_entries(
|
||||
"Account Closing Balance",
|
||||
None,
|
||||
last_to_date,
|
||||
filters,
|
||||
root_type=root_type,
|
||||
ignore_closing_entries=False,
|
||||
period_closing_voucher=last_pcv.name,
|
||||
)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(pcv_entries, get_currency(filters))
|
||||
for entry in pcv_entries:
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
opening_from_date = add_days(last_pcv.period_end_date, 1)
|
||||
ignore_opening_entries = True
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
|
||||
base_conds = [
|
||||
"company = ?",
|
||||
"is_cancelled = 0",
|
||||
f"account IN ({account_placeholders})",
|
||||
]
|
||||
base_params = [company, *leaf_accounts]
|
||||
if ignore_opening_entries and not ignore_is_opening:
|
||||
base_conds.append("is_opening = 'No'")
|
||||
base_conds.extend(extra_cond)
|
||||
base_params.extend(extra_params)
|
||||
|
||||
# Opening GL entries from DuckDB (entries before year_start_date)
|
||||
open_conds = [*base_conds, "posting_date < ?"]
|
||||
open_params = [*base_params, year_start_date]
|
||||
if opening_from_date:
|
||||
open_conds = [*open_conds, "posting_date >= ?"]
|
||||
open_params = [*open_params, opening_from_date]
|
||||
|
||||
opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(opening_entries, get_currency(filters))
|
||||
synthetic_open_date = add_days(year_start_date, -1)
|
||||
for entry in opening_entries:
|
||||
entry.posting_date = synthetic_open_date
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
|
||||
# Period GL entries from DuckDB (one aggregated query per period)
|
||||
for period in period_list:
|
||||
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
|
||||
period_params = [*base_params, period.from_date, period.to_date]
|
||||
|
||||
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(period_entries, get_currency(filters))
|
||||
for entry in period_entries:
|
||||
entry.posting_date = period.to_date
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
|
||||
@@ -428,6 +428,11 @@ def build_comparison_chart_data(filters, columns, data):
|
||||
if not fieldname:
|
||||
continue
|
||||
|
||||
# skip the dimension column ("budget_against"), it only matches the
|
||||
# "budget_" prefix by coincidence and would shift the actual values by one
|
||||
if fieldname == "budget_against":
|
||||
continue
|
||||
|
||||
if fieldname.startswith("budget_"):
|
||||
budget_fields.append(fieldname)
|
||||
elif fieldname.startswith("actual_"):
|
||||
@@ -439,7 +444,7 @@ def build_comparison_chart_data(filters, columns, data):
|
||||
labels = [
|
||||
col["label"].replace("Budget", "").strip()
|
||||
for col in columns
|
||||
if col.get("fieldname", "").startswith("budget_")
|
||||
if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against"
|
||||
]
|
||||
|
||||
budget_values = [0] * len(budget_fields)
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2013-12-06 13:22:23",
|
||||
"default_print_format": "General Ledger Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:34:35.246000",
|
||||
"modified": "2026-07-01 13:36:06.682661",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -818,3 +818,288 @@ def get_columns(filters):
|
||||
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
return _execute_with_duckdb_conn(filters, conn)
|
||||
|
||||
frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
|
||||
def _execute_with_duckdb_conn(filters, conn):
|
||||
if not filters:
|
||||
return [], []
|
||||
|
||||
account_details = {}
|
||||
|
||||
if filters.get("print_in_account_currency") and not filters.get("account"):
|
||||
frappe.throw(_("Select an account to print in account currency"))
|
||||
|
||||
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
|
||||
account_details.setdefault(acc.name, acc)
|
||||
|
||||
if filters.get("party"):
|
||||
filters.party = frappe.parse_json(filters.get("party"))
|
||||
|
||||
validate_filters(filters, account_details)
|
||||
validate_party(filters)
|
||||
filters = set_account_currency(filters)
|
||||
columns = get_columns(filters)
|
||||
res = get_result_duckdb(filters, account_details, conn)
|
||||
return columns, res
|
||||
|
||||
|
||||
def get_result_duckdb(filters, account_details, conn):
|
||||
accounting_dimensions = []
|
||||
if filters.get("include_dimensions"):
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn)
|
||||
data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries)
|
||||
return get_result_as_list(data, filters)
|
||||
|
||||
|
||||
def get_gl_entries_duckdb(filters, accounting_dimensions, conn):
|
||||
currency_map = get_currency(filters)
|
||||
|
||||
col_names = [
|
||||
"gl_entry",
|
||||
"posting_date",
|
||||
"account",
|
||||
"party_type",
|
||||
"party",
|
||||
"voucher_type",
|
||||
"voucher_subtype",
|
||||
"voucher_no",
|
||||
"cost_center",
|
||||
"project",
|
||||
"against_voucher_type",
|
||||
"against_voucher",
|
||||
"account_currency",
|
||||
"against",
|
||||
"is_opening",
|
||||
"creation",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
]
|
||||
select_exprs = [
|
||||
"name",
|
||||
"posting_date",
|
||||
"account",
|
||||
"party_type",
|
||||
"party",
|
||||
"voucher_type",
|
||||
"voucher_subtype",
|
||||
"voucher_no",
|
||||
"cost_center",
|
||||
"project",
|
||||
"against_voucher_type",
|
||||
"against_voucher",
|
||||
"account_currency",
|
||||
"against",
|
||||
"is_opening",
|
||||
"creation",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
]
|
||||
|
||||
if filters.get("show_remarks"):
|
||||
remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length")
|
||||
if remarks_length:
|
||||
select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})")
|
||||
else:
|
||||
select_exprs.append("remarks")
|
||||
col_names.append("remarks")
|
||||
|
||||
if filters.get("add_values_in_transaction_currency"):
|
||||
select_exprs += [
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
"transaction_currency",
|
||||
]
|
||||
col_names += [
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
"transaction_currency",
|
||||
]
|
||||
|
||||
if accounting_dimensions:
|
||||
select_exprs += accounting_dimensions
|
||||
col_names += accounting_dimensions
|
||||
|
||||
order_by = "posting_date, account, creation"
|
||||
if filters.get("include_dimensions"):
|
||||
order_by = "posting_date, creation"
|
||||
if filters.get("categorize_by") == "Categorize by Voucher":
|
||||
order_by = "posting_date, voucher_type, voucher_no"
|
||||
if filters.get("categorize_by") == "Categorize by Account":
|
||||
order_by = "account, posting_date, creation"
|
||||
|
||||
if filters.get("include_default_book_entries"):
|
||||
filters["company_fb"] = frappe.get_cached_value(
|
||||
"Company", filters.get("company"), "default_finance_book"
|
||||
)
|
||||
|
||||
conditions, params = _build_gl_conditions_duckdb(filters)
|
||||
select_clause = ", ".join(select_exprs)
|
||||
sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}'
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows]
|
||||
|
||||
party_name_map = get_party_name_map()
|
||||
for gl_entry in gl_entries:
|
||||
if gl_entry.party_type and gl_entry.party:
|
||||
gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
|
||||
|
||||
if filters.get("presentation_currency"):
|
||||
return convert_to_presentation_currency(gl_entries, currency_map, filters)
|
||||
return gl_entries
|
||||
|
||||
|
||||
def _build_gl_conditions_duckdb(filters):
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
|
||||
conditions = ["company = ?"]
|
||||
params = [filters.company]
|
||||
|
||||
if filters.get("account"):
|
||||
filters.account = get_accounts_with_children(filters.account)
|
||||
if filters.account:
|
||||
conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})")
|
||||
params.extend(filters.account)
|
||||
|
||||
if filters.get("cost_center"):
|
||||
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
|
||||
conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})")
|
||||
params.extend(filters.cost_center)
|
||||
|
||||
if filters.get("voucher_no"):
|
||||
conditions.append("voucher_no = ?")
|
||||
params.append(filters.voucher_no)
|
||||
|
||||
if filters.get("against_voucher_no"):
|
||||
conditions.append("against_voucher = ?")
|
||||
params.append(filters.against_voucher_no)
|
||||
|
||||
if filters.get("ignore_err"):
|
||||
err_journals = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={
|
||||
"company": filters.get("company"),
|
||||
"docstatus": 1,
|
||||
"voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
if err_journals:
|
||||
filters.update({"voucher_no_not_in": err_journals})
|
||||
|
||||
if filters.get("ignore_cr_dr_notes"):
|
||||
system_generated = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={
|
||||
"company": filters.get("company"),
|
||||
"docstatus": 1,
|
||||
"voucher_type": ("in", ["Credit Note", "Debit Note"]),
|
||||
"is_system_generated": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
if system_generated:
|
||||
vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated
|
||||
filters.update({"voucher_no_not_in": vouchers_to_ignore})
|
||||
|
||||
if filters.get("voucher_no_not_in"):
|
||||
vouchers = filters.voucher_no_not_in
|
||||
conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})")
|
||||
params.extend(vouchers)
|
||||
|
||||
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
|
||||
conditions.append("party_type IN ('Customer', 'Supplier')")
|
||||
|
||||
if filters.get("party_type"):
|
||||
conditions.append("party_type = ?")
|
||||
params.append(filters.party_type)
|
||||
|
||||
if filters.get("party"):
|
||||
conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})")
|
||||
params.extend(filters.party)
|
||||
|
||||
# from_date: skip when filtering by account/party to allow opening balance calc in Python
|
||||
if filters.get("disable_opening_balance_calculation"):
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date >= ?")
|
||||
params.append(filters.from_date)
|
||||
elif not (
|
||||
filters.get("account")
|
||||
or filters.get("party")
|
||||
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
|
||||
):
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date >= ?")
|
||||
params.append(filters.from_date)
|
||||
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date <= ? OR is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date <= ?")
|
||||
params.append(filters.to_date)
|
||||
|
||||
if filters.get("project"):
|
||||
conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})")
|
||||
params.extend(filters.project)
|
||||
|
||||
company_fb = filters.get("company_fb") or frappe.get_cached_value(
|
||||
"Company", filters.company, "default_finance_book"
|
||||
)
|
||||
if filters.get("include_default_book_entries"):
|
||||
if filters.get("finance_book"):
|
||||
if company_fb and cstr(filters.finance_book) != cstr(company_fb):
|
||||
frappe.throw(
|
||||
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
|
||||
)
|
||||
fb_vals = [cstr(filters.finance_book), ""]
|
||||
else:
|
||||
fb_vals = [cstr(company_fb), ""]
|
||||
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)")
|
||||
params.extend(fb_vals)
|
||||
else:
|
||||
if filters.get("finance_book"):
|
||||
conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)")
|
||||
params.append(cstr(filters.finance_book))
|
||||
else:
|
||||
conditions.append("(finance_book IN ('') OR finance_book IS NULL)")
|
||||
|
||||
if not filters.get("show_cancelled_entries"):
|
||||
conditions.append("is_cancelled = 0")
|
||||
|
||||
accounting_dimensions_list = get_accounting_dimensions(as_list=False)
|
||||
if accounting_dimensions_list:
|
||||
for dimension in accounting_dimensions_list:
|
||||
if not dimension.disabled and dimension.document_type != "Finance Book":
|
||||
if filters.get(dimension.fieldname):
|
||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
||||
filters[dimension.fieldname] = get_dimension_with_children(
|
||||
dimension.document_type, filters.get(dimension.fieldname)
|
||||
)
|
||||
vals = (
|
||||
filters[dimension.fieldname]
|
||||
if isinstance(filters[dimension.fieldname], list)
|
||||
else [filters[dimension.fieldname]]
|
||||
)
|
||||
conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})")
|
||||
params.extend(vals)
|
||||
|
||||
return conditions, params
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-18 11:43:33.173207",
|
||||
"default_print_format": "P&L Statement Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:36:04.544347",
|
||||
"modified": "2026-07-01 13:36:14.934965",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
compute_margin_view_data,
|
||||
filter_accounts,
|
||||
filter_out_zero_value_rows,
|
||||
get_accounts,
|
||||
get_appropriate_currency,
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -197,3 +205,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
|
||||
chart["currency"] = currency
|
||||
|
||||
return chart
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
frappe.throw(
|
||||
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
|
||||
)
|
||||
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
|
||||
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
|
||||
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)
|
||||
|
||||
net_profit_loss = get_net_profit_loss(
|
||||
income, expense, period_list, filters.company, filters.presentation_currency
|
||||
)
|
||||
|
||||
data = []
|
||||
data.extend(income or [])
|
||||
data.extend(expense or [])
|
||||
if net_profit_loss:
|
||||
data.append(net_profit_loss)
|
||||
|
||||
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
|
||||
|
||||
currency = filters.presentation_currency or frappe.get_cached_value(
|
||||
"Company", filters.company, "default_currency"
|
||||
)
|
||||
chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency)
|
||||
|
||||
report_summary, primitive_summary = get_report_summary(
|
||||
period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
|
||||
)
|
||||
|
||||
if filters.get("selected_view") == "Growth":
|
||||
compute_growth_view_data(data, period_list)
|
||||
|
||||
if filters.get("selected_view") == "Margin":
|
||||
compute_margin_view_data(data, period_list, filters.accumulated_values)
|
||||
|
||||
return columns, data, None, chart, report_summary, primitive_summary
|
||||
|
||||
|
||||
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
|
||||
accounts = get_accounts(filters.company, root_type)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
|
||||
company_currency = get_appropriate_currency(filters.company, filters)
|
||||
|
||||
gl_entries_by_account = {}
|
||||
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account)
|
||||
|
||||
calculate_values(
|
||||
accounts_by_name,
|
||||
gl_entries_by_account,
|
||||
period_list,
|
||||
filters.accumulated_values,
|
||||
False,
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
|
||||
|
||||
out = prepare_data(
|
||||
accounts,
|
||||
balance_must_be,
|
||||
period_list,
|
||||
company_currency,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
)
|
||||
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
|
||||
|
||||
if out:
|
||||
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account):
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import (
|
||||
_extra_gl_conditions,
|
||||
_fetch_gl_rows_duckdb,
|
||||
)
|
||||
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
|
||||
|
||||
company = filters.company
|
||||
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
|
||||
if not leaf_accounts:
|
||||
return
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
|
||||
base_conds = [
|
||||
"company = ?",
|
||||
"is_cancelled = 0",
|
||||
f"account IN ({account_placeholders})",
|
||||
"voucher_type != 'Period Closing Voucher'",
|
||||
]
|
||||
base_params = [company, *leaf_accounts]
|
||||
base_conds.extend(extra_cond)
|
||||
base_params.extend(extra_params)
|
||||
|
||||
for period in period_list:
|
||||
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
|
||||
period_params = [*base_params, period.from_date, period.to_date]
|
||||
|
||||
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(period_entries, get_currency(filters))
|
||||
for entry in period_entries:
|
||||
entry.posting_date = period.to_date
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-22 11:41:23.743564",
|
||||
"default_print_format": "Trial Balance Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"idx": 2,
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:44.889062",
|
||||
"modified": "2026-07-01 17:32:21.801141",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -571,3 +571,215 @@ def hide_group_accounts(data):
|
||||
d.update(indent=0)
|
||||
non_group_accounts_data.append(d)
|
||||
return non_group_accounts_data
|
||||
|
||||
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
validate_filters(filters)
|
||||
columns = get_columns()
|
||||
data = get_data_duckdb(filters, conn)
|
||||
return columns, data
|
||||
else:
|
||||
frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
|
||||
def get_data_duckdb(filters, conn):
|
||||
# accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
|
||||
accounts = frappe.db.sql(
|
||||
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
|
||||
from `tabAccount` where company=%s order by lft""",
|
||||
filters.company,
|
||||
as_dict=True,
|
||||
)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
|
||||
|
||||
gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening)
|
||||
opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening)
|
||||
|
||||
calculate_values(
|
||||
accounts,
|
||||
gl_entries_by_account,
|
||||
opening_balances,
|
||||
filters.get("show_net_values"),
|
||||
ignore_is_opening=ignore_is_opening,
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name)
|
||||
|
||||
data = prepare_data(accounts, filters, parent_children_map, company_currency)
|
||||
return filter_out_zero_value_rows(
|
||||
data, parent_children_map, show_zero_values=filters.get("show_zero_values")
|
||||
)
|
||||
|
||||
|
||||
def _extra_gl_conditions(filters):
|
||||
"""Returns (conditions, params) for optional shared GL Entry filters."""
|
||||
conditions, params = [], []
|
||||
|
||||
if filters.get("cost_center"):
|
||||
cc = get_cost_centers_with_children(filters.get("cost_center"))
|
||||
conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})")
|
||||
params.extend(cc)
|
||||
|
||||
if filters.get("project"):
|
||||
proj = filters.project if isinstance(filters.project, list) else [filters.project]
|
||||
conditions.append(f"project IN ({', '.join(['?'] * len(proj))})")
|
||||
params.extend(proj)
|
||||
|
||||
if frappe.db.count("Finance Book"):
|
||||
company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book")
|
||||
if filters.get("include_default_book_entries"):
|
||||
if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb):
|
||||
frappe.throw(
|
||||
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
|
||||
)
|
||||
fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""]
|
||||
else:
|
||||
fb_list = [cstr(filters.get("finance_book")), ""]
|
||||
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
|
||||
params.extend(fb_list)
|
||||
|
||||
for dim in get_accounting_dimensions(as_list=False):
|
||||
if filters.get(dim.fieldname):
|
||||
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
|
||||
filters[dim.fieldname] = get_dimension_with_children(
|
||||
dim.document_type, filters.get(dim.fieldname)
|
||||
)
|
||||
vals = (
|
||||
filters[dim.fieldname]
|
||||
if isinstance(filters[dim.fieldname], list)
|
||||
else [filters[dim.fieldname]]
|
||||
)
|
||||
conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})")
|
||||
params.extend(vals)
|
||||
|
||||
return conditions, params
|
||||
|
||||
|
||||
def _fetch_gl_rows_duckdb(conn, conditions, params):
|
||||
cols = [
|
||||
"account",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
"account_currency",
|
||||
]
|
||||
sql = f"""SELECT account, SUM(debit), SUM(credit),
|
||||
SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency
|
||||
FROM "tabGL Entry" WHERE {" AND ".join(conditions)}
|
||||
GROUP BY account, account_currency"""
|
||||
return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening):
|
||||
conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"]
|
||||
params = [filters.company, filters.from_date, filters.to_date]
|
||||
|
||||
if not ignore_is_opening:
|
||||
conditions.append("is_opening = 'No'")
|
||||
if not flt(filters.get("with_period_closing_entry_for_current_period")):
|
||||
conditions.append("voucher_type != 'Period Closing Voucher'")
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
conditions.extend(extra_cond)
|
||||
params.extend(extra_params)
|
||||
|
||||
entries = _fetch_gl_rows_duckdb(conn, conditions, params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(entries, get_currency(filters))
|
||||
|
||||
gl_entries_by_account = {}
|
||||
for entry in entries:
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
return gl_entries_by_account
|
||||
|
||||
|
||||
def get_opening_balances_duckdb(conn, filters, ignore_is_opening):
|
||||
bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening)
|
||||
pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening)
|
||||
bs.update(pl)
|
||||
return bs
|
||||
|
||||
|
||||
def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening):
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
|
||||
last_pcv = ""
|
||||
|
||||
if not ignore_closing_balances:
|
||||
last_pcv = frappe.db.get_all(
|
||||
"Period Closing Voucher",
|
||||
filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)},
|
||||
fields=["period_end_date", "name"],
|
||||
order_by="period_end_date desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if last_pcv:
|
||||
# Account Closing Balance fetched via frappe (not GL Entry)
|
||||
gle = get_opening_balance(
|
||||
"Account Closing Balance",
|
||||
filters,
|
||||
report_type,
|
||||
accounting_dimensions,
|
||||
period_closing_voucher=last_pcv[0].name,
|
||||
ignore_is_opening=ignore_is_opening,
|
||||
)
|
||||
if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
|
||||
start_date = add_days(last_pcv[0].period_end_date, 1)
|
||||
gle += _get_gl_entry_opening_duckdb(
|
||||
conn, filters, report_type, ignore_is_opening, start_date=start_date
|
||||
)
|
||||
else:
|
||||
gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening)
|
||||
|
||||
opening = frappe._dict()
|
||||
for d in gle:
|
||||
opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0})
|
||||
opening[d.account]["opening_debit"] += flt(d.debit)
|
||||
opening[d.account]["opening_credit"] += flt(d.credit)
|
||||
return opening
|
||||
|
||||
|
||||
def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None):
|
||||
accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name")
|
||||
if not accounts:
|
||||
return []
|
||||
|
||||
conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
|
||||
params = [filters.company, *accounts]
|
||||
|
||||
if start_date:
|
||||
conditions.append("posting_date >= ? AND posting_date < ?")
|
||||
params.extend([start_date, filters.from_date])
|
||||
if not ignore_is_opening:
|
||||
conditions.append("is_opening = 'No'")
|
||||
elif not ignore_is_opening:
|
||||
conditions.append("(posting_date < ? OR is_opening = 'Yes')")
|
||||
params.append(filters.from_date)
|
||||
else:
|
||||
conditions.append("posting_date < ?")
|
||||
params.append(filters.from_date)
|
||||
|
||||
if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss":
|
||||
conditions.append("posting_date >= ?")
|
||||
params.append(filters.year_start_date)
|
||||
|
||||
if not flt(filters.get("with_period_closing_entry_for_opening")):
|
||||
conditions.append("voucher_type != 'Period Closing Voucher'")
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
conditions.extend(extra_cond)
|
||||
params.extend(extra_params)
|
||||
|
||||
gle = _fetch_gl_rows_duckdb(conn, conditions, params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(gle, get_currency(filters))
|
||||
return gle
|
||||
|
||||
@@ -1408,13 +1408,11 @@ def get_account_balances(accounts, company, finance_book=None, include_default_f
|
||||
def get_account_balances_coa(company: str, include_default_fb_balances: bool = False):
|
||||
company_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
|
||||
Account = DocType("Account")
|
||||
account_list = (
|
||||
frappe.qb.from_(Account)
|
||||
.select(Account.name, Account.parent_account, Account.account_currency)
|
||||
.where(Account.company == company)
|
||||
.orderby(Account.lft)
|
||||
.run(as_dict=True)
|
||||
account_list = frappe.get_list(
|
||||
"Account",
|
||||
fields=["name", "parent_account", "account_currency"],
|
||||
filters={"company": company},
|
||||
order_by="lft",
|
||||
)
|
||||
|
||||
account_balances_cc = {account.get("name"): 0 for account in account_list}
|
||||
@@ -1424,9 +1422,8 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
|
||||
GLEntry = DocType("GL Entry")
|
||||
precision = get_currency_precision()
|
||||
get_ledger_balances_query = (
|
||||
frappe.qb.from_(GLEntry)
|
||||
frappe.get_query(GLEntry, fields=[GLEntry.account], ignore_permissions=False)
|
||||
.select(
|
||||
GLEntry.account,
|
||||
(Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"),
|
||||
(
|
||||
Sum(Round(GLEntry.debit_in_account_currency, precision))
|
||||
@@ -1436,7 +1433,7 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
|
||||
.groupby(GLEntry.account)
|
||||
)
|
||||
|
||||
condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0]
|
||||
conditions = [GLEntry.company == company, GLEntry.is_cancelled == 0]
|
||||
|
||||
default_finance_book = None
|
||||
|
||||
@@ -1444,12 +1441,9 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
|
||||
default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book")
|
||||
|
||||
if default_finance_book:
|
||||
condition_list.append(
|
||||
(GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())
|
||||
)
|
||||
conditions.append((GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull()))
|
||||
|
||||
for condition in condition_list:
|
||||
get_ledger_balances_query = get_ledger_balances_query.where(condition)
|
||||
get_ledger_balances_query = get_ledger_balances_query.where(Criterion.all(conditions))
|
||||
|
||||
ledger_balances = get_ledger_balances_query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import get_date_str, nowdate
|
||||
|
||||
from erpnext.accounts.dashboard_fixtures import _get_fiscal_year
|
||||
from erpnext.buying.dashboard_fixtures import get_company_for_dashboards
|
||||
|
||||
|
||||
def get_data():
|
||||
fiscal_year = _get_fiscal_year(nowdate())
|
||||
|
||||
if not fiscal_year:
|
||||
return frappe._dict()
|
||||
|
||||
year_start_date = get_date_str(fiscal_year.get("year_start_date"))
|
||||
year_end_date = get_date_str(fiscal_year.get("year_end_date"))
|
||||
|
||||
return frappe._dict(
|
||||
{
|
||||
"dashboards": get_dashboards(),
|
||||
"charts": get_charts(fiscal_year, year_start_date, year_end_date),
|
||||
"number_cards": get_number_cards(fiscal_year, year_start_date, year_end_date),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_dashboards():
|
||||
return [
|
||||
{
|
||||
"name": "Asset",
|
||||
"dashboard_name": "Asset",
|
||||
"charts": [
|
||||
{"chart": "Asset Value Analytics", "width": "Full"},
|
||||
{"chart": "Category-wise Asset Value", "width": "Half"},
|
||||
{"chart": "Location-wise Asset Value", "width": "Half"},
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Total Assets"},
|
||||
{"card": "New Assets (This Year)"},
|
||||
{"card": "Asset Value"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def get_charts(fiscal_year, year_start_date, year_end_date):
|
||||
company = get_company_for_dashboards()
|
||||
return [
|
||||
{
|
||||
"name": "Asset Value Analytics",
|
||||
"chart_name": _("Asset Value Analytics"),
|
||||
"chart_type": "Report",
|
||||
"report_name": "Fixed Asset Register",
|
||||
"is_custom": 1,
|
||||
"group_by_type": "Count",
|
||||
"number_of_groups": 0,
|
||||
"is_public": 0,
|
||||
"timespan": "Last Year",
|
||||
"time_interval": "Yearly",
|
||||
"timeseries": 0,
|
||||
"filters_json": json.dumps(
|
||||
{
|
||||
"company": company,
|
||||
"status": "In Location",
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": fiscal_year.get("name"),
|
||||
"to_fiscal_year": fiscal_year.get("name"),
|
||||
"period_start_date": year_start_date,
|
||||
"period_end_date": year_end_date,
|
||||
"date_based_on": "Purchase Date",
|
||||
"group_by": "--Select a group--",
|
||||
}
|
||||
),
|
||||
"type": "Bar",
|
||||
"custom_options": json.dumps(
|
||||
{
|
||||
"type": "bar",
|
||||
"barOptions": {"stacked": 1},
|
||||
"axisOptions": {"shortenYAxisNumbers": 1},
|
||||
"tooltipOptions": {},
|
||||
}
|
||||
),
|
||||
"doctype": "Dashboard Chart",
|
||||
"y_axis": [],
|
||||
},
|
||||
{
|
||||
"name": "Category-wise Asset Value",
|
||||
"chart_name": _("Category-wise Asset Value"),
|
||||
"chart_type": "Report",
|
||||
"report_name": "Fixed Asset Register",
|
||||
"x_field": "asset_category",
|
||||
"timeseries": 0,
|
||||
"filters_json": json.dumps(
|
||||
{
|
||||
"company": company,
|
||||
"status": "In Location",
|
||||
"group_by": "Asset Category",
|
||||
"asset_type": ["!=", "Existing Asset"],
|
||||
}
|
||||
),
|
||||
"type": "Donut",
|
||||
"doctype": "Dashboard Chart",
|
||||
"y_axis": [
|
||||
{
|
||||
"parent": "Category-wise Asset Value",
|
||||
"parentfield": "y_axis",
|
||||
"parenttype": "Dashboard Chart",
|
||||
"y_field": "asset_value",
|
||||
"doctype": "Dashboard Chart Field",
|
||||
}
|
||||
],
|
||||
"custom_options": json.dumps(
|
||||
{"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}}
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Location-wise Asset Value",
|
||||
"chart_name": "Location-wise Asset Value",
|
||||
"chart_type": "Report",
|
||||
"report_name": "Fixed Asset Register",
|
||||
"x_field": "location",
|
||||
"timeseries": 0,
|
||||
"filters_json": json.dumps(
|
||||
{
|
||||
"company": company,
|
||||
"status": "In Location",
|
||||
"group_by": "Location",
|
||||
"asset_type": ["!=", "Existing Asset"],
|
||||
}
|
||||
),
|
||||
"type": "Donut",
|
||||
"doctype": "Dashboard Chart",
|
||||
"y_axis": [
|
||||
{
|
||||
"parent": "Location-wise Asset Value",
|
||||
"parentfield": "y_axis",
|
||||
"parenttype": "Dashboard Chart",
|
||||
"y_field": "asset_value",
|
||||
"doctype": "Dashboard Chart Field",
|
||||
}
|
||||
],
|
||||
"custom_options": json.dumps(
|
||||
{"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_number_cards(fiscal_year, year_start_date, year_end_date):
|
||||
return [
|
||||
{
|
||||
"name": "Total Assets",
|
||||
"label": _("Total Assets"),
|
||||
"function": "Count",
|
||||
"document_type": "Asset",
|
||||
"is_public": 1,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Monthly",
|
||||
"filters_json": "[]",
|
||||
"doctype": "Number Card",
|
||||
},
|
||||
{
|
||||
"name": "New Assets (This Year)",
|
||||
"label": _("New Assets (This Year)"),
|
||||
"function": "Count",
|
||||
"document_type": "Asset",
|
||||
"is_public": 1,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Monthly",
|
||||
"filters_json": json.dumps([["Asset", "creation", "between", [year_start_date, year_end_date]]]),
|
||||
"doctype": "Number Card",
|
||||
},
|
||||
{
|
||||
"name": "Asset Value",
|
||||
"label": _("Asset Value"),
|
||||
"function": "Sum",
|
||||
"aggregate_function_based_on": "value_after_depreciation",
|
||||
"document_type": "Asset",
|
||||
"is_public": 1,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Monthly",
|
||||
"filters_json": "[]",
|
||||
"doctype": "Number Card",
|
||||
},
|
||||
]
|
||||
@@ -147,7 +147,15 @@ frappe.ui.form.on("Asset", {
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.status === "Fully Depreciated") {
|
||||
frm.add_custom_button(
|
||||
__("Asset Repair"),
|
||||
function () {
|
||||
frm.trigger("create_asset_repair");
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
frm.add_custom_button(
|
||||
__("Split Asset"),
|
||||
function () {
|
||||
|
||||
@@ -1391,6 +1391,47 @@ class TestDepreciationBasics(AssetSetup):
|
||||
self.assertFalse(depr_schedule[1].journal_entry)
|
||||
self.assertFalse(depr_schedule[2].journal_entry)
|
||||
|
||||
def test_depr_schedule_link_matches_at_currency_precision(self):
|
||||
"""A Depreciation Schedule row whose amount carries more decimals than the
|
||||
company currency (e.g. 25701.202 vs a JE debit of 25701.20) must still be
|
||||
matched and stamped with the Journal Entry. Comparing at exact float
|
||||
equality left the link NULL, so the scheduler treated the row as unposted
|
||||
and created a duplicate Journal Entry on every run. Regression test for
|
||||
JournalEntry.update_journal_entry_link_on_depr_schedule()."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry import journal_entry as journal_entry_module
|
||||
|
||||
posting_date = getdate("2021-06-01")
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.name = "JE-DEPR-TEST"
|
||||
je.finance_book = None
|
||||
je.posting_date = posting_date
|
||||
|
||||
# JE debit is stored at company currency precision (2 dp)...
|
||||
je_row = MagicMock()
|
||||
je_row.debit = 25701.20
|
||||
je_row.precision.return_value = 2
|
||||
|
||||
# ...while the schedule row amount carries a third decimal.
|
||||
schedule_row = frappe._dict(
|
||||
name="DS-ROW-1",
|
||||
schedule_date=posting_date,
|
||||
journal_entry=None,
|
||||
depreciation_amount=25701.202,
|
||||
)
|
||||
asset = frappe._dict(name="ASSET-TEST")
|
||||
|
||||
with (
|
||||
patch.object(journal_entry_module, "get_depr_schedule", return_value=[schedule_row]),
|
||||
patch.object(frappe.db, "set_value") as mock_set_value,
|
||||
):
|
||||
je.update_journal_entry_link_on_depr_schedule(asset, je_row)
|
||||
|
||||
mock_set_value.assert_called_once_with(
|
||||
"Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST"
|
||||
)
|
||||
|
||||
def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self):
|
||||
"""Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account."""
|
||||
|
||||
|
||||
@@ -84,6 +84,15 @@ frappe.ui.form.on("Asset Repair", {
|
||||
};
|
||||
};
|
||||
}
|
||||
if (frm.doc.asset) {
|
||||
frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => {
|
||||
frm.set_df_property(
|
||||
"capitalize_repair_cost",
|
||||
"read_only",
|
||||
message && message.status === "Fully Depreciated"
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
|
||||
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -275,7 +275,7 @@
|
||||
"link_fieldname": "asset_repair"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-06 14:57:54.257572",
|
||||
"modified": "2026-06-20 15:43:54.943335",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Repair",
|
||||
|
||||
@@ -70,12 +70,15 @@ class AssetRepair(AccountsController):
|
||||
self.check_repair_status()
|
||||
|
||||
def validate_asset(self):
|
||||
if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"):
|
||||
if self.asset_doc.status in ("Sold", "Scrapped"):
|
||||
frappe.throw(
|
||||
_("Asset {0} is in {1} status and cannot be repaired.").format(
|
||||
get_link_to_form("Asset", self.asset), self.asset_doc.status
|
||||
)
|
||||
)
|
||||
if self.asset_doc.get_status() == "Fully Depreciated":
|
||||
self.capitalize_repair_cost = 0
|
||||
self.increase_in_asset_life = 0
|
||||
|
||||
def validate_dates(self):
|
||||
if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)):
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.model.workflow import get_workflow_name
|
||||
from frappe.query_builder import Criterion, DocType
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.query_builder.functions import Abs, IfNull, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
add_months,
|
||||
@@ -140,6 +140,26 @@ class AccountsController(TransactionBase):
|
||||
if self.doctype in relevant_docs:
|
||||
self.set_payment_schedule()
|
||||
|
||||
def before_insert(self):
|
||||
self.clear_clearance_date_on_amend()
|
||||
|
||||
def clear_clearance_date_on_amend(self):
|
||||
"""Drop the bank reconciliation clearance date copied over while amending.
|
||||
|
||||
The framework copies `no_copy` fields when amending, so a reconciled
|
||||
voucher would carry a stale clearance date into its amendment even though
|
||||
the linked bank transaction gets unreconciled on cancellation.
|
||||
"""
|
||||
if not self.get("amended_from"):
|
||||
return
|
||||
|
||||
if self.meta.has_field("clearance_date"):
|
||||
self.clearance_date = None
|
||||
|
||||
for payment in self.get("payments") or []:
|
||||
if payment.meta.has_field("clearance_date"):
|
||||
payment.clearance_date = None
|
||||
|
||||
def on_update(self):
|
||||
from erpnext.controllers.taxes_and_totals import process_item_wise_tax_details
|
||||
|
||||
@@ -3491,8 +3511,18 @@ def get_common_query(
|
||||
common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
|
||||
|
||||
if condition.get("accounting_dimensions"):
|
||||
apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions")
|
||||
for field, val in condition.get("accounting_dimensions").items():
|
||||
common_filter_conditions.append(payment_entry[field] == val)
|
||||
if isinstance(val, list | tuple | set):
|
||||
value_condition = payment_entry[field].isin(val)
|
||||
if apply_strict_user_permissions:
|
||||
common_filter_conditions.append(value_condition)
|
||||
else:
|
||||
common_filter_conditions.append(
|
||||
(IfNull(payment_entry[field], "") == "") | value_condition
|
||||
)
|
||||
else:
|
||||
common_filter_conditions.append(payment_entry[field] == val)
|
||||
|
||||
if condition.get("minimum_payment_amount"):
|
||||
common_filter_conditions.append(
|
||||
|
||||
@@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute):
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def get_attribute_abbr_renames(item_attribute):
|
||||
"""Return the set of (current) attribute values whose abbreviation was renamed."""
|
||||
if item_attribute.numeric_values:
|
||||
return set()
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return set()
|
||||
|
||||
old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values}
|
||||
changed_values = set()
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_abbrs and old_abbrs[row.name] != row.abbr:
|
||||
changed_values.add(row.attribute_value)
|
||||
|
||||
return changed_values
|
||||
|
||||
|
||||
def update_variant_item_codes_for_abbr_renames(item_attribute):
|
||||
"""Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation."""
|
||||
changed_values = get_attribute_abbr_renames(item_attribute)
|
||||
if not changed_values:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
variant_names = (
|
||||
frappe.qb.from_(item_variant_table)
|
||||
.select(item_variant_table.parent)
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(item_variant_table.attribute_value.isin(list(changed_values)))
|
||||
.distinct()
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
for variant_name in variant_names:
|
||||
rename_variant_item_code(variant_name)
|
||||
|
||||
|
||||
def rename_variant_item_code(variant_name):
|
||||
"""Recompute a variant's item_code/item_name from its template and current attribute abbreviations,
|
||||
renaming the Item if it has changed."""
|
||||
variant = frappe.get_doc("Item", variant_name)
|
||||
if not variant.variant_of:
|
||||
return
|
||||
|
||||
template = frappe.get_cached_doc("Item", variant.variant_of)
|
||||
|
||||
new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes})
|
||||
make_variant_item_code(template.item_code, template.item_name, new_code)
|
||||
|
||||
if not new_code.item_code or new_code.item_code == variant.item_code:
|
||||
return
|
||||
|
||||
frappe.rename_doc("Item", variant.item_code, new_code.item_code)
|
||||
|
||||
# Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so
|
||||
# item_name is always rebuilt here too, even if it had since been customized away from that pattern.
|
||||
if new_code.item_name and new_code.item_name != variant.item_name:
|
||||
frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name)
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -166,7 +166,8 @@ status_map = {
|
||||
"Pick List": [
|
||||
["Draft", None],
|
||||
["Open", "eval:self.docstatus == 1"],
|
||||
["Completed", "stock_entry_exists"],
|
||||
["Completed", "is_fully_transferred"],
|
||||
["Partially Transferred", "is_partially_transferred"],
|
||||
[
|
||||
"Partly Delivered",
|
||||
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",
|
||||
|
||||
@@ -387,6 +387,10 @@ class StockController(AccountsController):
|
||||
parent_details = self.get_parent_details_for_packed_items()
|
||||
|
||||
for row in self.get(table_name):
|
||||
item_code = row.get("rm_item_code") or row.get("item_code")
|
||||
if not item_code or not self.is_serial_batch_item(item_code):
|
||||
continue
|
||||
|
||||
if (
|
||||
not via_landed_cost_voucher
|
||||
and row.serial_and_batch_bundle
|
||||
@@ -2056,6 +2060,7 @@ class StockController(AccountsController):
|
||||
def show_accounting_ledger_preview(company, doctype, docname):
|
||||
filters = frappe._dict(company=company, include_dimensions=1)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_gl_preview")
|
||||
|
||||
gl_columns, gl_data = get_accounting_ledger_preview(doc, filters)
|
||||
@@ -2069,6 +2074,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
|
||||
def show_stock_ledger_preview(company, doctype, docname):
|
||||
filters = frappe._dict(company=company)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_sl_preview")
|
||||
|
||||
sl_columns, sl_data = get_stock_ledger_preview(doc, filters)
|
||||
|
||||
77
erpnext/controllers/tests/test_stock_controller.py
Normal file
77
erpnext/controllers/tests/test_stock_controller.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.controllers.stock_controller import (
|
||||
show_accounting_ledger_preview,
|
||||
show_stock_ledger_preview,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestLedgerPreviewPermission(ERPNextTestSuite):
|
||||
def test_accounting_ledger_preview_requires_read_permission(self):
|
||||
company = "_Test Company"
|
||||
je = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
|
||||
|
||||
email = "ledger_preview_no_role@example.com"
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": email,
|
||||
"first_name": "No Role",
|
||||
"user_type": "Website User",
|
||||
"send_welcome_email": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
try:
|
||||
frappe.set_user(email)
|
||||
self.assertRaises(
|
||||
frappe.PermissionError,
|
||||
show_accounting_ledger_preview,
|
||||
company,
|
||||
"Journal Entry",
|
||||
je.name,
|
||||
)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
# a permitted user is still able to read the preview
|
||||
accounting_ledger_result = show_accounting_ledger_preview(company, "Journal Entry", je.name)
|
||||
self.assertTrue(accounting_ledger_result.get("gl_data"))
|
||||
|
||||
def test_stock_ledger_preview_requires_read_permission(self):
|
||||
company = "_Test Company"
|
||||
pr = make_purchase_receipt()
|
||||
|
||||
email = "ledger_preview_no_role@example.com"
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": email,
|
||||
"first_name": "No Role",
|
||||
"user_type": "Website User",
|
||||
"send_welcome_email": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
try:
|
||||
frappe.set_user(email)
|
||||
self.assertRaises(
|
||||
frappe.PermissionError,
|
||||
show_stock_ledger_preview,
|
||||
company,
|
||||
"Purchase Receipt",
|
||||
pr.name,
|
||||
)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
stock_ledger_result = show_stock_ledger_preview(company, "Purchase Receipt", pr.name)
|
||||
self.assertTrue(stock_ledger_result.get("sl_data"))
|
||||
@@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans):
|
||||
|
||||
# based_on_cols, based_on_select, based_on_group_by, addl_tables
|
||||
if based_on == "Item":
|
||||
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
|
||||
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.item_code, t2.item_name,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_code"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Item Group":
|
||||
based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Item Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Item Group",
|
||||
"width": 120,
|
||||
"fieldname": "item_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.item_group,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_group"
|
||||
based_on_details["addl_tables"] = ""
|
||||
@@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans):
|
||||
elif based_on == "Customer":
|
||||
if trans == "Quotation":
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Party:Link/Customer:120",
|
||||
"Party Name:Data:120",
|
||||
"Territory:Link/Territory:120",
|
||||
{
|
||||
"label": _("Party"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
"fieldname": "party",
|
||||
},
|
||||
{"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory,"
|
||||
else:
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Customer:Link/Customer:120",
|
||||
"Customer Name:Data:120",
|
||||
"Territory:Link/Territory:120",
|
||||
{
|
||||
"label": _("Customer"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
"fieldname": "customer",
|
||||
},
|
||||
{
|
||||
"label": _("Customer Name"),
|
||||
"fieldtype": "Data",
|
||||
"width": 120,
|
||||
"fieldname": "customer_name",
|
||||
},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory,"
|
||||
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Customer Group":
|
||||
based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Customer Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer Group",
|
||||
"fieldname": "customer_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.customer_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.customer_group"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Supplier":
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Supplier:Link/Supplier:120",
|
||||
"Supplier Name:Data:120",
|
||||
"Supplier Group:Link/Supplier Group:140",
|
||||
{
|
||||
"label": _("Supplier"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier",
|
||||
"width": 120,
|
||||
"fieldname": "supplier",
|
||||
},
|
||||
{"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"},
|
||||
{
|
||||
"label": _("Supplier Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier Group",
|
||||
"width": 140,
|
||||
"fieldname": "supplier_group",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.supplier"
|
||||
@@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans):
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
elif based_on == "Supplier Group":
|
||||
based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Supplier Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier Group",
|
||||
"width": 140,
|
||||
"fieldname": "supplier_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t3.supplier_group"
|
||||
based_on_details["addl_tables"] = ",`tabSupplier` t3"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
elif based_on == "Territory":
|
||||
based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.territory,"
|
||||
based_on_details["based_on_group_by"] = "t1.territory"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Project":
|
||||
if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]:
|
||||
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 120,
|
||||
"fieldname": "project",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.project,"
|
||||
based_on_details["based_on_group_by"] = "t1.project"
|
||||
based_on_details["addl_tables"] = ""
|
||||
elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]:
|
||||
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 120,
|
||||
"fieldname": "project",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.project,"
|
||||
based_on_details["based_on_group_by"] = "t2.project"
|
||||
based_on_details["addl_tables"] = ""
|
||||
@@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans):
|
||||
frappe.throw(_("Project-wise data is not available for Quotation"))
|
||||
|
||||
based_on_details["based_on_select"] += "t4.default_currency as currency,"
|
||||
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
|
||||
based_on_details["based_on_cols"].append(
|
||||
{
|
||||
"label": _("Currency"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Currency",
|
||||
"width": 120,
|
||||
"fieldname": "currency",
|
||||
}
|
||||
)
|
||||
based_on_details["addl_tables"] += ", `tabCompany` t4"
|
||||
based_on_details["addl_tables_relational_cond"] = (
|
||||
based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name"
|
||||
@@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans):
|
||||
|
||||
def group_wise_column(group_by):
|
||||
if group_by:
|
||||
return [group_by + ":Link/" + group_by + ":120"]
|
||||
return [
|
||||
{
|
||||
"label": _(group_by),
|
||||
"fieldtype": "Link",
|
||||
"options": group_by,
|
||||
"width": 120,
|
||||
"fieldname": frappe.scrub(group_by),
|
||||
}
|
||||
]
|
||||
else:
|
||||
return []
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Settings", {
|
||||
// refresh: function(frm) {
|
||||
// }
|
||||
refresh: function (frm) {
|
||||
const flag = frm.events.calculate_visiblity_flag(frm);
|
||||
|
||||
frm.set_df_property("allowed_users", "hidden", !flag);
|
||||
frm.set_df_property("allowed_users", "reqd", flag);
|
||||
},
|
||||
|
||||
enable_frappe_crm_data_synchronization: function (frm) {
|
||||
const flag = frm.events.calculate_visiblity_flag(frm);
|
||||
|
||||
if (flag) {
|
||||
frappe.show_alert(
|
||||
__("Allowed Users is required for data synchronization from remote Frappe CRM site.")
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization
|
||||
is set and crm app is not installed.
|
||||
*/
|
||||
|
||||
frm.set_df_property("allowed_users", "hidden", !flag);
|
||||
frm.set_df_property("allowed_users", "reqd", flag);
|
||||
},
|
||||
|
||||
calculate_visiblity_flag: function (frm) {
|
||||
const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization;
|
||||
const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm"));
|
||||
|
||||
return crm_sync_enabled && !is_crm_installed;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,9 +120,9 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
|
||||
"fieldname": "allowed_users",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"hidden": 1,
|
||||
"label": "Allowed Users",
|
||||
"options": "Frappe CRM Allowed User",
|
||||
"permlevel": 1
|
||||
@@ -140,7 +140,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-22 01:26:13.474915",
|
||||
"modified": "2026-07-01 01:09:16.461470",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM Settings",
|
||||
|
||||
@@ -6,6 +6,8 @@ from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext.crm.frappe_crm_api import is_crm_installed
|
||||
|
||||
|
||||
class CRMSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -46,13 +48,16 @@ class CRMSettings(Document):
|
||||
)
|
||||
|
||||
def validate_allowed_users(self):
|
||||
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
|
||||
if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
)
|
||||
)
|
||||
|
||||
if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users:
|
||||
frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site."))
|
||||
|
||||
def before_save(self):
|
||||
self.clear_allowed_users()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
|
||||
import click
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
@@ -150,7 +151,9 @@ def create_customer(customer_data=None):
|
||||
for field in CUSTOMER_ALLOWED_FIELDS:
|
||||
if customer_data.get(field) is not None:
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
|
||||
# If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records.
|
||||
customer.insert(ignore_permissions=not is_crm_installed())
|
||||
customer_name = customer.name
|
||||
|
||||
contacts = json.loads(customer_data.get("contacts"))
|
||||
@@ -169,6 +172,10 @@ def validate_frappe_crm_sync():
|
||||
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
|
||||
)
|
||||
|
||||
# Skip allowed_users validation if CRM is installed on the site.
|
||||
if is_crm_installed():
|
||||
return
|
||||
|
||||
allowed_users = [d.user for d in CRMSettings.allowed_users]
|
||||
|
||||
if frappe.session.user not in allowed_users:
|
||||
@@ -178,3 +185,35 @@ def validate_frappe_crm_sync():
|
||||
),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
|
||||
def is_crm_installed():
|
||||
return "crm" in frappe.get_installed_apps()
|
||||
|
||||
|
||||
def remove_allowed_users_on_crm_install():
|
||||
try:
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
return
|
||||
|
||||
CRMSettings.allowed_users = []
|
||||
CRMSettings.save()
|
||||
click.secho("Removed 'Allowed Users' from CRM Settings.")
|
||||
except Exception:
|
||||
click.secho("'Allowed Users' from CRM Settings couldn't be cleared.")
|
||||
|
||||
|
||||
def disable_frappe_crm_data_synchronization_on_crm_uninstall():
|
||||
try:
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
return
|
||||
|
||||
CRMSettings.enable_frappe_crm_data_synchronization = 0
|
||||
CRMSettings.save()
|
||||
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.")
|
||||
except Exception:
|
||||
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.")
|
||||
|
||||
@@ -189,6 +189,7 @@ def get_filtered_todos(ref_doctype, ref_docname, status: str | tuple[str, str]):
|
||||
"allocated_to",
|
||||
"date",
|
||||
],
|
||||
order_by="date asc",
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +219,7 @@ def get_filtered_events(ref_doctype, ref_docname, open: bool):
|
||||
& (event_link.reference_docname == ref_docname)
|
||||
& (event_status_filter)
|
||||
)
|
||||
.orderby(event.starts_on)
|
||||
)
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages"
|
||||
|
||||
after_install = "erpnext.setup.install.after_install"
|
||||
|
||||
after_app_install = "erpnext.setup.install.after_app_install"
|
||||
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
|
||||
|
||||
boot_session = "erpnext.startup.boot.boot_session"
|
||||
notification_config = "erpnext.startup.notifications.get_notification_config"
|
||||
get_help_messages = "erpnext.utilities.activation.get_help_messages"
|
||||
@@ -489,6 +492,7 @@ scheduler_events = {
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
"erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries",
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
|
||||
1907
erpnext/locale/af.po
1907
erpnext/locale/af.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/ar.po
1911
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/bg.po
1907
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
2059
erpnext/locale/bs.po
2059
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/cs.po
1907
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/da.po
1907
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/de.po
1913
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
2039
erpnext/locale/eo.po
2039
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/es.po
1911
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
1935
erpnext/locale/fa.po
1935
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/fi.po
1907
erpnext/locale/fi.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/fr.po
1913
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
1915
erpnext/locale/hi.po
1915
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
2055
erpnext/locale/hr.po
2055
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/hu.po
1911
erpnext/locale/hu.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