mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-22 20:30:38 +00:00
Compare commits
242 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5de60c357 | ||
|
|
264bfa188b | ||
|
|
2c8c076f6e | ||
|
|
a0ac2a58dd | ||
|
|
5930ce623c | ||
|
|
3a196ae8e7 | ||
|
|
beeffee8f9 | ||
|
|
0f252542c3 | ||
|
|
92217b2c45 | ||
|
|
a4bf50656a | ||
|
|
6b3b03fcd8 | ||
|
|
c9394c030f | ||
|
|
429b58b833 | ||
|
|
e0d0bf07c8 | ||
|
|
a26296ca50 | ||
|
|
8a320fc0af | ||
|
|
f5bb4052fa | ||
|
|
0bdf258888 | ||
|
|
fe65882e59 | ||
|
|
a38cbfc88e | ||
|
|
942a8db7fa | ||
|
|
b7cf3bf641 | ||
|
|
0d53f1adb7 | ||
|
|
fcee4f128e | ||
|
|
e2be05e48b | ||
|
|
a9c3a00dc4 | ||
|
|
50c79c82bb | ||
|
|
c466b49d09 | ||
|
|
406d3b3862 | ||
|
|
2852671cd5 | ||
|
|
a3bfdede06 | ||
|
|
34a65e5dba | ||
|
|
ec0da0f113 | ||
|
|
aa08f753b4 | ||
|
|
c3aea9ca9c | ||
|
|
025b2f2922 | ||
|
|
5f6952b15c | ||
|
|
3948951904 | ||
|
|
915562c7a8 | ||
|
|
dfd05caeb0 | ||
|
|
827831a247 | ||
|
|
e1ebfa7163 | ||
|
|
c055de2da6 | ||
|
|
c5d94d5fc3 | ||
|
|
74451366c2 | ||
|
|
134d63de78 | ||
|
|
8946fc41a2 | ||
|
|
cbd4f93f3d | ||
|
|
0817d1064c | ||
|
|
b1adec7e9e | ||
|
|
caea21208e | ||
|
|
2808b34d42 | ||
|
|
7ccb2584c4 | ||
|
|
f3e1b3fca7 | ||
|
|
555c607f2f | ||
|
|
7cd7e4ab0f | ||
|
|
839fd5e3b6 | ||
|
|
56bbca0203 | ||
|
|
478719b828 | ||
|
|
57a49ce168 | ||
|
|
6d47c51c5b | ||
|
|
6ab1668b37 | ||
|
|
b256c76c3b | ||
|
|
e24ef847dc | ||
|
|
20f6ac81b9 | ||
|
|
de591661b9 | ||
|
|
9a7e796fd2 | ||
|
|
b1ca9c26cb | ||
|
|
4d931a7108 | ||
|
|
4d951c1cf8 | ||
|
|
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 | ||
|
|
9d5c7605b8 | ||
|
|
6bd903e45a | ||
|
|
5281d538ce | ||
|
|
5261566b41 | ||
|
|
5bbb71ef76 | ||
|
|
d453781374 | ||
|
|
ec782ee20d | ||
|
|
703e9a728c | ||
|
|
f94eee3197 | ||
|
|
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 | ||
|
|
02549ef8c4 | ||
|
|
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 | ||
|
|
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.2"
|
||||
__version__ = "16.29.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -120,6 +120,7 @@ class Account(NestedSet):
|
||||
self.validate_account_currency()
|
||||
self.validate_root_company_and_sync_account_to_children()
|
||||
self.validate_receivable_payable_account_type()
|
||||
self.validate_stock_account_type_change()
|
||||
|
||||
def validate_parent_child_account_type(self):
|
||||
if self.parent_account:
|
||||
@@ -208,6 +209,36 @@ class Account(NestedSet):
|
||||
frappe.msgprint(msg)
|
||||
self.add_comment("Comment", msg)
|
||||
|
||||
def validate_stock_account_type_change(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not (doc_before_save and doc_before_save.account_type == "Stock"):
|
||||
return
|
||||
|
||||
if self.account_type == "Stock":
|
||||
return
|
||||
|
||||
if self.stock_ledger_entry_exists():
|
||||
frappe.throw(
|
||||
_(
|
||||
"The account type of {0} cannot be changed from {1} because stock ledger entries exist against it."
|
||||
).format(frappe.bold(self.name), frappe.bold(_("Stock")))
|
||||
)
|
||||
|
||||
def stock_ledger_entry_exists(self):
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
|
||||
warehouse_account = get_warehouse_account_map(self.company)
|
||||
warehouses = [wh for wh, details in warehouse_account.items() if details.account == self.name]
|
||||
if not warehouses:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.count(
|
||||
"Stock Ledger Entry",
|
||||
filters={"warehouse": ("in", warehouses), "is_cancelled": 0},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_root_details(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -307,6 +307,31 @@ class TestAccount(ERPNextTestSuite):
|
||||
acc.account_currency = "USD"
|
||||
self.assertRaises(frappe.ValidationError, acc.save)
|
||||
|
||||
def test_stock_account_type_change_with_ledger_entries(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
stock_account = get_warehouse_account(frappe.get_doc("Warehouse", warehouse))
|
||||
|
||||
make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
target=warehouse,
|
||||
company=company,
|
||||
qty=5,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
account = frappe.get_doc("Account", stock_account)
|
||||
self.assertEqual(account.account_type, "Stock")
|
||||
|
||||
account.account_type = ""
|
||||
self.assertRaises(frappe.ValidationError, account.save)
|
||||
|
||||
account.reload()
|
||||
account.account_name = f"{account.account_name} Updated"
|
||||
account.save() # non-type change stays allowed
|
||||
|
||||
def test_account_balance(self):
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -218,6 +218,7 @@ def build_forest(data):
|
||||
for row in data:
|
||||
account_name, parent_account, account_number, parent_account_number = row[0:4]
|
||||
if account_number:
|
||||
account_number = cstr(account_number).strip()
|
||||
account_name = f"{account_number} - {account_name}"
|
||||
if parent_account_number:
|
||||
parent_account_number = cstr(parent_account_number).strip()
|
||||
|
||||
@@ -169,23 +169,10 @@ frappe.ui.form.on("Dunning", {
|
||||
},
|
||||
get_dunning_letter_text: function (frm) {
|
||||
if (frm.doc.dunning_type) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text",
|
||||
args: {
|
||||
dunning_type: frm.doc.dunning_type,
|
||||
language: frm.doc.language,
|
||||
doc: frm.doc,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("body_text", r.message.body_text);
|
||||
frm.set_value("closing_text", r.message.closing_text);
|
||||
frm.set_value("language", r.message.language);
|
||||
} else {
|
||||
frm.set_value("body_text", "");
|
||||
frm.set_value("closing_text", "");
|
||||
}
|
||||
},
|
||||
frm.call("get_dunning_letter_text").then((r) => {
|
||||
if (!r.exc) {
|
||||
frm.refresh_fields();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -163,6 +163,46 @@ class Dunning(AccountsController):
|
||||
"Serial and Batch Bundle",
|
||||
]
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_dunning_letter_text(self):
|
||||
DOCTYPE = "Dunning Letter Text"
|
||||
FIELDS = ["body_text", "closing_text", "language"]
|
||||
|
||||
if not self.dunning_type:
|
||||
return
|
||||
|
||||
filters = {"parent": self.dunning_type, "is_default_language": 1}
|
||||
|
||||
if self.language:
|
||||
filters.pop("is_default_language")
|
||||
filters["language"] = self.language
|
||||
|
||||
letter_text = frappe.db.get_value(DOCTYPE, filters, FIELDS, as_dict=True)
|
||||
|
||||
if not letter_text:
|
||||
msg = (
|
||||
_("Dunning Letter for Dunning Type {0} in language '{1}' not found.").format(
|
||||
frappe.bold(self.dunning_type), frappe.bold(self.language)
|
||||
)
|
||||
if self.language
|
||||
else _("Dunning Letter for Dunning Type {0} not found.").format(
|
||||
frappe.bold(self.dunning_type)
|
||||
)
|
||||
)
|
||||
frappe.msgprint(msg, alert=True, indicator="yellow")
|
||||
|
||||
self.body_text = (
|
||||
frappe.render_template(letter_text.body_text, self.as_dict(), restrict_globals=True)
|
||||
if letter_text
|
||||
else None
|
||||
)
|
||||
self.closing_text = (
|
||||
frappe.render_template(letter_text.closing_text, self.as_dict(), restrict_globals=True)
|
||||
if letter_text
|
||||
else None
|
||||
)
|
||||
self.language = letter_text.language if letter_text else self.language
|
||||
|
||||
|
||||
def update_linked_dunnings(doc, previous_outstanding_amount):
|
||||
if (
|
||||
@@ -241,35 +281,3 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
& (overdue_payment.sales_invoice == sales_invoice)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
|
||||
DOCTYPE = "Dunning Letter Text"
|
||||
FIELDS = ["body_text", "closing_text", "language"]
|
||||
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
if not language:
|
||||
language = doc.get("language")
|
||||
|
||||
letter_text = None
|
||||
if language:
|
||||
letter_text = frappe.db.get_value(
|
||||
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
|
||||
)
|
||||
|
||||
if not letter_text:
|
||||
letter_text = frappe.db.get_value(
|
||||
DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
|
||||
)
|
||||
|
||||
if not letter_text:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"body_text": frappe.render_template(letter_text.body_text, doc),
|
||||
"closing_text": frappe.render_template(letter_text.closing_text, doc),
|
||||
"language": letter_text.language,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import comma_and
|
||||
from frappe.utils.jinja import validate_template
|
||||
|
||||
|
||||
class DunningType(Document):
|
||||
@@ -30,3 +33,134 @@ class DunningType(Document):
|
||||
def autoname(self):
|
||||
company_abbr = frappe.get_value("Company", self.company, "abbr")
|
||||
self.name = f"{self.dunning_type} - {company_abbr}"
|
||||
|
||||
def validate(self):
|
||||
self.validate_dunning_letter_text()
|
||||
self.validate_income_account()
|
||||
self.validate_cost_center()
|
||||
self.set_default_dunning_type()
|
||||
|
||||
def validate_dunning_letter_text(self):
|
||||
self.validate_languages()
|
||||
self.validate_is_default_language()
|
||||
self.validate_dunning_letter_text_templates()
|
||||
|
||||
def validate_income_account(self):
|
||||
if not self.income_account:
|
||||
return
|
||||
|
||||
account = frappe.get_cached_doc("Account", self.income_account)
|
||||
|
||||
msg = []
|
||||
if account.company != self.company:
|
||||
msg.append(
|
||||
_(
|
||||
"{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}."
|
||||
).format(frappe.bold(self.income_account), frappe.bold(self.company))
|
||||
)
|
||||
|
||||
if account.disabled:
|
||||
msg.append(
|
||||
_("{0} is disabled. Please select a valid Income Account.").format(
|
||||
frappe.bold(self.income_account)
|
||||
)
|
||||
)
|
||||
|
||||
if account.root_type != "Income":
|
||||
msg.append(
|
||||
_("{0} is not an Income Account. Please select a valid Income Account.").format(
|
||||
frappe.bold(self.income_account)
|
||||
)
|
||||
)
|
||||
|
||||
if account.is_group:
|
||||
msg.append(
|
||||
_("{0} is a group account. Please select a non-group Income Account.").format(
|
||||
frappe.bold(self.income_account)
|
||||
)
|
||||
)
|
||||
|
||||
if msg:
|
||||
frappe.msgprint(
|
||||
msg,
|
||||
title=_("Income Account Validation Error"),
|
||||
as_list=True,
|
||||
raise_exception=frappe.ValidationError,
|
||||
)
|
||||
|
||||
def validate_cost_center(self):
|
||||
if not self.cost_center:
|
||||
return
|
||||
|
||||
cost_center = frappe.get_cached_doc("Cost Center", self.cost_center)
|
||||
|
||||
msg = []
|
||||
if cost_center.company != self.company:
|
||||
msg.append(
|
||||
_(
|
||||
"{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}."
|
||||
).format(frappe.bold(self.cost_center), frappe.bold(self.company))
|
||||
)
|
||||
|
||||
if cost_center.disabled:
|
||||
msg.append(
|
||||
_("{0} is disabled. Please select an enabled Cost Center.").format(
|
||||
frappe.bold(self.cost_center)
|
||||
)
|
||||
)
|
||||
|
||||
if cost_center.is_group:
|
||||
msg.append(
|
||||
_("{0} is a group Cost Center. Please select a non-group Cost Center.").format(
|
||||
frappe.bold(self.cost_center)
|
||||
)
|
||||
)
|
||||
|
||||
if msg:
|
||||
frappe.msgprint(
|
||||
msg,
|
||||
title=_("Cost Center Validation Error"),
|
||||
as_list=True,
|
||||
raise_exception=frappe.ValidationError,
|
||||
)
|
||||
|
||||
def validate_languages(self):
|
||||
languages = [d.language for d in self.dunning_letter_text]
|
||||
|
||||
if len(languages) == len(set(languages)):
|
||||
return
|
||||
|
||||
frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them."))
|
||||
|
||||
def validate_is_default_language(self):
|
||||
is_default_language_list = [
|
||||
d.language for d in self.dunning_letter_text if d.is_default_language == 1
|
||||
]
|
||||
|
||||
if len(is_default_language_list) <= 1:
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("{0} languages are marked as default languages. Please select only one of them.").format(
|
||||
comma_and(is_default_language_list, add_quotes=True)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_dunning_letter_text_templates(self):
|
||||
for d in self.dunning_letter_text:
|
||||
if d.body_text:
|
||||
validate_template(d.body_text, restrict_globals=True)
|
||||
|
||||
if d.closing_text:
|
||||
validate_template(d.closing_text, restrict_globals=True)
|
||||
|
||||
def set_default_dunning_type(self):
|
||||
if self.is_default != 1:
|
||||
return
|
||||
|
||||
frappe.db.set_value(
|
||||
"Dunning Type",
|
||||
{"company": self.company, "is_default": 1, "name": ["!=", self.name]},
|
||||
"is_default",
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,200 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def make_dunning_type(dunning_type, company="_Test Company", **kwargs):
|
||||
doc = frappe.new_doc("Dunning Type")
|
||||
doc.dunning_type = dunning_type
|
||||
doc.company = company
|
||||
doc.dunning_fee = kwargs.get("dunning_fee", 100)
|
||||
doc.rate_of_interest = kwargs.get("rate_of_interest", 5)
|
||||
doc.is_default = kwargs.get("is_default", 0)
|
||||
|
||||
if "income_account" in kwargs:
|
||||
doc.income_account = kwargs["income_account"]
|
||||
elif kwargs.get("income_account") is not False:
|
||||
doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1"
|
||||
|
||||
if "cost_center" in kwargs:
|
||||
doc.cost_center = kwargs["cost_center"]
|
||||
elif kwargs.get("cost_center") is not False:
|
||||
doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1"
|
||||
|
||||
for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]):
|
||||
doc.append("dunning_letter_text", row)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
class TestDunningType(ERPNextTestSuite):
|
||||
pass
|
||||
def test_income_account_must_belong_to_company(self):
|
||||
doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
|
||||
|
||||
def test_income_account_must_not_be_disabled(self):
|
||||
disabled_account = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "_Test Disabled Income Account",
|
||||
"parent_account": "Direct Income - _TC",
|
||||
"company": "_Test Company",
|
||||
"account_type": "Income Account",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
|
||||
|
||||
def test_income_account_must_be_income_type(self):
|
||||
doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert)
|
||||
|
||||
def test_income_account_must_not_be_group(self):
|
||||
doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert)
|
||||
|
||||
def test_income_account_is_optional(self):
|
||||
doc = make_dunning_type("_Test Dunning No Income Account", income_account=False)
|
||||
doc.insert()
|
||||
self.assertFalse(doc.income_account)
|
||||
|
||||
def test_valid_income_account_passes(self):
|
||||
doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC")
|
||||
doc.insert()
|
||||
self.assertEqual(doc.income_account, "Sales - _TC")
|
||||
|
||||
def test_cost_center_must_belong_to_company(self):
|
||||
doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
|
||||
|
||||
def test_cost_center_must_not_be_disabled(self):
|
||||
disabled_cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Disabled Cost Center",
|
||||
"parent_cost_center": "_Test Company - _TC",
|
||||
"company": "_Test Company",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
|
||||
|
||||
def test_cost_center_must_not_be_group(self):
|
||||
doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert)
|
||||
|
||||
def test_cost_center_is_optional(self):
|
||||
doc = make_dunning_type("_Test Dunning No CC", cost_center=False)
|
||||
doc.insert()
|
||||
self.assertFalse(doc.cost_center)
|
||||
|
||||
def test_valid_cost_center_passes(self):
|
||||
doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC")
|
||||
doc.insert()
|
||||
self.assertEqual(doc.cost_center, "Main - _TC")
|
||||
|
||||
def test_duplicate_languages_not_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Duplicate Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one"},
|
||||
{"language": "en", "body_text": "Body two"},
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert)
|
||||
|
||||
def test_unique_languages_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Unique Languages",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one"},
|
||||
{"language": "de", "body_text": "Body two"},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertEqual(len(doc.dunning_letter_text), 2)
|
||||
|
||||
def test_only_one_default_language_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Multiple Default Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one", "is_default_language": 1},
|
||||
{"language": "de", "body_text": "Body two", "is_default_language": 1},
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "languages are marked as default languages", doc.insert
|
||||
)
|
||||
|
||||
def test_single_default_language_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Single Default Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one", "is_default_language": 1},
|
||||
{"language": "de", "body_text": "Body two", "is_default_language": 0},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1)
|
||||
|
||||
def test_invalid_jinja_template_in_body_text_raises(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Invalid Body Template",
|
||||
dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
|
||||
|
||||
def test_invalid_jinja_template_in_closing_text_raises(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Invalid Closing Template",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"}
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
|
||||
|
||||
def test_valid_jinja_template_passes(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Valid Template",
|
||||
dunning_letter_text=[
|
||||
{
|
||||
"language": "en",
|
||||
"body_text": "Outstanding amount is {{ outstanding_amount }}",
|
||||
"closing_text": "Regards, {{ company }}",
|
||||
}
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_set_default_dunning_type_unsets_previous_default(self):
|
||||
first = make_dunning_type("_Test Dunning Default One", is_default=1)
|
||||
first.insert()
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1)
|
||||
|
||||
second = make_dunning_type("_Test Dunning Default Two", is_default=1)
|
||||
second.insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0)
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1)
|
||||
|
||||
def test_set_default_dunning_type_scoped_per_company(self):
|
||||
company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1)
|
||||
company_1.insert()
|
||||
|
||||
company_2 = make_dunning_type(
|
||||
"_Test Dunning Default Co2",
|
||||
company="_Test Company 1",
|
||||
is_default=1,
|
||||
)
|
||||
company_2.insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1)
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"section_break_mjlv",
|
||||
"due_date",
|
||||
"column_break_qghl",
|
||||
"amount"
|
||||
"amount",
|
||||
"currency"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -55,8 +56,18 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount",
|
||||
"options": "currency",
|
||||
"precision": "2"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lnjp",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -74,7 +85,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-19 02:21:36.455830",
|
||||
"modified": "2026-07-11 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Reference",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
@@ -783,6 +784,7 @@ def set_payment_references(payment_schedules):
|
||||
"description": row.get("description"),
|
||||
"due_date": row.get("due_date"),
|
||||
"amount": row.get("payment_amount"),
|
||||
"currency": row.get("currency"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -99,9 +99,9 @@ class ProcessStatementOfAccounts(Document):
|
||||
if not self.pdf_name:
|
||||
self.pdf_name = "{{ customer.customer_name }}"
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
validate_template(self.subject, restrict_globals=True)
|
||||
validate_template(self.body, restrict_globals=True)
|
||||
validate_template(self.pdf_name, restrict_globals=True)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -527,15 +527,15 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
|
||||
if report:
|
||||
for customer, report_pdf in report.items():
|
||||
context = get_context(customer, doc)
|
||||
filename = frappe.render_template(doc.pdf_name, context)
|
||||
filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True)
|
||||
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
|
||||
|
||||
recipients, cc = get_recipients_and_cc(customer, doc)
|
||||
if not recipients:
|
||||
continue
|
||||
|
||||
subject = frappe.render_template(doc.subject, context)
|
||||
message = frappe.render_template(doc.body, context)
|
||||
subject = frappe.render_template(doc.subject, context, restrict_globals=True)
|
||||
message = frappe.render_template(doc.body, context, restrict_globals=True)
|
||||
|
||||
if doc.sender:
|
||||
sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3156,8 +3156,6 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def postprocess_dunning(source, target):
|
||||
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
|
||||
|
||||
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
|
||||
if dunning_type:
|
||||
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
|
||||
@@ -3166,14 +3164,8 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
|
||||
target.dunning_fee = dunning_type.dunning_fee
|
||||
target.income_account = dunning_type.income_account
|
||||
target.cost_center = dunning_type.cost_center
|
||||
letter_text = get_dunning_letter_text(
|
||||
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
|
||||
)
|
||||
|
||||
if letter_text:
|
||||
target.body_text = letter_text.get("body_text")
|
||||
target.closing_text = letter_text.get("closing_text")
|
||||
target.language = letter_text.get("language")
|
||||
target.language = source.language
|
||||
target.get_dunning_letter_text()
|
||||
|
||||
# update outstanding from doc
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -270,6 +270,7 @@ class PurchaseOrder(BuyingController):
|
||||
"ref_dn_field": "material_request_item",
|
||||
"compare_fields": mri_compare_fields,
|
||||
"is_child_table": True,
|
||||
"allow_duplicate_prev_row_id": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -923,8 +923,10 @@
|
||||
"fieldname": "job_card",
|
||||
"fieldtype": "Link",
|
||||
"label": "Job Card",
|
||||
"no_copy": 1,
|
||||
"options": "Job Card",
|
||||
"search_index": 1
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
@@ -951,7 +953,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-14 12:16:16.192936",
|
||||
"modified": "2026-07-15 10:30:04.600510",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -16,7 +16,10 @@ from erpnext.accounts.party import (
|
||||
validate_party_accounts,
|
||||
validate_party_currency_before_merging,
|
||||
)
|
||||
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
|
||||
from erpnext.controllers.website_list_for_contact import (
|
||||
add_role_for_portal_user,
|
||||
link_portal_users_to_contacts,
|
||||
)
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
|
||||
|
||||
@@ -109,6 +112,7 @@ class Supplier(TransactionBase):
|
||||
def on_update(self):
|
||||
self.create_primary_contact()
|
||||
self.create_primary_address()
|
||||
link_portal_users_to_contacts(self)
|
||||
|
||||
def add_role_for_user(self):
|
||||
for portal_user in self.portal_users:
|
||||
|
||||
@@ -203,3 +203,24 @@ class TestSupplierPortal(ERPNextTestSuite):
|
||||
_, suppliers = get_customers_suppliers("Purchase Order", user)
|
||||
|
||||
self.assertIn(supplier.name, suppliers)
|
||||
|
||||
def test_portal_user_contact_link(self):
|
||||
user_email = frappe.generate_hash() + "@example.com"
|
||||
user = frappe.new_doc("User")
|
||||
user.email = user_email
|
||||
user.first_name = "Test Portal Contact User"
|
||||
user.send_welcome_email = False
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.first_name = "Test Portal Contact User"
|
||||
contact.add_email(user_email, is_primary=1)
|
||||
contact.links = []
|
||||
contact.insert(ignore_permissions=True)
|
||||
|
||||
supplier = create_supplier()
|
||||
supplier.append("portal_users", {"user": user.name})
|
||||
supplier.save()
|
||||
|
||||
contact.reload()
|
||||
self.assertTrue(contact.has_link("Supplier", supplier.name))
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"fieldname": "net_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -613,7 +614,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-17 12:05:52.441645",
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
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
|
||||
@@ -1343,66 +1347,63 @@ class StockController(AccountsController):
|
||||
if not batches:
|
||||
return
|
||||
|
||||
field_mapper = {
|
||||
"Sales Invoice": [["Sales Order", "sales_order"]],
|
||||
"Delivery Note": [["Sales Order", "against_sales_order"]],
|
||||
"Stock Entry": [
|
||||
["Work Order", "work_order"],
|
||||
["Subcontracting Inward Order", "subcontracting_inward_order"],
|
||||
],
|
||||
reference_fields = {
|
||||
"Sales Invoice": ["sales_order"],
|
||||
"Delivery Note": ["against_sales_order"],
|
||||
"Stock Entry": ["work_order", "subcontracting_inward_order"],
|
||||
}.get(self.doctype)
|
||||
|
||||
qty_field = {
|
||||
"Sales Invoice": "qty",
|
||||
"Delivery Note": "qty",
|
||||
"Stock Entry": "fg_completed_qty",
|
||||
}.get(self.doctype)
|
||||
|
||||
reserved_batches_data = self.get_reserved_batches(batches)
|
||||
items = self.items
|
||||
if self.doctype == "Stock Entry":
|
||||
items = [self]
|
||||
|
||||
for item in items:
|
||||
for field in field_mapper:
|
||||
if not item.get(field[1]):
|
||||
continue
|
||||
own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)}
|
||||
|
||||
value = item.get(field[1])
|
||||
for row in reserved_batches_data:
|
||||
if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get(
|
||||
"item_code"
|
||||
):
|
||||
continue
|
||||
outstanding_qty = defaultdict(float)
|
||||
reservations = defaultdict(list)
|
||||
for row in self.get_reserved_batches(batches):
|
||||
if row.voucher_no in own_vouchers:
|
||||
continue
|
||||
|
||||
if row.voucher_no == value:
|
||||
continue
|
||||
key = (row.batch_no, row.warehouse)
|
||||
outstanding = flt(row.qty) - flt(row.delivered_qty)
|
||||
outstanding_qty[key] += outstanding
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
row.batch_no,
|
||||
row.warehouse,
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, 6) <= 0:
|
||||
continue
|
||||
|
||||
if item.get(qty_field) < batch_qty:
|
||||
continue
|
||||
batch_qty = get_batch_qty(
|
||||
batch_no,
|
||||
warehouse,
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}."
|
||||
).format(
|
||||
frappe.bold(row.batch_no),
|
||||
frappe.bold(row.voucher_type),
|
||||
frappe.bold(row.voucher_no),
|
||||
frappe.bold(self.doctype),
|
||||
frappe.bold(self.name),
|
||||
frappe.bold(field[0]),
|
||||
frappe.bold(value),
|
||||
),
|
||||
title=_("Reserved Batch Conflict"),
|
||||
)
|
||||
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
f"{frappe.bold(voucher_type)} {frappe.bold(voucher_no)}"
|
||||
for voucher_type, voucher_no in dict.fromkeys(
|
||||
(row.voucher_type, row.voucher_no) for row in reservations[(batch_no, warehouse)]
|
||||
)
|
||||
)
|
||||
frappe.throw(
|
||||
_(
|
||||
"The batch {0} is reserved for {1} in the warehouse {2} and the remaining quantity is not enough to cover the reservations. So, cannot proceed with the {3} {4}."
|
||||
).format(
|
||||
frappe.bold(batch_no),
|
||||
vouchers,
|
||||
frappe.bold(warehouse),
|
||||
frappe.bold(self.doctype),
|
||||
frappe.bold(self.name),
|
||||
),
|
||||
title=_("Reserved Batch Conflict"),
|
||||
)
|
||||
|
||||
def get_reserved_batches(self, batches):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
@@ -1414,9 +1415,10 @@ class StockController(AccountsController):
|
||||
.on(doctype.name == child_doc.parent)
|
||||
.select(
|
||||
child_doc.batch_no,
|
||||
child_doc.qty,
|
||||
child_doc.delivered_qty,
|
||||
doctype.voucher_type,
|
||||
doctype.voucher_no,
|
||||
doctype.item_code,
|
||||
doctype.warehouse,
|
||||
)
|
||||
.where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))
|
||||
@@ -2053,9 +2055,10 @@ class StockController(AccountsController):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_accounting_ledger_preview(company, doctype, docname):
|
||||
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
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)
|
||||
@@ -2066,9 +2069,10 @@ def show_accounting_ledger_preview(company, doctype, docname):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_stock_ledger_preview(company, doctype, docname):
|
||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||
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 []
|
||||
|
||||
@@ -7,6 +7,8 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.modules.utils import get_module_app
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Lower
|
||||
from frappe.utils import cint, flt, has_common
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
@@ -306,3 +308,63 @@ def add_role_for_portal_user(portal_user, role):
|
||||
|
||||
user_doc.add_roles(role)
|
||||
frappe.msgprint(_("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True)
|
||||
|
||||
|
||||
def link_portal_users_to_contacts(doc):
|
||||
"""When portal users are added to Supplier/Customer, link them to the Contact profile."""
|
||||
# a User's name is its (lowercased) email, so portal_users are already the emails
|
||||
portal_users = {p.user for p in doc.get("portal_users") or [] if p.user}
|
||||
if not portal_users:
|
||||
return
|
||||
|
||||
before = doc.get_doc_before_save()
|
||||
if before:
|
||||
previous_users = {p.user for p in before.get("portal_users") or [] if p.user}
|
||||
if portal_users == previous_users:
|
||||
return
|
||||
|
||||
portal_users = list(portal_users)
|
||||
|
||||
contact = frappe.qb.DocType("Contact")
|
||||
contact_email = frappe.qb.DocType("Contact Email")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(contact)
|
||||
.left_join(contact_email)
|
||||
.on(contact_email.parent == contact.name)
|
||||
.select(contact.name)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
conditions = [
|
||||
contact.user.isin(portal_users),
|
||||
Lower(contact.email_id).isin(portal_users),
|
||||
Lower(contact_email.email_id).isin(portal_users),
|
||||
]
|
||||
|
||||
query = query.where(Criterion.any(conditions))
|
||||
contacts = query.run(pluck=True)
|
||||
|
||||
if not contacts:
|
||||
return
|
||||
|
||||
dynamic_link = frappe.qb.DocType("Dynamic Link")
|
||||
existing_links = (
|
||||
frappe.qb.from_(dynamic_link)
|
||||
.select(dynamic_link.parent)
|
||||
.where(
|
||||
(dynamic_link.parenttype == "Contact")
|
||||
& (dynamic_link.parent.isin(contacts))
|
||||
& (dynamic_link.link_doctype == doc.doctype)
|
||||
& (dynamic_link.link_name == doc.name)
|
||||
)
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
contacts_to_link = [name for name in contacts if name not in existing_links]
|
||||
|
||||
for name in contacts_to_link:
|
||||
contact_doc = frappe.get_doc("Contact", name)
|
||||
if not contact_doc.has_link(doc.doctype, doc.name):
|
||||
contact_doc.append("links", {"link_doctype": doc.doctype, "link_name": doc.name})
|
||||
contact_doc.save(ignore_permissions=True)
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"scheduled_time",
|
||||
"column_break_xaox",
|
||||
"status",
|
||||
"created_through_portal",
|
||||
"email_verified",
|
||||
"verification_token",
|
||||
"customer_details_section",
|
||||
"customer_name",
|
||||
"customer_phone_number",
|
||||
@@ -54,7 +58,8 @@
|
||||
"fieldtype": "Datetime",
|
||||
"in_list_view": 1,
|
||||
"label": "Scheduled Time",
|
||||
"reqd": 1
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
@@ -77,8 +82,8 @@
|
||||
"fieldname": "customer_email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Email",
|
||||
"reqd": 1,
|
||||
"options": "Email"
|
||||
"options": "Email",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "linked_docs_section",
|
||||
@@ -100,13 +105,43 @@
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Party",
|
||||
"options": "appointment_with"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "created_through_portal",
|
||||
"fieldtype": "Check",
|
||||
"label": "Created through Portal",
|
||||
"read_only": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xaox",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.created_through_portal === 1;",
|
||||
"fieldname": "email_verified",
|
||||
"fieldtype": "Check",
|
||||
"label": "Email Verified",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "verification_token",
|
||||
"fieldtype": "Data",
|
||||
"label": "Verification Token",
|
||||
"hidden": 1,
|
||||
"read_only": 1,
|
||||
"no_copy": 1,
|
||||
"search_index": 1
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2026-06-06 13:05:59.300573",
|
||||
"modified": "2026-07-20 02:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Appointment",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -158,8 +193,9 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
|
||||
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.form.assign_to import add as add_assignment
|
||||
from frappe.model.document import Document
|
||||
from frappe.share import add_docshare
|
||||
from frappe.utils import get_url, getdate, now
|
||||
from frappe.utils.verified_command import get_signed_params
|
||||
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
|
||||
from frappe.utils.data import sha256_hash
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
||||
|
||||
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
class Appointment(Document):
|
||||
@@ -24,104 +30,227 @@ class Appointment(Document):
|
||||
|
||||
appointment_with: DF.Link | None
|
||||
calendar_event: DF.Link | None
|
||||
created_through_portal: DF.Check
|
||||
customer_details: DF.LongText | None
|
||||
customer_email: DF.Data
|
||||
customer_name: DF.Data
|
||||
customer_phone_number: DF.Data | None
|
||||
customer_skype: DF.Data | None
|
||||
email_verified: DF.Check
|
||||
party: DF.DynamicLink | None
|
||||
scheduled_time: DF.Datetime
|
||||
status: DF.Literal["Open", "Unverified", "Closed"]
|
||||
verification_token: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
def find_lead_by_email(self):
|
||||
lead_list = frappe.get_list(
|
||||
"Lead", filters={"email_id": self.customer_email}, ignore_permissions=True
|
||||
)
|
||||
if lead_list:
|
||||
return lead_list[0].name
|
||||
return None
|
||||
def validate(self):
|
||||
self.validate_status_update()
|
||||
if not self.has_value_changed("scheduled_time"):
|
||||
return
|
||||
|
||||
def find_customer_by_email(self):
|
||||
customer_list = frappe.get_list(
|
||||
"Customer", filters={"email_id": self.customer_email}, ignore_permissions=True
|
||||
self.validate_backdated_booking()
|
||||
|
||||
if is_appointment_scheduling_enabled():
|
||||
self.validate_advanced_booking()
|
||||
self.validate_holiday()
|
||||
self.validate_slot_timing()
|
||||
|
||||
self.validate_available_time_slot()
|
||||
|
||||
def validate_status_update(self):
|
||||
if not self.has_value_changed("status"):
|
||||
return
|
||||
|
||||
if not self.created_through_portal:
|
||||
if self.status == "Unverified":
|
||||
frappe.throw(_("Appointments created manually cannot have 'Unverified' status."))
|
||||
return
|
||||
|
||||
if self.status == "Unverified" and self.email_verified:
|
||||
frappe.throw(_("A verified appointment cannot be moved back to 'Unverified' status."))
|
||||
|
||||
if self.status == "Open" and not self.email_verified:
|
||||
frappe.throw(
|
||||
_("An appointment booked through the portal can only be opened via email verification.")
|
||||
)
|
||||
|
||||
def validate_backdated_booking(self):
|
||||
if get_datetime(self.scheduled_time) < now_datetime():
|
||||
frappe.throw(_("Appointment cannot be scheduled for a past time."))
|
||||
|
||||
def validate_advanced_booking(self):
|
||||
advance_booking_days = cint(get_booking_settings().advance_booking_days)
|
||||
|
||||
if advance_booking_days and date_diff(self.scheduled_time, now_datetime()) > advance_booking_days:
|
||||
frappe.throw(
|
||||
_("Appointment can only be scheduled up to {0} day(s) in advance.").format(
|
||||
advance_booking_days
|
||||
)
|
||||
)
|
||||
|
||||
def validate_holiday(self):
|
||||
holiday_list = get_booking_settings().holiday_list
|
||||
|
||||
if not holiday_list:
|
||||
frappe.throw(_("Please add a valid Holiday List on Appointment Booking Settings."))
|
||||
|
||||
if is_holiday(holiday_list, getdate(self.scheduled_time)):
|
||||
frappe.throw(_("Appointment cannot be scheduled on a holiday."))
|
||||
|
||||
def validate_slot_timing(self):
|
||||
settings = get_booking_settings()
|
||||
if not settings.availability_of_slots:
|
||||
frappe.throw(_("No availability of slots are found. Please add on Appointment Booking Settings."))
|
||||
|
||||
scheduled_time = get_datetime(self.scheduled_time)
|
||||
day_of_week = WEEKDAYS[scheduled_time.weekday()]
|
||||
slot_start = timedelta(
|
||||
hours=scheduled_time.hour, minutes=scheduled_time.minute, seconds=scheduled_time.second
|
||||
)
|
||||
if customer_list:
|
||||
return customer_list[0].name
|
||||
return None
|
||||
slot_end = slot_start + timedelta(minutes=cint(settings.appointment_duration))
|
||||
|
||||
for slot in settings.availability_of_slots:
|
||||
if slot.day_of_week == day_of_week and slot.from_time <= slot_start and slot_end <= slot.to_time:
|
||||
return
|
||||
|
||||
frappe.throw(_("Appointment must be scheduled within the available slot timings."))
|
||||
|
||||
def validate_available_time_slot(self):
|
||||
settings = get_booking_settings()
|
||||
if not cint(settings.number_of_agents):
|
||||
return
|
||||
|
||||
# the locking read serializes concurrent bookings for the same window,
|
||||
# so two simultaneous requests cannot both pass the capacity check
|
||||
booked = count_overlapping_appointments(
|
||||
self.scheduled_time,
|
||||
cint(settings.appointment_duration),
|
||||
exclude_appointment=self.name,
|
||||
for_update=True,
|
||||
)
|
||||
|
||||
if booked >= cint(settings.number_of_agents):
|
||||
frappe.throw(_("Time slot is not available"))
|
||||
|
||||
def before_insert(self):
|
||||
number_of_appointments_in_same_slot = frappe.db.count(
|
||||
"Appointment", filters={"scheduled_time": self.scheduled_time}
|
||||
)
|
||||
number_of_agents = frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents")
|
||||
if number_of_agents != 0:
|
||||
if number_of_appointments_in_same_slot >= number_of_agents:
|
||||
frappe.throw(_("Time slot is not available"))
|
||||
# Link lead
|
||||
if not self.party:
|
||||
lead = self.find_lead_by_email()
|
||||
customer = self.find_customer_by_email()
|
||||
if customer:
|
||||
self.appointment_with = "Customer"
|
||||
self.party = customer
|
||||
else:
|
||||
self.appointment_with = "Lead"
|
||||
self.party = lead
|
||||
# Set status to "Unverified" for new Appointments.
|
||||
if self.created_through_portal:
|
||||
self.status = "Unverified"
|
||||
return
|
||||
|
||||
self.link_customer_lead()
|
||||
|
||||
def after_insert(self):
|
||||
if self.party:
|
||||
# Create Calendar event
|
||||
if not self.created_through_portal and self.party:
|
||||
self.auto_assign()
|
||||
self.create_calendar_event()
|
||||
else:
|
||||
# Set status to unverified
|
||||
self.db_set("status", "Unverified")
|
||||
# Send email to confirm
|
||||
self.send_confirmation_email()
|
||||
return
|
||||
|
||||
# Send email to confirm
|
||||
self.send_confirmation_email()
|
||||
|
||||
def on_update(self):
|
||||
# capture transitions before nested saves during materialization
|
||||
# refresh the before-save snapshot
|
||||
status_changed = self.has_value_changed("status")
|
||||
email_just_verified = bool(
|
||||
self.created_through_portal and self.email_verified
|
||||
) and self.has_value_changed("email_verified")
|
||||
|
||||
self.link_auto_assign_and_create_calendar_event()
|
||||
|
||||
if email_just_verified:
|
||||
self.send_appointment_confirmed_email()
|
||||
|
||||
if status_changed:
|
||||
self.update_event_and_assignments_status()
|
||||
|
||||
def on_trash(self):
|
||||
# the Event only references the party, not the appointment,
|
||||
# so it must be cleaned up explicitly
|
||||
if not self.calendar_event:
|
||||
return
|
||||
|
||||
event = self.calendar_event
|
||||
self.db_set("calendar_event", None, update_modified=False)
|
||||
frappe.delete_doc("Event", event, ignore_permissions=True)
|
||||
|
||||
def send_confirmation_email(self):
|
||||
verify_url = self._get_verify_url()
|
||||
template = "confirm_appointment"
|
||||
args = {
|
||||
"link": verify_url,
|
||||
"site_url": frappe.utils.get_url(),
|
||||
"full_name": self.customer_name,
|
||||
}
|
||||
self.send_email_to_customer(
|
||||
template="confirm_appointment",
|
||||
subject=_("Appointment Confirmation"),
|
||||
args={"link": self._get_verify_url(), "expiry_minutes": get_verification_link_expiry()},
|
||||
)
|
||||
frappe.msgprint(_("Please check your email to confirm the appointment."))
|
||||
|
||||
def send_appointment_confirmed_email(self):
|
||||
self.send_email_to_customer(
|
||||
template="appointment_confirmed",
|
||||
subject=_("Appointment Confirmed"),
|
||||
args={"scheduled_time": frappe.utils.format_datetime(self.scheduled_time)},
|
||||
reference_doctype="Appointment",
|
||||
reference_name=self.name,
|
||||
)
|
||||
|
||||
def send_email_to_customer(self, template, subject, args, **kwargs):
|
||||
frappe.sendmail(
|
||||
recipients=[self.customer_email],
|
||||
template=template,
|
||||
args=args,
|
||||
subject=_("Appointment Confirmation"),
|
||||
args={"full_name": self.customer_name, "site_url": frappe.utils.get_url(), **args},
|
||||
subject=subject,
|
||||
**kwargs,
|
||||
)
|
||||
if frappe.session.user == "Guest":
|
||||
frappe.msgprint(_("Please check your email to confirm the appointment"))
|
||||
else:
|
||||
frappe.msgprint(
|
||||
_("Appointment was created. But no lead was found. Please check the email to confirm")
|
||||
)
|
||||
|
||||
def on_change(self):
|
||||
# Sync Calendar
|
||||
if not self.calendar_event:
|
||||
def link_auto_assign_and_create_calendar_event(self):
|
||||
if self.is_new() or (self.created_through_portal and not self.email_verified):
|
||||
return
|
||||
|
||||
if not self.calendar_event:
|
||||
# first materialization: link the party, assign an agent, create the event
|
||||
self.link_customer_lead()
|
||||
self.auto_assign()
|
||||
self.create_calendar_event()
|
||||
|
||||
self.sync_calendar_event()
|
||||
|
||||
def sync_calendar_event(self):
|
||||
if not self.calendar_event or not self.has_value_changed("scheduled_time"):
|
||||
return
|
||||
|
||||
cal_event = frappe.get_doc("Event", self.calendar_event)
|
||||
cal_event.starts_on = self.scheduled_time
|
||||
cal_event.save(ignore_permissions=True)
|
||||
|
||||
def set_verified(self, email):
|
||||
if email != self.customer_email:
|
||||
frappe.throw(_("Email verification failed."))
|
||||
# Create new lead
|
||||
def update_event_and_assignments_status(self):
|
||||
"""Close or reopen the calendar event and assignments along with the appointment."""
|
||||
if self.status == "Unverified":
|
||||
return
|
||||
|
||||
is_closed = self.status == "Closed"
|
||||
new_status = "Closed" if is_closed else "Open"
|
||||
|
||||
if self.calendar_event:
|
||||
frappe.db.set_value("Event", self.calendar_event, "status", new_status)
|
||||
|
||||
# only move ToDos between Open and Closed - never touch Cancelled ones
|
||||
todo_filters = {
|
||||
"reference_type": "Appointment",
|
||||
"reference_name": self.name,
|
||||
"status": "Open" if is_closed else "Closed",
|
||||
}
|
||||
frappe.db.set_value("ToDo", todo_filters, "status", new_status)
|
||||
|
||||
def link_customer_lead(self):
|
||||
if not self.party:
|
||||
customer = self.find_party_by_email("Customer")
|
||||
self.appointment_with = "Customer" if customer else "Lead"
|
||||
self.party = customer or self.find_party_by_email("Lead")
|
||||
|
||||
self.create_lead_and_link()
|
||||
# Remove unverified status
|
||||
self.status = "Open"
|
||||
# Create calender event
|
||||
self.auto_assign()
|
||||
self.create_calendar_event()
|
||||
self.save(ignore_permissions=True)
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
|
||||
def find_party_by_email(self, doctype):
|
||||
party = frappe.get_all(doctype, filters={"email_id": self.customer_email}, limit=1, pluck="name")
|
||||
return party[0] if party else None
|
||||
|
||||
def create_lead_and_link(self):
|
||||
# Return if already linked
|
||||
@@ -140,86 +269,39 @@ class Appointment(Document):
|
||||
if self.customer_details:
|
||||
lead.append(
|
||||
"notes",
|
||||
{
|
||||
"note": self.customer_details,
|
||||
"added_by": frappe.session.user,
|
||||
"added_on": now(),
|
||||
},
|
||||
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
|
||||
)
|
||||
|
||||
lead.insert(ignore_permissions=True)
|
||||
|
||||
# Link lead
|
||||
self.party = lead.name
|
||||
self.party = lead.insert(ignore_permissions=True).name
|
||||
|
||||
def auto_assign(self):
|
||||
existing_assignee = self.get_assignee_from_latest_opportunity()
|
||||
if existing_assignee:
|
||||
# If the latest opportunity is assigned to someone
|
||||
# Assign the appointment to the same
|
||||
self.assign_agent(existing_assignee)
|
||||
return
|
||||
if self._assign:
|
||||
return
|
||||
available_agents = _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time))
|
||||
for agent in available_agents:
|
||||
if _check_agent_availability(agent, self.scheduled_time):
|
||||
self.assign_agent(agent[0])
|
||||
break
|
||||
|
||||
if existing_assignee := self.get_assignee_from_latest_opportunity():
|
||||
# assign to whoever handles the party's latest opportunity
|
||||
self.assign_agent(existing_assignee)
|
||||
return
|
||||
|
||||
busy_agents = get_busy_agents(self.scheduled_time)
|
||||
for agent in _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time)):
|
||||
if agent not in busy_agents:
|
||||
self.assign_agent(agent)
|
||||
break
|
||||
|
||||
def get_assignee_from_latest_opportunity(self):
|
||||
if not self.party:
|
||||
if not self.party or not frappe.db.exists("Lead", self.party):
|
||||
return None
|
||||
if not frappe.db.exists("Lead", self.party):
|
||||
return None
|
||||
opporutnities = frappe.get_list(
|
||||
|
||||
opportunities = frappe.get_all(
|
||||
"Opportunity",
|
||||
filters={
|
||||
"party_name": self.party,
|
||||
},
|
||||
ignore_permissions=True,
|
||||
filters={"party_name": self.party},
|
||||
fields=["_assign"],
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
if not opporutnities:
|
||||
return None
|
||||
latest_opportunity = frappe.get_doc("Opportunity", opporutnities[0].name)
|
||||
assignee = latest_opportunity._assign
|
||||
if not assignee:
|
||||
return None
|
||||
assignee = frappe.parse_json(assignee)[0]
|
||||
return assignee
|
||||
|
||||
def create_calendar_event(self):
|
||||
if self.calendar_event:
|
||||
return
|
||||
appointment_event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event",
|
||||
"subject": " ".join(["Appointment with", self.customer_name]),
|
||||
"starts_on": self.scheduled_time,
|
||||
"status": "Open",
|
||||
"type": "Public",
|
||||
"send_reminder": frappe.db.get_single_value(
|
||||
"Appointment Booking Settings", "email_reminders"
|
||||
),
|
||||
"event_participants": [
|
||||
dict(reference_doctype=self.appointment_with, reference_docname=self.party)
|
||||
],
|
||||
}
|
||||
)
|
||||
employee = _get_employee_from_user(self._assign)
|
||||
if employee:
|
||||
appointment_event.append(
|
||||
"event_participants", dict(reference_doctype="Employee", reference_docname=employee.name)
|
||||
)
|
||||
appointment_event.insert(ignore_permissions=True)
|
||||
self.calendar_event = appointment_event.name
|
||||
self.save(ignore_permissions=True)
|
||||
|
||||
def _get_verify_url(self):
|
||||
verify_route = "/book_appointment/verify"
|
||||
params = {"email": self.customer_email, "appointment": self.name}
|
||||
return get_url(verify_route + "?" + get_signed_params(params))
|
||||
assignees = opportunities and frappe.parse_json(opportunities[0]._assign or "[]")
|
||||
return assignees[0] if assignees else None
|
||||
|
||||
def assign_agent(self, agent):
|
||||
if not frappe.has_permission(doc=self, user=agent):
|
||||
@@ -227,45 +309,157 @@ class Appointment(Document):
|
||||
|
||||
add_assignment({"doctype": self.doctype, "name": self.name, "assign_to": [agent]})
|
||||
|
||||
def create_calendar_event(self):
|
||||
if self.calendar_event:
|
||||
return
|
||||
|
||||
event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event",
|
||||
"subject": f"Appointment with {self.customer_name}",
|
||||
"starts_on": self.scheduled_time,
|
||||
"status": "Open",
|
||||
"type": "Public",
|
||||
"send_reminder": cint(get_booking_settings().email_reminders),
|
||||
"event_participants": self.get_event_participants(),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
self.calendar_event = event.name
|
||||
self.save(ignore_permissions=True)
|
||||
|
||||
def get_event_participants(self):
|
||||
participants = [dict(reference_doctype=self.appointment_with, reference_docname=self.party)]
|
||||
|
||||
if employee := _get_employee_from_user(self._assign):
|
||||
participants.append(dict(reference_doctype="Employee", reference_docname=employee.name))
|
||||
|
||||
return participants
|
||||
|
||||
def _get_verify_url(self):
|
||||
key = self.generate_verification_key()
|
||||
return get_url("/book_appointment/verify?" + urlencode({"key": key}))
|
||||
|
||||
def generate_verification_key(self):
|
||||
# store only the hash; the raw key lives solely in the emailed link
|
||||
key = frappe.generate_hash()
|
||||
self.db_set("verification_token", sha256_hash(key), update_modified=False)
|
||||
return key
|
||||
|
||||
|
||||
def get_booking_settings():
|
||||
return frappe.get_cached_doc("Appointment Booking Settings")
|
||||
|
||||
|
||||
def is_appointment_scheduling_enabled():
|
||||
return bool(cint(get_booking_settings().enable_scheduling))
|
||||
|
||||
|
||||
def get_verification_link_expiry():
|
||||
"""Verification link expiry window in minutes."""
|
||||
return cint(get_booking_settings().verification_link_expiry_duration)
|
||||
|
||||
|
||||
def count_overlapping_appointments(
|
||||
scheduled_time, appointment_duration, exclude_appointment=None, for_update=False
|
||||
):
|
||||
"""Count non-Closed appointments whose duration window overlaps `scheduled_time`.
|
||||
With `for_update`, the range stays locked until commit, serializing concurrent bookings."""
|
||||
# select the rows (not COUNT) so `for_update` stays valid: PostgreSQL
|
||||
# rejects `FOR UPDATE` combined with an aggregate function
|
||||
appointment = frappe.qb.DocType("Appointment")
|
||||
query = (
|
||||
frappe.qb.from_(appointment)
|
||||
.select(appointment.name)
|
||||
.where(appointment.scheduled_time > add_to_date(scheduled_time, minutes=-appointment_duration))
|
||||
.where(appointment.scheduled_time < add_to_date(scheduled_time, minutes=appointment_duration))
|
||||
.where(appointment.status != "Closed")
|
||||
)
|
||||
|
||||
if exclude_appointment:
|
||||
query = query.where(appointment.name != exclude_appointment)
|
||||
|
||||
if for_update:
|
||||
query = query.for_update()
|
||||
|
||||
return len(query.run())
|
||||
|
||||
|
||||
def handle_expired_unverified_appointments():
|
||||
"""Close or delete Unverified appointments whose verification link has expired."""
|
||||
expiry = get_verification_link_expiry()
|
||||
if not expiry:
|
||||
return
|
||||
|
||||
cutoff = add_to_date(now_datetime(), minutes=-expiry)
|
||||
filters = {"status": "Unverified", "creation": ("<", cutoff)}
|
||||
action = get_booking_settings().action_for_expired_unverified_appointments or "Mark as Closed"
|
||||
|
||||
if action == "Mark as Closed":
|
||||
frappe.db.set_value("Appointment", filters, "status", "Closed")
|
||||
elif action == "Delete Permanently":
|
||||
for name in frappe.get_all("Appointment", filters=filters, pluck="name"):
|
||||
frappe.delete_doc("Appointment", name, ignore_permissions=True)
|
||||
|
||||
|
||||
def _get_agents_sorted_by_asc_workload(date):
|
||||
appointments = frappe.get_all("Appointment", fields="*")
|
||||
agent_list = _get_agent_list_as_strings()
|
||||
if not appointments:
|
||||
return agent_list
|
||||
appointment_counter = Counter(agent_list)
|
||||
for appointment in appointments:
|
||||
assign_data = appointment._assign
|
||||
if isinstance(assign_data, str):
|
||||
assign_data = assign_data.strip()
|
||||
if not assign_data:
|
||||
continue
|
||||
assigned_to = frappe.parse_json(assign_data)
|
||||
if assigned_to and (assigned_to[0] in agent_list) and getdate(appointment.scheduled_time) == date:
|
||||
appointment_counter[assigned_to[0]] += 1
|
||||
sorted_agent_list = appointment_counter.most_common()
|
||||
sorted_agent_list.reverse()
|
||||
return sorted_agent_list
|
||||
# count only the given day's assignments; scheduled_time is indexed so the
|
||||
# date range is resolved in SQL instead of scanning every appointment ever
|
||||
workload = Counter(agent.user for agent in get_booking_settings().agent_list)
|
||||
assigns = frappe.get_all(
|
||||
"Appointment",
|
||||
filters=[
|
||||
["_assign", "is", "set"],
|
||||
["scheduled_time", ">=", getdate(date)],
|
||||
["scheduled_time", "<", add_to_date(getdate(date), days=1)],
|
||||
],
|
||||
pluck="_assign",
|
||||
)
|
||||
|
||||
for assign in assigns:
|
||||
assignees = frappe.parse_json((assign or "").strip() or "[]")
|
||||
if assignees and assignees[0] in workload:
|
||||
workload[assignees[0]] += 1
|
||||
|
||||
return [agent for agent, _workload in reversed(workload.most_common())]
|
||||
|
||||
|
||||
def _get_agent_list_as_strings():
|
||||
agent_list_as_strings = []
|
||||
agent_list = frappe.get_doc("Appointment Booking Settings").agent_list
|
||||
for agent in agent_list:
|
||||
agent_list_as_strings.append(agent.user)
|
||||
return agent_list_as_strings
|
||||
def get_busy_agents(scheduled_time):
|
||||
"""Agents already assigned to a non-Closed appointment overlapping `scheduled_time`."""
|
||||
duration = _get_appointment_duration()
|
||||
assigns = frappe.get_all(
|
||||
"Appointment",
|
||||
filters=[
|
||||
["scheduled_time", ">", add_to_date(scheduled_time, minutes=-duration)],
|
||||
["scheduled_time", "<", add_to_date(scheduled_time, minutes=duration)],
|
||||
["status", "!=", "Closed"],
|
||||
],
|
||||
pluck="_assign",
|
||||
)
|
||||
return {assignee for assign in assigns for assignee in frappe.parse_json(assign or "[]")}
|
||||
|
||||
|
||||
def _check_agent_availability(agent_email, scheduled_time):
|
||||
appointemnts_at_scheduled_time = frappe.get_all("Appointment", filters={"scheduled_time": scheduled_time})
|
||||
for appointment in appointemnts_at_scheduled_time:
|
||||
if appointment._assign == agent_email:
|
||||
return False
|
||||
return True
|
||||
return agent_email not in get_busy_agents(scheduled_time)
|
||||
|
||||
|
||||
def get_booked_slot_times(from_time, to_time):
|
||||
"""scheduled_times of non-Closed appointments within (from_time, to_time), for slot availability."""
|
||||
return frappe.get_all(
|
||||
"Appointment",
|
||||
filters=[
|
||||
["scheduled_time", ">", from_time],
|
||||
["scheduled_time", "<", to_time],
|
||||
["status", "!=", "Closed"],
|
||||
],
|
||||
pluck="scheduled_time",
|
||||
)
|
||||
|
||||
|
||||
def _get_appointment_duration():
|
||||
return cint(get_booking_settings().appointment_duration)
|
||||
|
||||
|
||||
def _get_employee_from_user(user):
|
||||
employee_docname = frappe.db.get_value("Employee", {"user_id": user})
|
||||
if employee_docname:
|
||||
return frappe.get_doc("Employee", employee_docname)
|
||||
return None
|
||||
return frappe.get_doc("Employee", employee_docname) if employee_docname else None
|
||||
|
||||
@@ -1,37 +1,167 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import datetime
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_to_date, getdate, now_datetime, set_request
|
||||
from frappe.utils.data import sha256_hash
|
||||
|
||||
from erpnext.crm.doctype.appointment.appointment import (
|
||||
Appointment,
|
||||
_check_agent_availability,
|
||||
handle_expired_unverified_appointments,
|
||||
)
|
||||
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
from erpnext.www.book_appointment.index import create_appointment, get_appointment_slots
|
||||
from erpnext.www.book_appointment.verify import index as verify_index
|
||||
|
||||
LEAD_EMAIL = "test_appointment_lead@example.com"
|
||||
VERIFICATION_EXPIRY_MINUTES = 30
|
||||
ALL_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
def create_test_appointment():
|
||||
test_appointment = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Appointment",
|
||||
"status": "Open",
|
||||
"customer_name": "Test Lead",
|
||||
"customer_phone_number": "666",
|
||||
"customer_skype": "test",
|
||||
"customer_email": LEAD_EMAIL,
|
||||
"scheduled_time": datetime.datetime.now(),
|
||||
"customer_details": "Hello, Friend!",
|
||||
}
|
||||
)
|
||||
def create_test_appointment(**kwargs):
|
||||
args = {
|
||||
"doctype": "Appointment",
|
||||
"status": "Open",
|
||||
"customer_name": "Test Lead",
|
||||
"customer_phone_number": "666",
|
||||
"customer_skype": "test",
|
||||
"customer_email": LEAD_EMAIL,
|
||||
"scheduled_time": add_to_date(now_datetime(), hours=2),
|
||||
"customer_details": "Hello, Friend!",
|
||||
}
|
||||
args.update(kwargs)
|
||||
test_appointment = frappe.get_doc(args)
|
||||
test_appointment.insert()
|
||||
return test_appointment
|
||||
|
||||
|
||||
def create_lead(email, name="Existing Lead"):
|
||||
frappe.db.delete("Lead", {"email_id": email})
|
||||
return frappe.get_doc({"doctype": "Lead", "lead_name": name, "email_id": email}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
|
||||
def set_booking_setting(field, value):
|
||||
frappe.db.set_single_value("Appointment Booking Settings", field, value)
|
||||
|
||||
|
||||
def slot_on(days_from_now, hour, minute=0):
|
||||
day = datetime.date.today() + datetime.timedelta(days=days_from_now)
|
||||
return datetime.datetime.combine(day, datetime.time(hour, minute))
|
||||
|
||||
|
||||
def backdate_creation(appointment_name, minutes):
|
||||
frappe.db.set_value(
|
||||
"Appointment",
|
||||
appointment_name,
|
||||
"creation",
|
||||
add_to_date(now_datetime(), minutes=-minutes),
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
|
||||
def get_status(appointment_name):
|
||||
return frappe.db.get_value("Appointment", appointment_name, "status")
|
||||
|
||||
|
||||
def get_assignees(appointment_name):
|
||||
return frappe.parse_json(frappe.db.get_value("Appointment", appointment_name, "_assign") or "[]")
|
||||
|
||||
|
||||
def get_todo_statuses(appointment_name):
|
||||
return frappe.get_all(
|
||||
"ToDo",
|
||||
filters={"reference_type": "Appointment", "reference_name": appointment_name},
|
||||
pluck="status",
|
||||
)
|
||||
|
||||
|
||||
def parse_verify_url(verify_url):
|
||||
parsed = urlparse(verify_url)
|
||||
return parsed, {key: value[0] for key, value in parse_qs(parsed.query).items()}
|
||||
|
||||
|
||||
class TestAppointment(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
set_booking_setting("verification_link_expiry_duration", VERIFICATION_EXPIRY_MINUTES)
|
||||
frappe.db.delete("Lead", {"email_id": LEAD_EMAIL})
|
||||
self.test_appointment = create_test_appointment()
|
||||
self.test_appointment.set_verified(self.test_appointment.customer_email)
|
||||
|
||||
def _configure_booking_settings(self, holiday_dates=None, agents=None):
|
||||
holiday_list = make_holiday_list(
|
||||
"_Test Appointment Holiday List",
|
||||
from_date=getdate(),
|
||||
to_date=add_to_date(getdate(), days=60),
|
||||
holiday_dates=holiday_dates or [],
|
||||
)
|
||||
|
||||
settings = frappe.get_doc("Appointment Booking Settings")
|
||||
settings.enable_scheduling = 1
|
||||
settings.enable_appointment_portal = 1
|
||||
settings.appointment_duration = 30
|
||||
settings.advance_booking_days = 30
|
||||
settings.verification_link_expiry_duration = VERIFICATION_EXPIRY_MINUTES
|
||||
settings.holiday_list = holiday_list.name
|
||||
settings.set("agent_list", [])
|
||||
for agent in agents or ["Administrator"]:
|
||||
settings.append("agent_list", {"user": agent})
|
||||
settings.set("availability_of_slots", [])
|
||||
for day in ALL_WEEKDAYS:
|
||||
settings.append(
|
||||
"availability_of_slots", {"day_of_week": day, "from_time": "09:00:00", "to_time": "17:00:00"}
|
||||
)
|
||||
settings.save()
|
||||
|
||||
def _create_portal_appointment(self, email, days_from_now=7, time="10:00:00"):
|
||||
"""Book as Guest. The verification email is mocked and kept on
|
||||
``self._verification_email_mock`` for assertions."""
|
||||
if not getattr(self, "_booking_settings_configured", False):
|
||||
self._configure_booking_settings()
|
||||
self._booking_settings_configured = True
|
||||
|
||||
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
|
||||
appointment = create_appointment(
|
||||
date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)),
|
||||
time=time,
|
||||
tz="UTC",
|
||||
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
|
||||
)
|
||||
self._verification_email_mock = mock_send
|
||||
return appointment
|
||||
|
||||
def _request_verification(self, appointment, verify_url=None):
|
||||
"""Simulate the GET request made by clicking the emailed verification link.
|
||||
|
||||
The confirmation email sent on successful verification is mocked and kept
|
||||
on ``self._confirmed_email_mock`` for assertions.
|
||||
"""
|
||||
parsed, params = parse_verify_url(verify_url or appointment._get_verify_url())
|
||||
|
||||
old_request = getattr(frappe.local, "request", None)
|
||||
old_form_dict = frappe.local.form_dict
|
||||
old_user = frappe.session.user
|
||||
try:
|
||||
# the real link is clicked by an anonymous visitor; set_user resets
|
||||
# form_dict, so switch the user before populating the request
|
||||
frappe.set_user("Guest")
|
||||
set_request(method="GET", path=f"{parsed.path}?{parsed.query}")
|
||||
frappe.local.form_dict = frappe._dict(params)
|
||||
context = frappe._dict()
|
||||
with patch.object(Appointment, "send_appointment_confirmed_email") as mock_confirmed:
|
||||
verify_index.get_context(context)
|
||||
self._confirmed_email_mock = mock_confirmed
|
||||
return context
|
||||
finally:
|
||||
frappe.set_user(old_user)
|
||||
frappe.local.request = old_request
|
||||
frappe.local.form_dict = old_form_dict
|
||||
frappe.local.flags.commit = False
|
||||
|
||||
def test_calendar_event_created(self):
|
||||
cal_event = frappe.get_doc("Event", self.test_appointment.calendar_event)
|
||||
@@ -39,3 +169,371 @@ class TestAppointment(ERPNextTestSuite):
|
||||
|
||||
def test_lead_linked(self):
|
||||
self.assertTrue(self.test_appointment.party)
|
||||
|
||||
def test_desk_created_appointment_skips_email_verification(self):
|
||||
"""Appointments created from the desk (created_through_portal unset) must be
|
||||
linked and confirmed immediately - no verification email should be sent."""
|
||||
with patch.object(Appointment, "send_confirmation_email") as mock_send:
|
||||
appointment = create_test_appointment(customer_email="another_desk_lead@example.com")
|
||||
|
||||
mock_send.assert_not_called()
|
||||
self.assertEqual(appointment.status, "Open")
|
||||
self.assertTrue(appointment.party)
|
||||
frappe.db.delete("Lead", {"email_id": "another_desk_lead@example.com"})
|
||||
|
||||
def test_portal_booking_stays_unverified_for_existing_lead(self):
|
||||
"""A portal booking whose email matches an existing Lead/Customer must NOT
|
||||
be auto-linked - it must stay Unverified until the email is confirmed."""
|
||||
create_lead("existing_lead@example.com")
|
||||
appointment = self._create_portal_appointment("existing_lead@example.com", days_from_now=5)
|
||||
|
||||
self._verification_email_mock.assert_called_once()
|
||||
self.assertTrue(appointment.created_through_portal)
|
||||
self.assertEqual(appointment.status, "Unverified")
|
||||
self.assertFalse(appointment.email_verified)
|
||||
self.assertFalse(appointment.party)
|
||||
|
||||
def test_verify_url_uses_opaque_token(self):
|
||||
appointment = self._create_portal_appointment("portal_visitor@example.com")
|
||||
parsed, params = parse_verify_url(appointment._get_verify_url())
|
||||
|
||||
# the link carries only an opaque key - no email, name or signed params
|
||||
self.assertEqual(set(params), {"key"})
|
||||
self.assertNotIn("email", parsed.query)
|
||||
# only the hash of that key is stored on the appointment
|
||||
stored = frappe.db.get_value("Appointment", appointment.name, "verification_token")
|
||||
self.assertEqual(stored, sha256_hash(params["key"]))
|
||||
|
||||
def test_email_verification_within_expiry_window(self):
|
||||
# Link used within the validity window - verification succeeds and the
|
||||
# appointment gets linked, assigned and added to the calendar
|
||||
on_time = self._create_portal_appointment("portal_visitor_on_time@example.com")
|
||||
context = self._request_verification(on_time)
|
||||
|
||||
self.assertTrue(context.success)
|
||||
self._confirmed_email_mock.assert_called_once()
|
||||
on_time.reload()
|
||||
self.assertEqual(on_time.status, "Open")
|
||||
self.assertTrue(on_time.email_verified)
|
||||
self.assertTrue(on_time.party)
|
||||
self.assertTrue(on_time.calendar_event)
|
||||
|
||||
# Link used after the validity window - verification fails
|
||||
late = self._create_portal_appointment("portal_visitor_late@example.com", days_from_now=10)
|
||||
after_expiry = add_to_date(now_datetime(), minutes=VERIFICATION_EXPIRY_MINUTES + 1)
|
||||
with patch.object(verify_index, "now_datetime", return_value=after_expiry):
|
||||
context = self._request_verification(late)
|
||||
|
||||
self.assertFalse(context.success)
|
||||
self._confirmed_email_mock.assert_not_called()
|
||||
late.reload()
|
||||
self.assertEqual(late.status, "Unverified")
|
||||
self.assertFalse(late.email_verified)
|
||||
self.assertFalse(late.party)
|
||||
|
||||
def test_verification_link_reused_after_success(self):
|
||||
appointment = self._create_portal_appointment("portal_visitor_twice@example.com")
|
||||
verify_url = appointment._get_verify_url()
|
||||
|
||||
context = self._request_verification(appointment, verify_url=verify_url)
|
||||
self.assertTrue(context.success)
|
||||
self._confirmed_email_mock.assert_called_once()
|
||||
|
||||
# re-clicking the link is idempotent and does not send another email
|
||||
context = self._request_verification(appointment, verify_url=verify_url)
|
||||
self.assertTrue(context.success)
|
||||
self.assertIn("already verified", context.message)
|
||||
self._confirmed_email_mock.assert_not_called()
|
||||
|
||||
def test_verification_link_for_deleted_appointment(self):
|
||||
"""A verification link can outlive its appointment - clicking it must
|
||||
render a friendly message, not crash."""
|
||||
appointment = self._create_portal_appointment("portal_visitor_gone@example.com")
|
||||
verify_url = appointment._get_verify_url()
|
||||
frappe.delete_doc("Appointment", appointment.name, ignore_permissions=True)
|
||||
|
||||
context = self._request_verification(appointment, verify_url=verify_url)
|
||||
|
||||
self.assertFalse(context.success)
|
||||
self.assertIn("book the appointment again", context.message)
|
||||
|
||||
def test_reschedule_syncs_calendar_event(self):
|
||||
new_time = add_to_date(self.test_appointment.scheduled_time, hours=1)
|
||||
self.test_appointment.scheduled_time = new_time
|
||||
self.test_appointment.save()
|
||||
|
||||
starts_on = frappe.db.get_value("Event", self.test_appointment.calendar_event, "starts_on")
|
||||
self.assertEqual(starts_on, new_time)
|
||||
|
||||
def test_portal_endpoint_disabled(self):
|
||||
self._configure_booking_settings()
|
||||
set_booking_setting("enable_appointment_portal", 0)
|
||||
|
||||
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
|
||||
create_appointment(
|
||||
date=str(datetime.date.today() + datetime.timedelta(days=3)),
|
||||
time="10:00:00",
|
||||
tz="UTC",
|
||||
contact={
|
||||
"name": "Blocked",
|
||||
"email": "blocked@example.com",
|
||||
"number": "1",
|
||||
"skype": "",
|
||||
"notes": "",
|
||||
},
|
||||
)
|
||||
|
||||
def test_booked_slot_unavailable_on_portal(self):
|
||||
from frappe.utils.data import get_system_timezone
|
||||
|
||||
self._configure_booking_settings()
|
||||
tz = get_system_timezone()
|
||||
day = datetime.date.today() + datetime.timedelta(days=2)
|
||||
|
||||
def get_availability():
|
||||
with self.set_user("Guest"):
|
||||
slots = get_appointment_slots(str(day), tz)
|
||||
return {slot["time"].strftime("%H:%M"): slot["availability"] for slot in slots}
|
||||
|
||||
booked = create_test_appointment(
|
||||
customer_email="slot_taken@example.com", scheduled_time=slot_on(2, 10)
|
||||
)
|
||||
|
||||
availability = get_availability()
|
||||
self.assertFalse(availability["10:00"])
|
||||
self.assertTrue(availability["13:00"])
|
||||
|
||||
# closing the appointment frees its slot on the portal
|
||||
booked.status = "Closed"
|
||||
booked.save()
|
||||
self.assertTrue(get_availability()["10:00"])
|
||||
|
||||
# an off-grid desk appointment blocks every portal slot it overlaps
|
||||
create_test_appointment(customer_email="off_grid@example.com", scheduled_time=slot_on(2, 13, 15))
|
||||
availability = get_availability()
|
||||
self.assertFalse(availability["13:00"])
|
||||
self.assertFalse(availability["13:30"])
|
||||
self.assertTrue(availability["14:00"])
|
||||
|
||||
def test_expired_unverified_appointments_are_closed(self):
|
||||
stale = self._create_portal_appointment("portal_visitor_stale@example.com", days_from_now=8)
|
||||
fresh = self._create_portal_appointment("portal_visitor_fresh@example.com", days_from_now=9)
|
||||
verify_url = stale._get_verify_url()
|
||||
|
||||
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
|
||||
set_booking_setting("action_for_expired_unverified_appointments", "Mark as Closed")
|
||||
|
||||
handle_expired_unverified_appointments()
|
||||
|
||||
self.assertEqual(get_status(stale.name), "Closed")
|
||||
self.assertEqual(get_status(fresh.name), "Unverified")
|
||||
# Open appointments are never touched, regardless of age
|
||||
self.assertEqual(get_status(self.test_appointment.name), "Open")
|
||||
|
||||
# clicking the link of a closed appointment renders a friendly message
|
||||
context = self._request_verification(stale, verify_url=verify_url)
|
||||
self.assertFalse(context.success)
|
||||
self.assertIn("closed", context.message)
|
||||
|
||||
def test_expired_unverified_appointments_are_deleted(self):
|
||||
stale = self._create_portal_appointment("portal_visitor_purged@example.com", days_from_now=8)
|
||||
fresh = self._create_portal_appointment("portal_visitor_kept@example.com", days_from_now=9)
|
||||
|
||||
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
|
||||
set_booking_setting("action_for_expired_unverified_appointments", "Delete Permanently")
|
||||
|
||||
handle_expired_unverified_appointments()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Appointment", stale.name))
|
||||
self.assertTrue(frappe.db.exists("Appointment", fresh.name))
|
||||
self.assertTrue(frappe.db.exists("Appointment", self.test_appointment.name))
|
||||
|
||||
def test_cleanup_skipped_when_expiry_not_configured(self):
|
||||
appointment = self._create_portal_appointment("portal_visitor_no_expiry@example.com")
|
||||
backdate_creation(appointment.name, 5)
|
||||
set_booking_setting("verification_link_expiry_duration", 0)
|
||||
|
||||
handle_expired_unverified_appointments()
|
||||
|
||||
self.assertEqual(get_status(appointment.name), "Unverified")
|
||||
|
||||
def test_status_transition_rules(self):
|
||||
# desk appointments can never be Unverified
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(customer_email="desk_unverified@example.com", status="Unverified")
|
||||
|
||||
# portal appointments cannot be opened manually before verification
|
||||
unverified = self._create_portal_appointment("manual_open@example.com")
|
||||
unverified.status = "Open"
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
unverified.save(ignore_permissions=True)
|
||||
|
||||
# verified appointments cannot be reverted to Unverified
|
||||
verified = self._create_portal_appointment("revert_unverified@example.com", days_from_now=8)
|
||||
self._request_verification(verified)
|
||||
verified.reload()
|
||||
verified.status = "Unverified"
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
verified.save(ignore_permissions=True)
|
||||
|
||||
# both desk and verified portal appointments can be closed and reopened
|
||||
for appointment in (self.test_appointment, verified):
|
||||
appointment.reload()
|
||||
appointment.status = "Closed"
|
||||
appointment.save(ignore_permissions=True)
|
||||
appointment.status = "Open"
|
||||
appointment.save(ignore_permissions=True)
|
||||
self.assertEqual(appointment.status, "Open")
|
||||
|
||||
def test_agent_auto_assignment(self):
|
||||
agent_email = "appointment_agent@example.com"
|
||||
if not frappe.db.exists("User", agent_email):
|
||||
frappe.get_doc(
|
||||
{"doctype": "User", "email": agent_email, "first_name": "Appointment Agent"}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
self._configure_booking_settings(agents=["Administrator", agent_email])
|
||||
first = create_test_appointment(
|
||||
customer_email="assigned_one@example.com", scheduled_time=slot_on(2, 11)
|
||||
)
|
||||
second = create_test_appointment(
|
||||
customer_email="assigned_two@example.com", scheduled_time=slot_on(2, 11)
|
||||
)
|
||||
|
||||
# both appointments in the same slot get an agent, and never the same one
|
||||
self.assertTrue(get_assignees(first.name))
|
||||
self.assertTrue(get_assignees(second.name))
|
||||
self.assertNotEqual(get_assignees(first.name), get_assignees(second.name))
|
||||
|
||||
# closing an assigned appointment closes its ToDo without re-assigning
|
||||
first.reload()
|
||||
first.status = "Closed"
|
||||
first.save()
|
||||
self.assertTrue(get_todo_statuses(first.name))
|
||||
self.assertTrue(all(status == "Closed" for status in get_todo_statuses(first.name)))
|
||||
|
||||
# reopening brings the ToDos back
|
||||
first.status = "Open"
|
||||
first.save()
|
||||
self.assertTrue(all(status == "Open" for status in get_todo_statuses(first.name)))
|
||||
|
||||
def test_agent_busy_for_the_whole_appointment_duration(self):
|
||||
self._configure_booking_settings()
|
||||
slot = slot_on(3, 11)
|
||||
appointment = create_test_appointment(customer_email="busy_agent@example.com", scheduled_time=slot)
|
||||
assignee = get_assignees(appointment.name)[0]
|
||||
|
||||
# busy anywhere inside the 30-minute appointment window, free right after it
|
||||
self.assertFalse(_check_agent_availability(assignee, slot))
|
||||
self.assertFalse(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=15)))
|
||||
self.assertTrue(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=30)))
|
||||
|
||||
def test_closed_appointment_closes_calendar_event(self):
|
||||
self.test_appointment.status = "Closed"
|
||||
self.test_appointment.save()
|
||||
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
|
||||
self.assertEqual(event_status, "Closed")
|
||||
|
||||
# reopening the appointment reopens the calendar event
|
||||
self.test_appointment.status = "Open"
|
||||
self.test_appointment.save()
|
||||
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
|
||||
self.assertEqual(event_status, "Open")
|
||||
|
||||
def test_deleting_appointment_deletes_calendar_event(self):
|
||||
event = self.test_appointment.calendar_event
|
||||
self.assertTrue(frappe.db.exists("Event", event))
|
||||
|
||||
frappe.delete_doc("Appointment", self.test_appointment.name)
|
||||
|
||||
self.assertFalse(frappe.db.exists("Event", event))
|
||||
|
||||
def test_backdated_appointment_is_rejected(self):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="backdated@example.com",
|
||||
scheduled_time=add_to_date(now_datetime(), hours=-1),
|
||||
)
|
||||
|
||||
def test_booking_beyond_advance_window_is_rejected(self):
|
||||
self._configure_booking_settings()
|
||||
set_booking_setting("advance_booking_days", 7)
|
||||
|
||||
# within the advance booking window - allowed
|
||||
within = create_test_appointment(
|
||||
customer_email="advance_within@example.com", scheduled_time=slot_on(5, 10)
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", within.name))
|
||||
|
||||
# beyond the advance booking window - rejected
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="advance_beyond@example.com", scheduled_time=slot_on(8, 10)
|
||||
)
|
||||
|
||||
def test_appointment_on_holiday_is_rejected(self):
|
||||
holiday = add_to_date(getdate(), days=3)
|
||||
self._configure_booking_settings(
|
||||
holiday_dates=[{"holiday_date": holiday, "description": "Test Holiday"}]
|
||||
)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(customer_email="on_holiday@example.com", scheduled_time=slot_on(3, 10))
|
||||
|
||||
# the day after the holiday is bookable
|
||||
after_holiday = create_test_appointment(
|
||||
customer_email="after_holiday@example.com", scheduled_time=slot_on(4, 10)
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", after_holiday.name))
|
||||
|
||||
def test_appointment_outside_slot_timing_is_rejected(self):
|
||||
self._configure_booking_settings()
|
||||
|
||||
# before the slot opens
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(customer_email="before_opening@example.com", scheduled_time=slot_on(2, 8))
|
||||
|
||||
# starts within the slot but would end after it closes
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="past_closing@example.com", scheduled_time=slot_on(2, 16, 45)
|
||||
)
|
||||
|
||||
# within the slot timings
|
||||
within = create_test_appointment(
|
||||
customer_email="within_slot@example.com", scheduled_time=slot_on(2, 10)
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", within.name))
|
||||
|
||||
def test_overlapping_time_slot_capacity(self):
|
||||
set_booking_setting("number_of_agents", 1)
|
||||
set_booking_setting("appointment_duration", 30)
|
||||
|
||||
slot = slot_on(1, 10)
|
||||
first = create_test_appointment(customer_email="slot_first@example.com", scheduled_time=slot)
|
||||
|
||||
# a booking starting inside the first appointment's duration is rejected
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="slot_overlap@example.com",
|
||||
scheduled_time=slot + datetime.timedelta(minutes=15),
|
||||
)
|
||||
|
||||
# rescheduling must not count the appointment's own booked slot
|
||||
first.scheduled_time = slot + datetime.timedelta(minutes=10)
|
||||
first.save()
|
||||
|
||||
# a booking starting exactly when the rescheduled one ends is allowed
|
||||
adjacent = create_test_appointment(
|
||||
customer_email="slot_adjacent@example.com",
|
||||
scheduled_time=slot + datetime.timedelta(minutes=40),
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", adjacent.name))
|
||||
|
||||
# a closed (cancelled) appointment frees its slot
|
||||
first.status = "Closed"
|
||||
first.save()
|
||||
after_cancellation = create_test_appointment(
|
||||
customer_email="after_cancellation@example.com", scheduled_time=slot
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", after_cancellation.name))
|
||||
|
||||
@@ -1,48 +1,56 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2019-08-27 10:56:48.309824",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enable_scheduling",
|
||||
"agent_detail_section",
|
||||
"availability_of_slots",
|
||||
"number_of_agents",
|
||||
"agent_list",
|
||||
"holiday_list",
|
||||
"appointment_details_section",
|
||||
"appointment_duration",
|
||||
"email_reminders",
|
||||
"column_break_ehiq",
|
||||
"agent_list",
|
||||
"number_of_agents",
|
||||
"agent_detail_section",
|
||||
"enable_scheduling",
|
||||
"availability_of_slots",
|
||||
"section_break_bkln",
|
||||
"column_break_alwa",
|
||||
"advance_booking_days",
|
||||
"column_break_bspp",
|
||||
"holiday_list",
|
||||
"success_details",
|
||||
"success_redirect_url"
|
||||
"enable_appointment_portal",
|
||||
"verification_link_expiry_duration",
|
||||
"column_break_fovk",
|
||||
"success_redirect_url",
|
||||
"action_for_expired_unverified_appointments"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"fieldname": "availability_of_slots",
|
||||
"fieldtype": "Table",
|
||||
"label": "Availability Of Slots",
|
||||
"options": "Appointment Booking Slots",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"options": "Appointment Booking Slots"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "number_of_agents",
|
||||
"fieldtype": "Int",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Number of Concurrent Appointments",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"fieldname": "holiday_list",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Holiday List",
|
||||
"options": "Holiday List",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"options": "Holiday List"
|
||||
},
|
||||
{
|
||||
"default": "60",
|
||||
@@ -60,29 +68,31 @@
|
||||
},
|
||||
{
|
||||
"default": "7",
|
||||
"depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"fieldname": "advance_booking_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of days appointments can be booked in advance",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;"
|
||||
},
|
||||
{
|
||||
"fieldname": "agent_list",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Agents",
|
||||
"options": "Assignment Rule User",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"options": "Assignment Rule User"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_scheduling",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Appointment Scheduling",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;"
|
||||
},
|
||||
{
|
||||
"fieldname": "agent_detail_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Agent Details"
|
||||
"hide_border": 1,
|
||||
"label": "Appointment Scheduling"
|
||||
},
|
||||
{
|
||||
"fieldname": "appointment_details_section",
|
||||
@@ -92,20 +102,68 @@
|
||||
{
|
||||
"fieldname": "success_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Success Settings"
|
||||
"label": "Appointment Booking Portal Settings"
|
||||
},
|
||||
{
|
||||
"description": "Leave blank for home.\nThis is relative to site URL, for example \"about\" will redirect to \"https://yoursitename.com/about\"",
|
||||
"fieldname": "success_redirect_url",
|
||||
"fieldtype": "Data",
|
||||
"label": "Success Redirect URL"
|
||||
"label": "Success Redirect URL",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"default": "30",
|
||||
"depends_on": "eval: doc.enable_scheduling === 1;",
|
||||
"description": "In Minutes (min: 15 mins, max: 60 mins)",
|
||||
"fieldname": "verification_link_expiry_duration",
|
||||
"fieldtype": "Int",
|
||||
"label": "Verification Link Expiry Duration",
|
||||
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;",
|
||||
"max_value": 60.0,
|
||||
"min_value": 15.0,
|
||||
"non_negative": 1,
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ehiq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_appointment_portal",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Appointment Booking Through Portal",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_fovk",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "Mark as Closed",
|
||||
"fieldname": "action_for_expired_unverified_appointments",
|
||||
"fieldtype": "Select",
|
||||
"label": "Action for Expired Unverified Appointments",
|
||||
"options": "Mark as Closed\nDelete Permanently",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_bkln",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_alwa",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_bspp",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"hide_toolbar": 0,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-16 13:28:21.198138",
|
||||
"modified": "2026-07-20 00:11:18.996384",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Appointment Booking Settings",
|
||||
@@ -139,6 +197,15 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"permlevel": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate
|
||||
|
||||
|
||||
class AppointmentBookingSettings(Document):
|
||||
@@ -24,33 +24,43 @@ class AppointmentBookingSettings(Document):
|
||||
AppointmentBookingSlots,
|
||||
)
|
||||
|
||||
action_for_expired_unverified_appointments: DF.Literal["Mark as Closed", "Delete Permanently"]
|
||||
advance_booking_days: DF.Int
|
||||
agent_list: DF.TableMultiSelect[AssignmentRuleUser]
|
||||
appointment_duration: DF.Int
|
||||
availability_of_slots: DF.Table[AppointmentBookingSlots]
|
||||
email_reminders: DF.Check
|
||||
enable_appointment_portal: DF.Check
|
||||
enable_scheduling: DF.Check
|
||||
holiday_list: DF.Link
|
||||
holiday_list: DF.Link | None
|
||||
number_of_agents: DF.Int
|
||||
success_redirect_url: DF.Data | None
|
||||
verification_link_expiry_duration: DF.Int
|
||||
# end: auto-generated types
|
||||
|
||||
agent_list: typing.ClassVar[list] = [] # Hack
|
||||
min_date = "01/01/1970 "
|
||||
format_string = "%d/%m/%Y %H:%M:%S"
|
||||
|
||||
def validate(self):
|
||||
self.validate_availability_of_slots()
|
||||
|
||||
def save(self):
|
||||
self.number_of_agents = len(self.agent_list)
|
||||
super().save()
|
||||
self.validate_appointment_scheduling()
|
||||
self.validate_portal_booking()
|
||||
|
||||
def validate_appointment_scheduling(self):
|
||||
if not self.enable_scheduling:
|
||||
return
|
||||
|
||||
self.validate_availability_of_slots()
|
||||
self.validate_holiday_list()
|
||||
self.validate_advance_booking_days()
|
||||
|
||||
def validate_availability_of_slots(self):
|
||||
if not self.availability_of_slots:
|
||||
frappe.throw(
|
||||
_("Please fill up the Availability of Slots table to enable Appointment Scheduling.")
|
||||
)
|
||||
|
||||
format_string = "%Y-%m-%d %H:%M:%S"
|
||||
for record in self.availability_of_slots:
|
||||
from_time = datetime.datetime.strptime(self.min_date + record.from_time, self.format_string)
|
||||
to_time = datetime.datetime.strptime(self.min_date + record.to_time, self.format_string)
|
||||
to_time - from_time
|
||||
from_time = datetime.datetime.strptime(f"1970-01-01 {record.from_time}", format_string)
|
||||
to_time = datetime.datetime.strptime(f"1970-01-01 {record.to_time}", format_string)
|
||||
self.validate_from_and_to_time(from_time, to_time, record)
|
||||
self.duration_is_divisible(from_time, to_time)
|
||||
|
||||
@@ -65,3 +75,38 @@ class AppointmentBookingSettings(Document):
|
||||
timedelta = to_time - from_time
|
||||
if timedelta.total_seconds() % (self.appointment_duration * 60):
|
||||
frappe.throw(_("The difference between from time and To Time must be a multiple of Appointment"))
|
||||
|
||||
def validate_holiday_list(self):
|
||||
if not self.holiday_list:
|
||||
frappe.throw(_("Please select a Holiday List to enable Appointment Scheduling."))
|
||||
|
||||
hl_from_date, hl_to_date = frappe.get_cached_value(
|
||||
"Holiday List", self.holiday_list, ["from_date", "to_date"]
|
||||
)
|
||||
now = getdate()
|
||||
|
||||
if not (now >= hl_from_date and now <= hl_to_date):
|
||||
frappe.throw(_("Holiday List - {0} is not valid for current date.").format(self.holiday_list))
|
||||
|
||||
def validate_advance_booking_days(self):
|
||||
if not self.advance_booking_days:
|
||||
frappe.throw(_("Advance Booking Days is mandatory for Appointment Scheduling."))
|
||||
|
||||
def validate_portal_booking(self):
|
||||
if not self.enable_appointment_portal:
|
||||
return
|
||||
|
||||
if not self.enable_scheduling:
|
||||
frappe.throw(
|
||||
_("Appointment Scheduling needs to be enabled for Appointment Booking through portal.")
|
||||
)
|
||||
|
||||
self.validate_link_expiry_duration()
|
||||
|
||||
def validate_link_expiry_duration(self):
|
||||
if (
|
||||
not self.verification_link_expiry_duration
|
||||
or self.verification_link_expiry_duration > 60
|
||||
or self.verification_link_expiry_duration < 15
|
||||
):
|
||||
frappe.throw(_("'Verification Link Expiry Duration' must be between 15 to 60 minutes."))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user