mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-10 07:49:29 +00:00
Compare commits
10 Commits
mergify/bp
...
v16.31.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ea583a1f | ||
|
|
2769a8c69e | ||
|
|
8378b6e203 | ||
|
|
eaf95e5c36 | ||
|
|
a5de60c357 | ||
|
|
264bfa188b | ||
|
|
de591661b9 | ||
|
|
9a7e796fd2 | ||
|
|
9d5c7605b8 | ||
|
|
f94eee3197 |
30
.github/helper/install.sh
vendored
30
.github/helper/install.sh
vendored
@@ -4,36 +4,6 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
|
||||
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
|
||||
# below, or payments under `bench get-app`. See the PR description.
|
||||
#
|
||||
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
|
||||
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
|
||||
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
|
||||
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
|
||||
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
|
||||
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
|
||||
if [ -n "$ci_github_token" ]; then
|
||||
export CI_GITHUB_TOKEN="$ci_github_token"
|
||||
export GIT_CONFIG_COUNT=3
|
||||
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
|
||||
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
|
||||
# token to ~/.git-credentials. An empty value clears the list before ours is added.
|
||||
export GIT_CONFIG_KEY_0="credential.helper"
|
||||
export GIT_CONFIG_VALUE_0=""
|
||||
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
|
||||
export GIT_CONFIG_VALUE_1="x-access-token"
|
||||
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
|
||||
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
|
||||
# token is read from the environment at call time and never stored anywhere. Answering only
|
||||
# `get` makes the helper inert for git's `store`/`erase` calls.
|
||||
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
|
||||
fi
|
||||
|
||||
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Upload main.pot to Crowdin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- version-16-hotfix
|
||||
paths:
|
||||
- "erpnext/locale/main.pot"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: crowdin-upload-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-sources:
|
||||
name: Upload sources from ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ github.ref_name }}
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore Crowdin cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .crowdin
|
||||
key: crowdin-${{ github.ref_name }}
|
||||
restore-keys: crowdin-${{ github.ref_name }}-
|
||||
|
||||
- name: Upload main.pot to Crowdin
|
||||
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||
with:
|
||||
config: crowdin.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
crowdin_branch_name: "[frappe.erpnext] ${{ github.ref_name }}"
|
||||
upload_sources_args: "--cache"
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Save Crowdin cache
|
||||
uses: actions/cache/save@v6
|
||||
if: always()
|
||||
with:
|
||||
path: .crowdin
|
||||
key: crowdin-${{ github.ref_name }}-${{ github.run_id }}
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -105,8 +105,6 @@ jobs:
|
||||
env:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Patch Tests
|
||||
run: |
|
||||
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,8 +129,6 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
|
||||
@@ -13,7 +13,6 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
- '**.po'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
3
.github/workflows/server-tests-mariadb.yml
vendored
3
.github/workflows/server-tests-mariadb.yml
vendored
@@ -13,7 +13,6 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
- '**.po'
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
- cron: "0 0 * * *"
|
||||
@@ -102,8 +101,6 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.17.1",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.2",
|
||||
"jotai-family": "^1.1.0",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.3.0",
|
||||
"react-router": "^8.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
@@ -48,18 +48,18 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.2.1"
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
"typescript-eslint": "^8.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import Fuse from "fuse.js"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import { FormControl } from "../ui/form"
|
||||
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch"
|
||||
|
||||
|
||||
export interface AccountsDropdownProps {
|
||||
@@ -105,10 +104,6 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Searching replaces the grouped list with a short result list, so pin the scroll back to
|
||||
// the top - otherwise the auto-selected first result can be out of view.
|
||||
const listRef = useResetScrollOnSearch(search)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -158,7 +153,7 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
|
||||
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
|
||||
<CommandList ref={listRef}>
|
||||
<CommandList>
|
||||
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
|
||||
|
||||
{recommendedAccounts.length > 0 && (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ChevronDownIcon, ExternalLink } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
|
||||
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch";
|
||||
import _ from "@/lib/translate";
|
||||
import ErrorBanner from "../ui/error-banner";
|
||||
import MarkdownRenderer from "../ui/markdown";
|
||||
@@ -150,10 +149,6 @@ const LinkFieldCombobox = ({
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Results change as the search runs, so pin the scroll back to the top to keep the
|
||||
// auto-selected first result in view.
|
||||
const listRef = useResetScrollOnSearch(searchInput)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -269,7 +264,7 @@ const LinkFieldCombobox = ({
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
|
||||
<CommandList ref={listRef}>
|
||||
<CommandList>
|
||||
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items?.map((result) => (
|
||||
@@ -277,7 +272,7 @@ const LinkFieldCombobox = ({
|
||||
<span className="font-medium">
|
||||
{result.label || result.value}
|
||||
</span>
|
||||
{result.description && <span className="text-p-xs text-ink-gray-5">
|
||||
{result.description && <span className="text-xs text-ink-gray-5">
|
||||
<MarkdownRenderer content={result.description} />
|
||||
</span>}
|
||||
</CommandItem>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Progress } from "@/components/ui/progress"
|
||||
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
||||
import { Edit, Info, Trash2 } from "lucide-react"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Form } from "@/components/ui/form"
|
||||
@@ -26,109 +26,50 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const useBankCurrency = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
return bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of the balance summary - label on the left, figure right-aligned.
|
||||
*
|
||||
* `items-baseline` keeps the figure on the label's FIRST line, so a row carrying a `subLabel`
|
||||
* (the statement row's "As of <date>" note) doesn't centre its value against both lines.
|
||||
*/
|
||||
const BalanceRow = ({ label, info, subLabel, emphasis, children }: {
|
||||
label: React.ReactNode
|
||||
info?: React.ReactNode
|
||||
subLabel?: React.ReactNode
|
||||
emphasis?: boolean
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className={cn("flex items-center gap-1 whitespace-nowrap text-xs text-ink-gray-6",
|
||||
emphasis && "font-medium text-ink-gray-7")}>
|
||||
{label}
|
||||
{info}
|
||||
</span>
|
||||
{subLabel}
|
||||
</span>
|
||||
<div className="flex flex-col items-end">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Type styles for a figure. Shared so an interactive figure can put them on the <button>
|
||||
* ITSELF rather than on a nested span: Tailwind's preflight sets `font: inherit` on buttons,
|
||||
* which resets line-height too, so a button wrapping a `text-sm` span gets a taller strut than
|
||||
* the span and the row grows - visible as extra space above a baseline-aligned row.
|
||||
*/
|
||||
const BALANCE_VALUE_CLASSES = "font-numeric text-sm tabular-nums text-ink-gray-8"
|
||||
|
||||
const BalanceValue = ({ children, emphasis, tone, className }: { children: React.ReactNode, emphasis?: boolean, tone?: 'red', className?: string }) => (
|
||||
<span className={cn(BALANCE_VALUE_CLASSES,
|
||||
emphasis && "font-semibold",
|
||||
tone === 'red' && "text-ink-red-3",
|
||||
className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
const BalanceSkeleton = () => <Skeleton className="h-4 w-24 rounded-sm" />
|
||||
|
||||
/**
|
||||
* Balances and progress for the selected bank account, laid out like the totals block of an
|
||||
* invoice. This sits beside the bank picker rather than in a row of its own (saves vertical
|
||||
* space) and outside the picker's horizontal scroll area, so the figures being reconciled
|
||||
* against can never scroll out of view.
|
||||
*/
|
||||
const BankAccountBalancePanel = () => {
|
||||
const BankBalance = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-72 shrink-0 flex-col justify-center gap-2.5 border-s border-outline-gray-2 ps-4">
|
||||
{/* Names the account these figures belong to - the picker scrolls, so the
|
||||
highlighted card can't be relied on as the referent. */}
|
||||
<span
|
||||
className="truncate text-xs font-medium text-ink-gray-7"
|
||||
title={bankAccount.account_name}>
|
||||
{bankAccount.account_name}
|
||||
</span>
|
||||
<OpeningBalanceRow />
|
||||
<SystemClosingBalanceRow />
|
||||
<StatementClosingBalanceRow />
|
||||
<Separator />
|
||||
<DifferenceRow />
|
||||
<ReconciledRow />
|
||||
<div className="flex justify-between">
|
||||
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
|
||||
<OpeningBalance />
|
||||
<ClosingBalance />
|
||||
<ClosingBalanceAsPerStatement />
|
||||
<Difference />
|
||||
</div>
|
||||
|
||||
<ReconcileProgress />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OpeningBalanceRow = () => {
|
||||
const currency = useBankCurrency()
|
||||
const OpeningBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const { data, isLoading } = useGetAccountOpeningBalance()
|
||||
|
||||
return <BalanceRow label={_("Opening Balance")}>
|
||||
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Opening Balance")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
|
||||
const SystemClosingBalanceRow = () => {
|
||||
const currency = useBankCurrency()
|
||||
const ClosingBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
return (
|
||||
<BalanceRow
|
||||
label={_("Closing (system)")}
|
||||
info={
|
||||
<StatContainer className="min-w-48">
|
||||
<div className="flex items-start gap-1">
|
||||
<StatLabel>
|
||||
{_("Closing Balance as per system")}
|
||||
</StatLabel>
|
||||
<HoverCard openDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<Info className="size-3.5 text-ink-gray-6" />
|
||||
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96" align="start" side="right">
|
||||
<H4 className="text-base">{_("Closing balance as per system")}</H4>
|
||||
@@ -143,111 +84,15 @@ const SystemClosingBalanceRow = () => {
|
||||
</Paragraph>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
}
|
||||
>
|
||||
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
|
||||
</div>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StatementClosingBalanceRow = () => {
|
||||
|
||||
const Difference = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const currency = useBankCurrency()
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
||||
onSuccess: (data) => {
|
||||
if (data?.message && data?.message?.balance) {
|
||||
setValue({
|
||||
value: data?.message?.balance,
|
||||
stringValue: data?.message?.balance.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isDateSame = data?.message?.date === dates.toDate
|
||||
|
||||
// The server uses the returned date to distinguish an unset balance from a saved zero.
|
||||
const hasBalance = Boolean(data?.message?.date)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const tooltip = hasBalance
|
||||
? _("Click to change the closing balance as per statement")
|
||||
: _("Click to set the closing balance as per statement")
|
||||
|
||||
return (
|
||||
<BalanceRow
|
||||
label={_("Closing (statement)")}
|
||||
// The pencil sits beside the label, mirroring the info icon on the row above, so
|
||||
// the figure stays a plain right-aligned number in line with every other row.
|
||||
info={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* `p-0`: Tailwind's preflight gives buttons `appearance: button` but
|
||||
doesn't reset padding, so a bare button picks up the UA's ~1px 6px
|
||||
and knocks this row out of step with its neighbours. */}
|
||||
<button
|
||||
type='button'
|
||||
aria-label={tooltip}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer p-0 text-ink-gray-5 transition-colors hover:text-ink-gray-7">
|
||||
<Edit className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
subLabel={!isDateSame && data?.message.date
|
||||
? <span className="whitespace-nowrap text-2xs font-medium text-ink-red-3">
|
||||
{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}
|
||||
</span>
|
||||
: undefined}
|
||||
>
|
||||
{/* Deliberately NOT a flex container: a flex box's baseline doesn't resolve to its
|
||||
text, so the row's `items-baseline` couldn't line this up with the label. As a
|
||||
plain inline button its baseline is the figure's own, like every other row.
|
||||
"Set" gets the same treatment as a figure - it stands in for one. */}
|
||||
{isLoading
|
||||
? <BalanceSkeleton />
|
||||
: <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* The figure styles live on the button itself - see
|
||||
BALANCE_VALUE_CLASSES. `p-0` because preflight leaves the UA's
|
||||
button padding in place. */}
|
||||
<button
|
||||
type='button'
|
||||
aria-label={tooltip}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(BALANCE_VALUE_CLASSES,
|
||||
"cursor-pointer p-0 underline decoration-outline-gray-5 decoration-dashed underline-offset-4",
|
||||
"transition-colors hover:decoration-ink-gray-8")}>
|
||||
{hasBalance ? formatCurrency(flt(data?.message?.balance, 2), currency) : _("Set")}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>}
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<ClosingBalanceForm
|
||||
defaultBalance={data?.message?.balance ?? 0}
|
||||
date={dates.toDate}
|
||||
bankAccount={bankAccount}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</BalanceRow>
|
||||
)
|
||||
}
|
||||
|
||||
const DifferenceRow = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const currency = useBankCurrency()
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
@@ -257,15 +102,16 @@ const DifferenceRow = () => {
|
||||
|
||||
const isError = difference !== 0
|
||||
|
||||
return <BalanceRow label={_("Difference")} emphasis>
|
||||
{isLoading
|
||||
? <BalanceSkeleton />
|
||||
: <BalanceValue emphasis tone={isError ? 'red' : undefined}>{formatCurrency(difference, currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
return <StatContainer className="w-fit text-end sm:min-w-56">
|
||||
<StatLabel className="text-end">{_("Difference")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
|
||||
{formatCurrency(difference,
|
||||
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
|
||||
}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
|
||||
/** Reconciliation progress through the selected date range: a count plus a slim bar. */
|
||||
const ReconciledRow = () => {
|
||||
const ReconcileProgress = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
@@ -286,14 +132,75 @@ const ReconciledRow = () => {
|
||||
|
||||
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
|
||||
|
||||
return <div className="flex flex-col gap-1.5">
|
||||
<BalanceRow label={_("Reconciled")}>
|
||||
<BalanceValue>{reconciledCount} / {totalCount ?? 0}</BalanceValue>
|
||||
</BalanceRow>
|
||||
<Progress value={progress} max={100} size="sm" />
|
||||
return <div className="w-[18%] flex flex-col gap-1 items-end">
|
||||
<div className="w-full">
|
||||
<Progress
|
||||
value={progress}
|
||||
max={100}
|
||||
size="md"
|
||||
label="Progress"
|
||||
hint
|
||||
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ClosingBalanceAsPerStatement = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
||||
onSuccess: (data) => {
|
||||
if (data?.message && data?.message?.balance) {
|
||||
setValue({
|
||||
value: data?.message?.balance,
|
||||
stringValue: data?.message?.balance.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isDateSame = data?.message?.date === dates.toDate
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
<Edit className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Click to set the closing balance as per statement")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-xl">
|
||||
<ClosingBalanceForm
|
||||
defaultBalance={data?.message?.balance ?? 0}
|
||||
date={dates.toDate}
|
||||
bankAccount={bankAccount}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
|
||||
</div>
|
||||
</StatContainer>
|
||||
|
||||
}
|
||||
|
||||
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
@@ -395,7 +302,7 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
|
||||
|
||||
return <div>
|
||||
<Separator className="my-8" />
|
||||
<p className="text-p-sm text-center pb-2">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -424,4 +331,4 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
|
||||
|
||||
}
|
||||
|
||||
export default BankAccountBalancePanel
|
||||
export default BankBalance
|
||||
@@ -205,9 +205,9 @@ const BankClearanceSummaryView = () => {
|
||||
|
||||
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="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div className="shrink-0">
|
||||
<div>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -220,9 +220,8 @@ const BankClearanceSummaryView = () => {
|
||||
data={data.message.result}
|
||||
columns={clearanceColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -74,10 +74,7 @@ const BankPicker = ({ className }: { className?: string }) => {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
// No trailing padding: it would sit inside the fade region, so the mask would
|
||||
// spend itself on empty space and the last card would stop short of the balance
|
||||
// panel instead of fading towards it. The column gap provides the separation.
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto scroll-fade-x",
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
|
||||
banks?.length > 4 ? 'pb-2' : '', className,
|
||||
)}
|
||||
style={{
|
||||
@@ -111,12 +108,12 @@ const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
|
||||
role="button"
|
||||
title={`Select ${bank.account_name}`}
|
||||
onClick={onSelect}
|
||||
// `shrink-0`: this is a horizontally scrolling row, so cards keep their own width
|
||||
// instead of being compressed to fit the container.
|
||||
className={cn('w-60 shrink-0 rounded-md border border-outline-gray-1 p-2 overflow-hidden cursor-pointer transition-colors',
|
||||
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
|
||||
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
|
||||
)}
|
||||
>
|
||||
|
||||
|
||||
<BankLogo bank={bank} className="mb-2" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -5,179 +5,107 @@ import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod }
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { parse } from "chrono-node"
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import useFiscalYear from '@/hooks/useFiscalYear'
|
||||
import dayjs from 'dayjs'
|
||||
import _ from '@/lib/translate'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import useResetScrollOnSearch from '@/hooks/useResetScrollOnSearch'
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
/** Current fiscal year plus this many previous ones, for quarter/year options. */
|
||||
const PREVIOUS_FISCAL_YEARS = 2
|
||||
|
||||
type DateOption = {
|
||||
/** Stable id - used as the cmdk value and the React key. */
|
||||
key: string
|
||||
label: string
|
||||
translatedLabel: string
|
||||
fromDate: string
|
||||
toDate: string
|
||||
format: string
|
||||
/** Extra terms to match against, beyond the labels and dates. */
|
||||
keywords?: string[]
|
||||
/** Whether to show this option when the search box is empty. */
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiscal years keep the same month/day boundaries year on year, so previous years can be
|
||||
* derived by subtracting whole years instead of fetching them. Works for both Jan-Dec and
|
||||
* Apr-Mar style fiscal years.
|
||||
*/
|
||||
const fiscalYearLabel = (start: dayjs.Dayjs, end: dayjs.Dayjs) =>
|
||||
start.year() === end.year() ? `${start.year()}` : `${start.year()}-${end.year()}`
|
||||
|
||||
const BankRecDateFilter = () => {
|
||||
|
||||
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
|
||||
|
||||
const { fiscalYear } = useFiscalYear()
|
||||
const { data: fiscalYear } = useFiscalYear()
|
||||
|
||||
const today = useMemo(() => dayjs().format(DATE_FORMAT), [])
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
const standardOptions: DateOption[] = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const timePeriodOptions = useMemo(() => {
|
||||
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const dates = getDatesForTimePeriod(period)
|
||||
return {
|
||||
key: period,
|
||||
label: period,
|
||||
translatedLabel: dates.translatedLabel ?? _(period),
|
||||
fromDate: dates.fromDate,
|
||||
toDate: dates.toDate,
|
||||
format: dates.format,
|
||||
isDefault: true,
|
||||
translatedLabel: dates.translatedLabel
|
||||
}
|
||||
})
|
||||
|
||||
if (!fiscalYear) {
|
||||
return standardOptions
|
||||
}
|
||||
if (fiscalYear?.message) {
|
||||
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters
|
||||
const fiscalYearStart = fiscalYear.message.year_start_date
|
||||
const fiscalYearEnd = fiscalYear.message.year_end_date
|
||||
|
||||
const currentStart = dayjs(fiscalYear.year_start_date)
|
||||
const currentEnd = dayjs(fiscalYear.year_end_date)
|
||||
|
||||
const quarterOptions: DateOption[] = []
|
||||
const fiscalYearOptions: DateOption[] = []
|
||||
|
||||
// Static literals so the translation extractor can find them.
|
||||
const quarterLabels = [_("Q1"), _("Q2"), _("Q3"), _("Q4")]
|
||||
|
||||
for (let yearsAgo = 0; yearsAgo <= PREVIOUS_FISCAL_YEARS; yearsAgo++) {
|
||||
const start = currentStart.subtract(yearsAgo, 'year')
|
||||
const end = currentEnd.subtract(yearsAgo, 'year')
|
||||
// Keep the real name for the current year; derive it for the earlier ones.
|
||||
const yearLabel = yearsAgo === 0 ? fiscalYear.name : fiscalYearLabel(start, end)
|
||||
|
||||
for (let quarter = 0; quarter < 4; quarter++) {
|
||||
const quarterStart = start.add(quarter * 3, 'month')
|
||||
// End the day before the next quarter starts, clamped to the fiscal year end
|
||||
// so a short fiscal year can't spill over.
|
||||
const nextQuarterStart = start.add((quarter + 1) * 3, 'month')
|
||||
const quarterEnd = nextQuarterStart.subtract(1, 'day').isAfter(end)
|
||||
? end
|
||||
: nextQuarterStart.subtract(1, 'day')
|
||||
|
||||
if (quarterStart.isAfter(end)) continue
|
||||
|
||||
quarterOptions.push({
|
||||
key: `Q${quarter + 1}-${yearLabel}`,
|
||||
label: `Q${quarter + 1}: ${yearLabel}`,
|
||||
translatedLabel: `${quarterLabels[quarter]}: ${yearLabel}`,
|
||||
fromDate: quarterStart.format(DATE_FORMAT),
|
||||
toDate: quarterEnd.format(DATE_FORMAT),
|
||||
format: 'MMM YYYY',
|
||||
keywords: ['quarter', `q${quarter + 1}`, yearLabel],
|
||||
// Only the current fiscal year's quarters clutter the default list;
|
||||
// older ones stay searchable.
|
||||
isDefault: yearsAgo === 0,
|
||||
})
|
||||
const q1 = {
|
||||
label: `Q1: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const label = yearsAgo === 0
|
||||
? 'This Fiscal Year'
|
||||
: yearsAgo === 1
|
||||
? 'Last Fiscal Year'
|
||||
: `FY ${yearLabel}`
|
||||
const q2 = {
|
||||
label: `Q2: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
fiscalYearOptions.push({
|
||||
key: `fiscal-year-${yearLabel}`,
|
||||
label,
|
||||
translatedLabel: yearsAgo <= 1 ? _(label) : `${_("FY")} ${yearLabel}`,
|
||||
fromDate: start.format(DATE_FORMAT),
|
||||
toDate: end.format(DATE_FORMAT),
|
||||
format: 'MMM YYYY',
|
||||
keywords: ['fiscal year', yearLabel],
|
||||
isDefault: yearsAgo <= 1,
|
||||
const q3 = {
|
||||
label: `Q3: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q4 = {
|
||||
label: `Q4: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const thisYear = {
|
||||
label: `This Fiscal Year`,
|
||||
translatedLabel: `${_("This Fiscal Year")}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const lastYear = {
|
||||
label: `Last Fiscal Year`,
|
||||
translatedLabel: `${_("Last Fiscal Year")}`,
|
||||
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
|
||||
|
||||
const topRankedItems = standardOptions.filter((option) => {
|
||||
return option.label === "This Month" || option.label === "Last Month"
|
||||
})
|
||||
|
||||
const bottomRankedItems = standardOptions.filter((option) => {
|
||||
return option.label !== "This Month" && option.label !== "Last Month"
|
||||
})
|
||||
|
||||
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
|
||||
}
|
||||
|
||||
// "This Month"/"Last Month" first, then quarters and fiscal years, then the rest.
|
||||
const topRanked = standardOptions.filter((o) => o.label === 'This Month' || o.label === 'Last Month')
|
||||
const bottomRanked = standardOptions.filter((o) => o.label !== 'This Month' && o.label !== 'Last Month')
|
||||
|
||||
return [...topRanked, ...quarterOptions, ...fiscalYearOptions, ...bottomRanked]
|
||||
return standardOptions
|
||||
}, [fiscalYear])
|
||||
|
||||
// Reconciliation only looks backwards, so a period that hasn't started is never useful.
|
||||
const selectableOptions = useMemo(
|
||||
() => allOptions.filter((option) => option.fromDate <= today),
|
||||
[allOptions, today],
|
||||
)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
// We filter ourselves (`shouldFilter={false}`) so that the parsed-date suggestion can be a
|
||||
// real CommandItem alongside the predefined options, and keyboard navigation covers both.
|
||||
const filteredOptions = useMemo(() => {
|
||||
const query = value.trim().toLowerCase()
|
||||
|
||||
if (!query) {
|
||||
return selectableOptions.filter((option) => option.isDefault)
|
||||
}
|
||||
|
||||
const tokens = query.split(/\s+/)
|
||||
|
||||
return selectableOptions.filter((option) => {
|
||||
const haystack = [
|
||||
option.label,
|
||||
option.translatedLabel,
|
||||
...(option.keywords ?? []),
|
||||
option.fromDate,
|
||||
option.toDate,
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return tokens.every((token) => haystack.includes(token))
|
||||
})
|
||||
}, [selectableOptions, value])
|
||||
|
||||
const parsedOption = useMemo(() => parseDateRange(value), [value])
|
||||
|
||||
// Filtering shortens the list, so pin the scroll back to the top to keep the
|
||||
// auto-selected first option in view.
|
||||
const listRef = useResetScrollOnSearch(value)
|
||||
|
||||
// Don't show a parsed suggestion that duplicates an option already in the list.
|
||||
const showParsedOption = parsedOption
|
||||
&& !filteredOptions.some((o) => o.fromDate === parsedOption.fromDate && o.toDate === parsedOption.toDate)
|
||||
|
||||
const timePeriod: TimePeriod | string = useMemo(() => {
|
||||
if (bankRecDate.fromDate && bankRecDate.toDate) {
|
||||
for (const period of allOptions) {
|
||||
// Check if the from and to dates match any predefined time period
|
||||
for (const period of timePeriodOptions) {
|
||||
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
|
||||
return period.label;
|
||||
}
|
||||
@@ -186,11 +114,10 @@ const BankRecDateFilter = () => {
|
||||
} else {
|
||||
return "Date Range";
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, allOptions]);
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
|
||||
|
||||
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
|
||||
setBankRecDate({ fromDate, toDate })
|
||||
setValue("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -203,9 +130,7 @@ const BankRecDateFilter = () => {
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
const RangeArrow = direction === 'ltr'
|
||||
? <ChevronRight className='text-[12px] text-ink-gray-5/70' />
|
||||
: <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />
|
||||
|
||||
|
||||
return <div className='flex items-center'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -216,57 +141,30 @@ const BankRecDateFilter = () => {
|
||||
size='md'
|
||||
className='rounded-e-none border-e-0'
|
||||
role="combobox">
|
||||
{allOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-84 p-1" align='start'>
|
||||
<Command shouldFilter={false}>
|
||||
<Command>
|
||||
|
||||
<CommandInput placeholder={_("e.g. Last 3 weeks, Q1, May 2025")} onValueChange={setValue} value={value} />
|
||||
<CommandList ref={listRef} className='max-h-80'>
|
||||
{showParsedOption && parsedOption && (
|
||||
<CommandGroup heading={_("Matched date")}>
|
||||
<CommandItem
|
||||
value='parsed-date-range'
|
||||
className='flex justify-between'
|
||||
onSelect={() => handleTimePeriodChange(parsedOption.fromDate, parsedOption.toDate)}>
|
||||
<span className='max-w-[45%] truncate'>{value}</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{parsedOption.fromDate === parsedOption.toDate
|
||||
? formatDate(parsedOption.fromDate, 'Do MMM YYYY')
|
||||
: <>{formatDate(parsedOption.fromDate, 'Do MMM YY')} {RangeArrow} {formatDate(parsedOption.toDate, 'Do MMM YY')}</>}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredOptions.length > 0 && (
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((period) => (
|
||||
<CommandItem
|
||||
key={period.key}
|
||||
value={period.key}
|
||||
className='flex justify-between'
|
||||
onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
|
||||
<span>
|
||||
{period.translatedLabel}
|
||||
</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{formatDate(period.fromDate, period.format)} {RangeArrow} {formatDate(period.toDate, period.format)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!showParsedOption && filteredOptions.length === 0 && (
|
||||
<div className='p-2 text-sm text-ink-gray-5'>
|
||||
{_("No results found")}
|
||||
</div>
|
||||
)}
|
||||
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} />
|
||||
<CommandList className='max-h-fit'>
|
||||
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'>
|
||||
<EmptyState onSelect={handleTimePeriodChange} value={value} />
|
||||
</CommandEmpty>
|
||||
{timePeriodOptions.map((period) => (
|
||||
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
|
||||
<span>
|
||||
{period.translatedLabel ?? _(period.label)}
|
||||
</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -301,97 +199,77 @@ const BankRecDateFilter = () => {
|
||||
}
|
||||
|
||||
const referentialKeywords = ["last", "this", "next", "previous"]
|
||||
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
|
||||
|
||||
/** chrono exposes `knownValues` on ParsingComponents but doesn't type it publicly. */
|
||||
const knownValuesOf = (components: unknown): Record<string, number> =>
|
||||
(components as { knownValues?: Record<string, number> })?.knownValues ?? {}
|
||||
const dates = useMemo(() => {
|
||||
if (value) {
|
||||
// Try parsing the value
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
|
||||
/**
|
||||
* How far back a parsed date must move to land in the past. Reconciliation only ever looks
|
||||
* backwards, so an ambiguous input that chrono resolves into the future - "December" typed in
|
||||
* September, or a bare weekday like "Friday" - is pulled to its most recent past occurrence.
|
||||
* An explicitly stated year is respected; a range that is still future gets discarded later.
|
||||
*
|
||||
* This returns a shift rather than a date so that a range can be moved as a single unit -
|
||||
* shifting its start and end independently would distort or invert it.
|
||||
*/
|
||||
const pastShift = (date: Date, knownValues: Record<string, number>) => {
|
||||
const today = dayjs()
|
||||
let candidate = dayjs(date)
|
||||
if (parsedDate && parsedDate.length > 0) {
|
||||
const startDate = parsedDate[0].start.date()
|
||||
const endDate = parsedDate[0].end?.date()
|
||||
|
||||
if (!candidate.isAfter(today, 'date') || knownValues.year !== undefined) {
|
||||
return { amount: 0, unit: 'year' as const }
|
||||
if (!endDate) {
|
||||
const today = new Date()
|
||||
// If today is greater than the start date, use today as the end date
|
||||
if (startDate.getTime() > today.getTime()) {
|
||||
return { fromDate: today, toDate: startDate }
|
||||
} else {
|
||||
// Check if the user only wants a specific month like "May 2025"
|
||||
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: dayjs(startDate).endOf('month').toDate()
|
||||
}
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
|
||||
// If month and day is known, then we should not assume that the user wants to get everything until today
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: startDate,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: today
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { fromDate: startDate, toDate: endDate }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const onClick = (fromDate: Date, toDate: Date) => {
|
||||
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
// A bare weekday repeats weekly, everything else (month/day) repeats yearly.
|
||||
const unit = knownValues.weekday !== undefined && knownValues.day === undefined
|
||||
? 'day' as const
|
||||
: 'year' as const
|
||||
const step = unit === 'day' ? 7 : 1
|
||||
let amount = 0
|
||||
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
|
||||
|
||||
for (let i = 0; i < 200 && candidate.isAfter(today, 'date'); i++) {
|
||||
candidate = candidate.subtract(step, unit)
|
||||
amount += step
|
||||
}
|
||||
|
||||
return { amount, unit }
|
||||
return <div>
|
||||
{dates ?
|
||||
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}>
|
||||
<span className='text-sm text-ink-gray-5 max-w-[30%]'>
|
||||
{value}
|
||||
</span>
|
||||
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YYYY')}
|
||||
</span> :
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
|
||||
</span>}
|
||||
</div> :
|
||||
<span className='text-sm text-ink-gray-5'>
|
||||
No results found
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse free text into a past date range, or return undefined when it can't be parsed or
|
||||
* resolves entirely into the future.
|
||||
*/
|
||||
const parseDateRange = (value: string): { fromDate: string, toDate: string } | undefined => {
|
||||
if (!value.trim()) return undefined
|
||||
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
|
||||
if (!parsedDate || parsedDate.length === 0) return undefined
|
||||
|
||||
const result = parsedDate[0]
|
||||
const startKnownValues = knownValuesOf(result.start)
|
||||
|
||||
// Anchor the shift on the start and apply it to both ends, so an explicit range like
|
||||
// "1st Sept to 30th Sept" keeps its shape instead of having only its end rolled back.
|
||||
const shift = pastShift(result.start.date(), startKnownValues)
|
||||
const startDate = dayjs(result.start.date()).subtract(shift.amount, shift.unit).toDate()
|
||||
const endDate = result.end
|
||||
? dayjs(result.end.date()).subtract(shift.amount, shift.unit).toDate()
|
||||
: undefined
|
||||
|
||||
const today = new Date()
|
||||
let range: { fromDate: Date, toDate: Date }
|
||||
|
||||
if (endDate) {
|
||||
const endKnownValues = knownValuesOf(result.end)
|
||||
// chrono ends "Apr 2025 to Jun 2025" on the 1st of June, but the user means all of it.
|
||||
const rangeEnd = endKnownValues.month && !endKnownValues.day
|
||||
? dayjs(endDate).endOf('month').toDate()
|
||||
: endDate
|
||||
range = { fromDate: startDate, toDate: rangeEnd }
|
||||
} else if (startKnownValues.month && !startKnownValues.day) {
|
||||
// The user only wants a specific month like "May 2025" - span the whole month
|
||||
range = { fromDate: dayjs(startDate).startOf('month').toDate(), toDate: dayjs(startDate).endOf('month').toDate() }
|
||||
} else if (startKnownValues.month && startKnownValues.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
|
||||
// If month and day is known, then we should not assume that the user wants to get everything until today
|
||||
range = { fromDate: startDate, toDate: startDate }
|
||||
} else {
|
||||
range = { fromDate: startDate, toDate: today }
|
||||
}
|
||||
|
||||
// A range that hasn't started yet is never useful for reconciliation. A range that merely
|
||||
// ends in the future is kept as typed, the same way "This Month" spans the whole month.
|
||||
if (dayjs(range.fromDate).isAfter(today, 'date')) return undefined
|
||||
|
||||
if (dayjs(range.toDate).isBefore(range.fromDate, 'date')) {
|
||||
range = { fromDate: range.toDate, toDate: range.fromDate }
|
||||
}
|
||||
|
||||
return {
|
||||
fromDate: dayjs(range.fromDate).format(DATE_FORMAT),
|
||||
toDate: dayjs(range.toDate).format(DATE_FORMAT),
|
||||
}
|
||||
}
|
||||
|
||||
export default BankRecDateFilter
|
||||
export default BankRecDateFilter
|
||||
@@ -191,9 +191,9 @@ const BankReconciliationStatementView = () => {
|
||||
|
||||
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="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div className="shrink-0">
|
||||
<div>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -201,18 +201,16 @@ const BankReconciliationStatementView = () => {
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && <div className="shrink-0"><SummarySection data={data} /></div>}
|
||||
{data && <SummarySection data={data} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<p className="shrink-0 text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<ListView
|
||||
data={statementRows}
|
||||
columns={statementColumns}
|
||||
getRowId={(row) => row.payment_entry}
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
maxHeight="min(70vh, 640px)"
|
||||
emptyState={_("No entries with a payment document in this list.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -245,9 +245,9 @@ const BankTransactionListView = () => {
|
||||
|
||||
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="flex min-h-0 flex-1 flex-col space-y-2 py-2">
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex shrink-0 gap-2 justify-between items-center">
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -278,9 +278,8 @@ const BankTransactionListView = () => {
|
||||
data={filteredResults}
|
||||
columns={transactionColumns}
|
||||
getRowId={(row) => row.name}
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
emptyState={<Empty>
|
||||
<EmptyMedia>
|
||||
<ListIcon />
|
||||
|
||||
@@ -181,9 +181,9 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
|
||||
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="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div className="shrink-0">
|
||||
<div>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
@@ -198,15 +198,13 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<p className="shrink-0 text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={incorrectlyClearedColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
maxHeight="min(70vh, 640px)"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Link } from "react-router"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
||||
|
||||
const MatchAndReconcile = () => {
|
||||
const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!selectedBank) {
|
||||
@@ -52,15 +52,15 @@ const MatchAndReconcile = () => {
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="flex min-h-0 flex-1 items-stretch space-x-2" >
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<H4 className="shrink-0 text-sm font-medium">{_("Unreconciled Transactions")}</H4>
|
||||
<UnreconciledTransactions />
|
||||
<div className={`flex items-start space-x-2`} >
|
||||
<div className="flex-1">
|
||||
<H4 className="text-sm font-medium">{_("Unreconciled Transactions")}</H4>
|
||||
<UnreconciledTransactions contentHeight={contentHeight} />
|
||||
</div>
|
||||
<Separator orientation="vertical" className="self-stretch" />
|
||||
<div className="flex min-h-0 flex-1 flex-col px-1">
|
||||
<H4 className="shrink-0 text-sm font-medium">{_("Match or Create")}</H4>
|
||||
<VouchersSection />
|
||||
<Separator orientation="vertical" style={{ minHeight: `${contentHeight}px` }} />
|
||||
<div className="flex-1 px-1">
|
||||
<H4 className="text-sm font-medium">{_("Match or Create")}</H4>
|
||||
<VouchersSection contentHeight={contentHeight} />
|
||||
</div>
|
||||
</div>
|
||||
<TransferModal />
|
||||
@@ -69,19 +69,16 @@ const MatchAndReconcile = () => {
|
||||
</>
|
||||
}
|
||||
|
||||
/**
|
||||
* TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets
|
||||
* the real height. The scroll container fills its flex parent rather than taking a pixel
|
||||
* height - the virtualizer observes its own rect, so it stays correct across resizes and any
|
||||
* layout change above it.
|
||||
*/
|
||||
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
|
||||
function VirtualizedListBody<T>({
|
||||
items,
|
||||
height,
|
||||
getItemKey,
|
||||
children,
|
||||
estimateSize = 74,
|
||||
}: {
|
||||
items: T[]
|
||||
height: number
|
||||
getItemKey: (item: T, index: number) => string | number
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
estimateSize?: number
|
||||
@@ -103,7 +100,8 @@ function VirtualizedListBody<T>({
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-auto contain-strict"
|
||||
className="overflow-auto contain-strict"
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
@@ -125,7 +123,7 @@ function VirtualizedListBody<T>({
|
||||
)
|
||||
}
|
||||
|
||||
const UnreconciledTransactions = () => {
|
||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
@@ -189,13 +187,14 @@ const UnreconciledTransactions = () => {
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
const listHeight = contentHeight - 72
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
}
|
||||
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-1">
|
||||
<div className="flex py-2 w-full gap-2 shrink-0">
|
||||
return <div className="space-y-1">
|
||||
<div className="flex py-2 w-full gap-2">
|
||||
|
||||
<InputGroup variant='outline'>
|
||||
<label className="sr-only">{_("Search transactions")}</label>
|
||||
@@ -279,6 +278,7 @@ const UnreconciledTransactions = () => {
|
||||
|
||||
<VirtualizedListBody
|
||||
items={results}
|
||||
height={listHeight}
|
||||
estimateSize={74}
|
||||
getItemKey={(transaction) => transaction.name}
|
||||
>
|
||||
@@ -381,7 +381,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
|
||||
}
|
||||
|
||||
|
||||
const VouchersSection = () => {
|
||||
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
@@ -402,8 +402,8 @@ const VouchersSection = () => {
|
||||
return <OptionsForMultipleTransactions transactions={selectedTransactions} />
|
||||
}
|
||||
|
||||
return <div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} />
|
||||
return <div style={{ minHeight: contentHeight }} className="mt-2">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -535,11 +535,11 @@ const OptionsForMultipleTransactions = ({ transactions }: { transactions: Unreco
|
||||
}
|
||||
|
||||
|
||||
const OptionsForSingleTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
|
||||
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
|
||||
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
return <div className="flex flex-col gap-3">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-4 justify-center">
|
||||
@@ -602,7 +602,7 @@ const OptionsForSingleTransaction = ({ transaction }: { transaction: Unreconcile
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
|
||||
<VouchersForTransaction transaction={transaction} />
|
||||
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -774,11 +774,12 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
)
|
||||
}
|
||||
|
||||
const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
const voucherList = vouchers?.message ?? []
|
||||
const listHeight = contentHeight - 120
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
@@ -800,8 +801,8 @@ const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTran
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="relative flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm text-ink-gray-5">
|
||||
return <div className="relative space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
|
||||
<Separator className="flex-1" />
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
@@ -817,6 +818,7 @@ const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTran
|
||||
</Empty>}
|
||||
<VirtualizedListBody
|
||||
items={voucherList}
|
||||
height={listHeight}
|
||||
estimateSize={121}
|
||||
getItemKey={(voucher) => voucher.name}
|
||||
>
|
||||
|
||||
@@ -59,8 +59,8 @@ const SelectedTransactionDetails = ({ transaction, showAccount = false, account
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-p-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-p-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
<span className='text-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transact
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<span className='text-p-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,7 @@ export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[],
|
||||
{
|
||||
keywords: ['Federal Bank'],
|
||||
logo: 'Federal_Bank.png',
|
||||
logoDark: 'Federal_Bank-Dark.png',
|
||||
logoDark: 'Federal_Bank-dark.png',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
|
||||
@@ -83,13 +83,10 @@ const StatementDetails = ({ data }: Props) => {
|
||||
|
||||
}
|
||||
|
||||
// `progress` is a percentage (drives the bar); `current`/`total` are actual counts.
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [imported, setImported] = useState({ current: 0, total: 0 })
|
||||
|
||||
useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
|
||||
setProgress(event.progress)
|
||||
setImported({ current: event.current ?? 0, total: event.total ?? 0 })
|
||||
})
|
||||
|
||||
const file_name = data.doc.file.split("/").pop() ?? ""
|
||||
@@ -115,9 +112,7 @@ const StatementDetails = ({ data }: Props) => {
|
||||
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
|
||||
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
|
||||
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
|
||||
{loading ? _("Importing...") : data.final_transactions?.length === 1
|
||||
? _("Import 1 transaction")
|
||||
: _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-start gap-4'>
|
||||
@@ -134,9 +129,7 @@ const StatementDetails = ({ data }: Props) => {
|
||||
</div>
|
||||
|
||||
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
|
||||
<span className='text-sm'>{imported.total === 1
|
||||
? _("Importing 1 transaction")
|
||||
: _("Importing {0} of {1} transactions", [imported.current.toString(), imported.total.toString()])}
|
||||
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])}
|
||||
</span>
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ function ListViewInner<TData>({
|
||||
)}
|
||||
role="columnheader"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate leading-snug">
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
@@ -1,58 +1,13 @@
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useMemo } from "react"
|
||||
import dayjs from "dayjs"
|
||||
import { useCurrentCompany } from "./useCurrentCompany"
|
||||
|
||||
export type FiscalYear = {
|
||||
name: string
|
||||
year_start_date: string
|
||||
year_end_date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The fiscal year containing today, for the currently selected company.
|
||||
*
|
||||
* `company` matters in multi-company setups, where fiscal years can be restricted to
|
||||
* specific companies. `date` matters because without it `get_fiscal_year` returns the newest
|
||||
* fiscal year in the system (they're ordered by start date, descending) - which may be one
|
||||
* created in advance for a year that hasn't started.
|
||||
*/
|
||||
const useFiscalYear = () => {
|
||||
const company = useCurrentCompany()
|
||||
|
||||
const { data, ...rest } = useFrappeGetCall<{ message: FiscalYear | [string, string, string] | false }>(
|
||||
"erpnext.accounts.utils.get_fiscal_year",
|
||||
{
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
company,
|
||||
as_dict: 1,
|
||||
// Return nothing instead of throwing/msgprinting when no fiscal year covers today.
|
||||
raise_on_missing: 0,
|
||||
verbose: 0,
|
||||
},
|
||||
company ? `fiscal_year_${company}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false
|
||||
}
|
||||
)
|
||||
return useFrappeGetCall("erpnext.accounts.utils.get_fiscal_year", undefined, 'fiscal_year', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false
|
||||
})
|
||||
|
||||
// get_fiscal_year returns a dict with as_dict, a (name, start, end) tuple without it, and
|
||||
// false when there's no match - normalise all three.
|
||||
const fiscalYear = useMemo<FiscalYear | undefined>(() => {
|
||||
const message = data?.message
|
||||
if (!message) return undefined
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
const [name, year_start_date, year_end_date] = message
|
||||
return { name, year_start_date, year_end_date }
|
||||
}
|
||||
|
||||
return message
|
||||
}, [data])
|
||||
|
||||
return { fiscalYear, ...rest }
|
||||
}
|
||||
|
||||
export default useFiscalYear
|
||||
export default useFiscalYear
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useLayoutEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* Pins a scrollable list back to the top whenever the search term changes.
|
||||
*
|
||||
* Dropdowns that do their own filtering (`shouldFilter={false}`) swap a long list for a much
|
||||
* shorter one while the scroll container keeps its previous offset - which can leave the
|
||||
* auto-selected first item scrolled out of view.
|
||||
*
|
||||
* Returns a ref to attach to the scroll container (e.g. `CommandList`).
|
||||
*/
|
||||
const useResetScrollOnSearch = (search: string) => {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Layout effect so the reset lands before paint, avoiding a visible jump.
|
||||
useLayoutEffect(() => {
|
||||
listRef.current?.scrollTo({ top: 0 })
|
||||
}, [search])
|
||||
|
||||
return listRef
|
||||
}
|
||||
|
||||
export default useResetScrollOnSearch
|
||||
@@ -1,6 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./styles/scroll-fade.css";
|
||||
|
||||
@font-face {
|
||||
font-family: InterVariable;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import BankAccountBalancePanel from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
||||
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
||||
@@ -9,9 +9,10 @@ import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAtomValue } from "jotai"
|
||||
@@ -24,13 +25,23 @@ const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankR
|
||||
|
||||
const BankReconciliation = () => {
|
||||
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
setHeaderHeight(ref.current.clientHeight)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* The page owns the viewport height and the tabs/lists below fill what's left, so
|
||||
the virtualizers size themselves from layout instead of a measured pixel value. */}
|
||||
<div className="px-2 pt-1 flex-col gap-4 md:flex hidden h-dvh">
|
||||
<div className="flex flex-col gap-4 shrink-0">
|
||||
<div className="flex justify-between shrink-0">
|
||||
<div className="p-4 flex-col gap-4 md:flex hidden">
|
||||
<div ref={ref} className="flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -43,7 +54,7 @@ const BankReconciliation = () => {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
<div className="flex gap-1 items-center">
|
||||
{_("Banking")}
|
||||
{_("Banking")} <Badge theme="violet" variant="subtle">{_("Beta")}</Badge>
|
||||
</div>
|
||||
|
||||
</BreadcrumbPage>
|
||||
@@ -60,8 +71,10 @@ const BankReconciliation = () => {
|
||||
<BankRecDateFilter />
|
||||
</div>
|
||||
</div>
|
||||
<BankPicker />
|
||||
<BankBalance />
|
||||
</div>
|
||||
<BankRecWorkspace />
|
||||
<BankRecTabs remainingHeightAfterTabs={remainingHeightAfterTabs} />
|
||||
<BankTransactionUnreconcileModal />
|
||||
</div>
|
||||
<div className="md:hidden flex h-screen items-center justify-between">
|
||||
@@ -91,53 +104,42 @@ const BankReconciliation = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const BankRecWorkspace = () => {
|
||||
const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: number }) => {
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
return <Tabs defaultValue="Match and Reconcile" className="min-h-0 flex-1 gap-4">
|
||||
{/* Picker + tab strip stack on the left, balance panel beside them - the tab strip
|
||||
fills height the panel needs anyway, so it costs no row of its own. The picker
|
||||
scrolls horizontally (`min-w-0` lets it shrink so its overflow-x engages) while
|
||||
the panel stays put, so the figures never scroll away. */}
|
||||
{/* No gap here: the panel's own `border-s ps-4` supplies the separation, and a gap
|
||||
would leave dead space the picker's edge fade can't reach. */}
|
||||
<div className="flex shrink-0 items-stretch">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3">
|
||||
<BankPicker />
|
||||
{selectedBankAccount && <TabsList>
|
||||
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Reconciliation Statement")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Transactions")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Clearance Summary")}</TabsTrigger>
|
||||
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared")}</TabsTrigger>
|
||||
</TabsList>}
|
||||
</div>
|
||||
{selectedBankAccount && <BankAccountBalancePanel />}
|
||||
</div>
|
||||
if (!selectedBankAccount) {
|
||||
return null
|
||||
}
|
||||
|
||||
{selectedBankAccount && <>
|
||||
<TabsContent value="Match and Reconcile" className="flex min-h-0 flex-col">
|
||||
<MatchAndReconcile />
|
||||
return <Tabs defaultValue="Match and Reconcile">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Bank Reconciliation Statement")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Bank Transactions")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Bank Clearance Summary")}</TabsTrigger>
|
||||
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared Entries")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="Match and Reconcile">
|
||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||
</TabsContent>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement" className="flex min-h-0 flex-col">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions" className="flex min-h-0 flex-col">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary" className="flex min-h-0 flex-col">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries" className="flex min-h-0 flex-col">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
</>}
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ const StatementImportLog = () => {
|
||||
field: "creation",
|
||||
order: "desc"
|
||||
},
|
||||
limit: 20
|
||||
limit: 10
|
||||
}, bankAccount ? undefined : null, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/* Scroll-edge fade mask for horizontal scroll containers (the bank picker strip).
|
||||
Ported from Raven's `scroll-fade-x`; imported by index.css, since Tailwind processes
|
||||
`@utility` in imported files the same as in the entry file.
|
||||
|
||||
The scroll-timeline keyframes reveal each edge's fade only when there IS content to scroll
|
||||
in that direction - no fade on the left edge when scrolled fully left, none on the right at
|
||||
the end. `@property` makes the fade animate smoothly rather than jumping.
|
||||
|
||||
Without scroll-timeline support (Firefox) there is deliberately NO fade at all: the fade
|
||||
vars stay at their 0px initial value and the gradient stops collapse to the edges. A static
|
||||
both-edges fallback was tried in Raven and removed - on a container with nothing to scroll
|
||||
it dimmed the edges anyway, promising content that didn't exist. */
|
||||
|
||||
@property --scroll-fade-l {
|
||||
/* length-percentage, NOT length: the fade size is min(12%, …) - a percentage. A <length>
|
||||
property rejects that value and reverts to initial-value (0px), zeroing the fade. */
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --scroll-fade-r {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@keyframes scroll-fade-reveal-l {
|
||||
from {
|
||||
--scroll-fade-l: 0px;
|
||||
}
|
||||
|
||||
to {
|
||||
--scroll-fade-l: var(--_scroll-fade-size-l);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-fade-reveal-r {
|
||||
from {
|
||||
--scroll-fade-r: var(--_scroll-fade-size-r);
|
||||
}
|
||||
|
||||
to {
|
||||
--scroll-fade-r: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-x {
|
||||
--_scroll-fade-size-l: var(--scroll-fade-l-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
|
||||
--_scroll-fade-size-r: var(--scroll-fade-r-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
|
||||
/* Eased (smoothstep) alpha ramp, sampled finely so it reads as a smooth curve, NOT fading
|
||||
all the way to transparent: the edge floors at 0.25 (content dims, never vanishes), ramping
|
||||
up to a full 1 for the body. The opaque end MUST be 1 or everything would be permanently
|
||||
dimmed. Stops collapse to the edge when the size animates to 0, so the true first/last card
|
||||
is never dimmed at rest. Tune the floor - higher (~0.4) = subtler, lower (~0.1) = stronger. */
|
||||
--scroll-fade-inline: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0.25) 0,
|
||||
rgba(0, 0, 0, 0.282) calc(var(--scroll-fade-l, 0px) * 0.125),
|
||||
rgba(0, 0, 0, 0.367) calc(var(--scroll-fade-l, 0px) * 0.25),
|
||||
rgba(0, 0, 0, 0.487) calc(var(--scroll-fade-l, 0px) * 0.375),
|
||||
rgba(0, 0, 0, 0.625) calc(var(--scroll-fade-l, 0px) * 0.5),
|
||||
rgba(0, 0, 0, 0.763) calc(var(--scroll-fade-l, 0px) * 0.625),
|
||||
rgba(0, 0, 0, 0.883) calc(var(--scroll-fade-l, 0px) * 0.75),
|
||||
rgba(0, 0, 0, 0.968) calc(var(--scroll-fade-l, 0px) * 0.875),
|
||||
rgba(0, 0, 0, 1) var(--scroll-fade-l, 0px),
|
||||
rgba(0, 0, 0, 1) calc(100% - var(--scroll-fade-r, 0px)),
|
||||
rgba(0, 0, 0, 0.968) calc(100% - var(--scroll-fade-r, 0px) * 0.875),
|
||||
rgba(0, 0, 0, 0.883) calc(100% - var(--scroll-fade-r, 0px) * 0.75),
|
||||
rgba(0, 0, 0, 0.763) calc(100% - var(--scroll-fade-r, 0px) * 0.625),
|
||||
rgba(0, 0, 0, 0.625) calc(100% - var(--scroll-fade-r, 0px) * 0.5),
|
||||
rgba(0, 0, 0, 0.487) calc(100% - var(--scroll-fade-r, 0px) * 0.375),
|
||||
rgba(0, 0, 0, 0.367) calc(100% - var(--scroll-fade-r, 0px) * 0.25),
|
||||
rgba(0, 0, 0, 0.282) calc(100% - var(--scroll-fade-r, 0px) * 0.125),
|
||||
rgba(0, 0, 0, 0.25) 100%);
|
||||
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation:
|
||||
scroll-fade-reveal-l 1ms ease-in-out,
|
||||
scroll-fade-reveal-r 1ms ease-in-out;
|
||||
animation-timeline: scroll(self x), scroll(self x);
|
||||
animation-range:
|
||||
0 var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24)),
|
||||
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
13
crowdin.yml
13
crowdin.yml
@@ -1,5 +1,14 @@
|
||||
preserve_hierarchy: true
|
||||
|
||||
files:
|
||||
- source: /erpnext/locale/main.pot
|
||||
translation: /erpnext/locale/%two_letters_code%.po
|
||||
pull_request_title: "fix: sync translations from crowdin"
|
||||
pull_request_labels:
|
||||
- translation
|
||||
- skip-release-notes
|
||||
pull_request_reviewers:
|
||||
- barredterra # change to your GitHub username if you copied this file
|
||||
commit_message: "fix: %language% translations"
|
||||
append_commit_message: false
|
||||
languages_mapping:
|
||||
two_letters_code:
|
||||
pt-BR: pt_BR
|
||||
|
||||
@@ -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.31.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
@@ -177,12 +177,7 @@ def normalize_ctx_input(T: type) -> callable:
|
||||
|
||||
def decorator(func: callable):
|
||||
# conserve annotations for frappe.utils.typing_validations
|
||||
@functools.wraps(
|
||||
func,
|
||||
assigned=(
|
||||
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
|
||||
),
|
||||
)
|
||||
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
|
||||
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
|
||||
if isinstance(ctx, Document):
|
||||
ctx = T(**ctx.as_dict())
|
||||
|
||||
@@ -71,6 +71,4 @@ def get_shipping_address(company, address=None):
|
||||
if address:
|
||||
address_as_dict = address[0]
|
||||
name, address_template = get_address_templates(address_as_dict)
|
||||
return address_as_dict.get("name"), frappe.render_template(
|
||||
address_template, address_as_dict, restrict_globals=True
|
||||
)
|
||||
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"modified": "2026-09-04 12:37:31.673782",
|
||||
"modified": "2025-12-19 12:37:31.673782",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss",
|
||||
@@ -17,6 +17,7 @@
|
||||
"owner": "Administrator",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"roles": [],
|
||||
"show_values_over_chart": 1,
|
||||
"timeseries": 0,
|
||||
"type": "Line",
|
||||
"use_report_chart": 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,6 @@ frappe.ui.form.on("Accounting Dimension", {
|
||||
return {
|
||||
filters: {
|
||||
name: ["not in", invalid_doctypes],
|
||||
istable: 0,
|
||||
issingle: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -60,14 +60,6 @@ class AccountingDimension(Document):
|
||||
msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type)
|
||||
frappe.throw(msg)
|
||||
|
||||
meta = frappe.get_meta(self.document_type)
|
||||
if meta.istable or meta.issingle:
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} cannot be used as an accounting dimension as it is not a standalone document type."
|
||||
).format(frappe.bold(self.document_type))
|
||||
)
|
||||
|
||||
exists = frappe.db.get_value("Accounting Dimension", {"document_type": self.document_type}, ["name"])
|
||||
|
||||
if exists and self.is_new():
|
||||
|
||||
@@ -52,23 +52,6 @@ class TestAccountingDimension(ERPNextTestSuite):
|
||||
self.assertEqual(gle.get("department"), "_Test Department - _TC")
|
||||
self.assertEqual(gle1.get("department"), "_Test Department - _TC")
|
||||
|
||||
def test_child_table_not_allowed_as_dimension(self):
|
||||
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"})
|
||||
self.assertRaises(frappe.ValidationError, dimension.insert)
|
||||
|
||||
def test_single_doctype_not_allowed_as_dimension(self):
|
||||
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Selling Settings"})
|
||||
self.assertRaises(frappe.ValidationError, dimension.insert)
|
||||
|
||||
def test_non_scalar_dimension_value_skipped_in_gl_dict(self):
|
||||
si = create_sales_invoice(do_not_save=1)
|
||||
|
||||
si.department = "_Test Department - _TC"
|
||||
self.assertEqual(si.get_gl_dict({}).get("department"), "_Test Department - _TC")
|
||||
|
||||
si.department = ["_Test Department - _TC"]
|
||||
self.assertNotIn("department", si.get_gl_dict({}))
|
||||
|
||||
def test_mandatory(self):
|
||||
location = frappe.get_doc("Accounting Dimension", "Location")
|
||||
location.dimension_defaults[0].mandatory_for_bs = True
|
||||
|
||||
@@ -94,14 +94,13 @@
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"disable_include_dimensions",
|
||||
"column_break_lvjk",
|
||||
"general_ledger_remarks_length",
|
||||
"receivable_payable_remarks_length",
|
||||
"column_break_lvjk",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
"column_break_ntmi",
|
||||
"receivable_payable_remarks_length",
|
||||
"legacy_section",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"tab_break_dpet",
|
||||
@@ -199,12 +198,10 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "System will use the latest saved Currency Exchange rate on or before the transaction date, however old it is. <br>\nUncheck to ignore rates older than Stale Days and fetch a fresh rate from the exchange rate provider instead.",
|
||||
"fieldname": "allow_stale",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Allow Stale Exchange Rates",
|
||||
"show_description_on_click": 1
|
||||
"label": "Allow Stale Exchange Rates"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -224,8 +221,7 @@
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
"fieldname": "over_billing_allowance",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Over Billing Allowance (%)",
|
||||
"non_negative": 1
|
||||
"label": "Over Billing Allowance (%)"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -281,10 +277,10 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enabling this option prevents the creation of a new Sales Invoice when the customer has an overdue limit set and their outstanding overdue amount exceeds that limit.",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Prevent Sales Invoice when Customer is Overdue"
|
||||
"label": "Restrict Customer Over Billing"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
@@ -478,7 +474,7 @@
|
||||
{
|
||||
"fieldname": "remarks_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "General Ledger Report"
|
||||
"label": "Remarks Column Length"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -552,7 +548,7 @@
|
||||
{
|
||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounts Receivable / Payable Report"
|
||||
"label": "Accounts Receivable / Payable Tuning"
|
||||
},
|
||||
{
|
||||
"fieldname": "legacy_section",
|
||||
@@ -784,12 +780,6 @@
|
||||
"fieldname": "book_stock_expense_gl_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Stock Expense GL Entries"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disable_include_dimensions",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable \"Consider Accounting Dimension\" Filter"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -798,7 +788,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-04 10:08:30.115003",
|
||||
"modified": "2026-07-27 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -72,7 +72,6 @@ class AccountsSettings(Document):
|
||||
default_ageing_range: DF.Data | None
|
||||
delete_linked_ledger_entries: DF.Check
|
||||
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
|
||||
disable_include_dimensions: DF.Check
|
||||
enable_accounting_dimensions: DF.Check
|
||||
enable_common_party_accounting: DF.Check
|
||||
enable_discounts_and_margin: DF.Check
|
||||
|
||||
@@ -916,7 +916,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
|
||||
|
||||
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
|
||||
|
||||
if days is None:
|
||||
if not days:
|
||||
days = 3
|
||||
|
||||
min_date = frappe.utils.add_days(date, -days)
|
||||
@@ -1340,11 +1340,9 @@ def get_pe_matching_query(
|
||||
ref_condition = pe.reference_no == transaction.reference_number
|
||||
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
|
||||
|
||||
amount_field = pe.received_amount_after_tax if account_from_to == "paid_to" else pe.paid_amount_after_tax
|
||||
|
||||
amount_equality = amount_field == transaction.unallocated_amount
|
||||
amount_equality = pe.paid_amount == transaction.unallocated_amount
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else amount_field > 0.0
|
||||
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
|
||||
|
||||
party_condition = (
|
||||
(pe.party_type == transaction.party_type) & (pe.party == transaction.party) & pe.party.isnotnull()
|
||||
@@ -1361,7 +1359,7 @@ def get_pe_matching_query(
|
||||
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
|
||||
ConstantColumn("Payment Entry").as_("doctype"),
|
||||
pe.name,
|
||||
amount_field.as_("paid_amount"),
|
||||
pe.base_paid_amount_after_tax.as_("paid_amount"),
|
||||
pe.reference_no,
|
||||
pe.reference_date,
|
||||
pe.party,
|
||||
|
||||
@@ -8,9 +8,7 @@ from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
get_linked_payments,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
@@ -99,103 +97,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
# assert API output post reconciliation
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
||||
def make_bank_transaction(self, date, deposit=100, withdrawal=0):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": date,
|
||||
"deposit": deposit,
|
||||
"withdrawal": withdrawal,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def get_matching_payment_entries(self, bank_transaction, exact_match=False):
|
||||
document_types = ["payment_entry", "exact_match"] if exact_match else ["payment_entry"]
|
||||
vouchers = get_linked_payments(
|
||||
bank_transaction,
|
||||
document_types,
|
||||
from_date=add_days(today(), -1),
|
||||
to_date=today(),
|
||||
)
|
||||
return [v for v in vouchers if v.get("doctype") == "Payment Entry"]
|
||||
|
||||
def test_get_bank_transactions_excludes_dates_after_to_date(self):
|
||||
self.make_bank_transaction(date=today())
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_deposit_matches_amount_received_in_bank_account(self):
|
||||
# money leaves another bank account and lands here minus a charge, so the two sides differ
|
||||
payment = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Entry",
|
||||
"payment_type": "Internal Transfer",
|
||||
"company": self.company,
|
||||
"posting_date": today(),
|
||||
"paid_from": "_Test Bank - _TC",
|
||||
"paid_to": self.bank,
|
||||
"paid_amount": 3537.64,
|
||||
"received_amount": 3460.52,
|
||||
"reference_no": "TRF-001",
|
||||
"reference_date": today(),
|
||||
}
|
||||
)
|
||||
payment.set_missing_values()
|
||||
payment.set_exchange_rate()
|
||||
payment.set_amounts()
|
||||
payment.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
|
||||
payment.deductions[-1].cost_center = "_Test Cost Center - _TC"
|
||||
payment = payment.save().submit()
|
||||
|
||||
transaction = self.make_bank_transaction(date=today(), deposit=3460.52)
|
||||
|
||||
# the received side is what reached this bank account, so that is what is shown
|
||||
matches = self.get_matching_payment_entries(transaction.name)
|
||||
self.assertEqual([m["name"] for m in matches], [payment.name])
|
||||
self.assertEqual(matches[0]["paid_amount"], 3460.52)
|
||||
|
||||
# and what the exact match compares against
|
||||
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
|
||||
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
|
||||
|
||||
def test_withdrawal_matches_amount_paid_from_bank_account(self):
|
||||
payment = create_payment_entry(
|
||||
company=self.company,
|
||||
payment_type="Pay",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
paid_from=self.bank,
|
||||
paid_to="Creditors - _TC",
|
||||
paid_amount=1250,
|
||||
)
|
||||
payment = payment.save().submit()
|
||||
|
||||
transaction = self.make_bank_transaction(date=today(), deposit=0, withdrawal=1250)
|
||||
|
||||
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
|
||||
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
|
||||
self.assertEqual(exact_matches[0]["paid_amount"], 1250)
|
||||
|
||||
def test_auto_reconcile_message_for_no_matches(self):
|
||||
message, indicator = get_auto_reconcile_message([], [])
|
||||
self.assertEqual(indicator, "blue")
|
||||
self.assertIn("No matches", message)
|
||||
|
||||
def test_auto_reconcile_message_counts_and_pluralizes(self):
|
||||
# reconciled count is reported and the indicator turns green
|
||||
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
|
||||
self.assertEqual(indicator, "green")
|
||||
self.assertIn("2 Transaction(s) Reconciled", message)
|
||||
|
||||
# partially-reconciled label is singular for one, plural for many
|
||||
singular, _ = get_auto_reconcile_message(["p1"], [])
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
@@ -167,10 +167,9 @@ def get_transaction_reference(txn_data: dict) -> str:
|
||||
).strip()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
doc = frappe.get_doc("Bank Statement Import", data_import)
|
||||
doc.check_permission("write")
|
||||
|
||||
_file_doc, content = get_file(mt940_file_path)
|
||||
|
||||
@@ -235,30 +234,26 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission()
|
||||
return bsi.get_preview_from_template(import_file, google_sheets_url)
|
||||
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
|
||||
import_file, google_sheets_url
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_import(data_import):
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission("write")
|
||||
return bsi.start_import()
|
||||
job_id = frappe.get_doc("Bank Statement Import", data_import).start_import()
|
||||
return job_id is not None
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_errored_template(data_import_name):
|
||||
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
data_import.check_permission()
|
||||
data_import.export_errored_rows()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_import_log(data_import_name):
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
bsi.check_permission()
|
||||
return bsi.download_import_log()
|
||||
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
|
||||
|
||||
|
||||
def is_mt940_format(content: str) -> bool:
|
||||
@@ -397,7 +392,6 @@ def get_import_status(docname):
|
||||
import_status = {}
|
||||
|
||||
data_import = frappe.get_doc("Bank Statement Import", docname)
|
||||
data_import.check_permission()
|
||||
import_status["status"] = data_import.status
|
||||
|
||||
logs = frappe.get_all(
|
||||
|
||||
@@ -375,7 +375,8 @@ class BankStatementImportLog(Document):
|
||||
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||
|
||||
final_transactions, table["date_format"], table["amount_format"] = build_table_transactions(table)
|
||||
table["included"] = should_include_table(table, final_transactions)
|
||||
# Tables with no detectable transactions (ads, summaries, headers) start excluded.
|
||||
table["included"] = bool(final_transactions)
|
||||
|
||||
self.pdf_tables = json.dumps(tables)
|
||||
return tables
|
||||
@@ -541,8 +542,6 @@ class BankStatementImportLog(Document):
|
||||
"bank-rec-statement-import-progress",
|
||||
{
|
||||
"progress": round(progress / total_transactions * 100),
|
||||
"current": progress,
|
||||
"total": total_transactions,
|
||||
},
|
||||
doctype="Bank Statement Import Log",
|
||||
docname=self.name,
|
||||
@@ -552,7 +551,6 @@ class BankStatementImportLog(Document):
|
||||
"bank-rec-statement-import-progress",
|
||||
{
|
||||
"progress": 100,
|
||||
"current": total_transactions,
|
||||
"total": total_transactions,
|
||||
},
|
||||
doctype="Bank Statement Import Log",
|
||||
@@ -823,15 +821,6 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
"""Pure version of the final-transaction builder (date normalized, amount split)."""
|
||||
final_transactions = []
|
||||
|
||||
# Which marker does this statement actually write? A statement that only ever says "Cr"
|
||||
# is marking the credits as its exceptions, so an unmarked row is a withdrawal; one that
|
||||
# only ever says "Dr" means the opposite. With both markers present an unmarked row is
|
||||
# genuinely undetermined, so it stays a withdrawal.
|
||||
unmarked_is_deposit = False
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
markers = {get_amount_cr_dr_marker(row.get("amount")) for row in transaction_rows}
|
||||
unmarked_is_deposit = markers - {None} == {"dr"}
|
||||
|
||||
def parse_amount(transaction_row: dict):
|
||||
if amount_format == "Separate columns for withdrawal and deposit":
|
||||
return get_float_amount(transaction_row.get("withdrawal")), get_float_amount(
|
||||
@@ -840,43 +829,44 @@ 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")
|
||||
marker = get_amount_cr_dr_marker(amount)
|
||||
# The marker carries the direction, so the amount's own sign is ignored.
|
||||
signed_amount = get_float_amount(amount) or 0
|
||||
|
||||
if marker:
|
||||
return (0, abs(signed_amount)) if marker == "cr" else (abs(signed_amount), 0)
|
||||
# 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:
|
||||
return float_amount, 0
|
||||
|
||||
# An unmarked row takes the opposite direction to the marker this statement
|
||||
# uses. A negative amount reverses that again (a refund).
|
||||
is_deposit = unmarked_is_deposit
|
||||
if signed_amount < 0:
|
||||
is_deposit = not is_deposit
|
||||
|
||||
return (0, abs(signed_amount)) if is_deposit else (abs(signed_amount), 0)
|
||||
|
||||
# `or 0` below: get_float_amount returns None for an unparseable cell, and a blank
|
||||
# transaction-type cell comes through as None. Both used to raise.
|
||||
if amount_format == "Amount column has positive/negative values":
|
||||
amount = get_float_amount(transaction_row.get("amount", "0")) or 0
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if amount > 0:
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
|
||||
transaction_type = str(transaction_row.get("debit_credit") or "").strip().lower()
|
||||
amount = abs(get_float_amount(transaction_row.get("amount", "0")) or 0)
|
||||
|
||||
if amount_format == 'Transaction type column has "CR"/"DR" values':
|
||||
# "credit" contains "cr". "debit" does not contain "dr", so it correctly falls
|
||||
# through to the withdrawal side.
|
||||
return (0, amount) if "cr" in transaction_type else (amount, 0)
|
||||
transaction_type = transaction_row.get("debit_credit")
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if "cr" in transaction_type.lower():
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
|
||||
if amount_format == 'Transaction type column has "C"/"D" values':
|
||||
return (0, amount) if transaction_type == "c" else (amount, 0)
|
||||
transaction_type = transaction_row.get("debit_credit")
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if transaction_type.lower().strip() == "c":
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
|
||||
if amount_format == 'Transaction type column has "Deposit"/"Withdrawal" values':
|
||||
return (0, amount) if "deposit" in transaction_type else (amount, 0)
|
||||
transaction_type = transaction_row.get("debit_credit")
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if "deposit" in transaction_type.lower():
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
|
||||
return 0, 0
|
||||
|
||||
@@ -920,26 +910,6 @@ def build_table_transactions(table: dict):
|
||||
return final_transactions, date_format, amount_format
|
||||
|
||||
|
||||
def should_include_table(table: dict, final_transactions: list) -> bool:
|
||||
"""
|
||||
Whether a freshly extracted PDF table should START as included - only the default state
|
||||
of the checkbox, which the user can change afterwards.
|
||||
|
||||
It must have yielded transactions, and it must have a Description column mapped. A
|
||||
transaction table always carries a narration; the summary boxes printed around it -
|
||||
payment due, credit limit, reward points - are dates and figures only. Otherwise the
|
||||
HDFC credit-card "Payment Due Date / Total Dues / Minimum Amount Due" box parses as one
|
||||
transaction and imports a phantom row.
|
||||
|
||||
A description is NOT needed to import (it is not mandatory on Bank Transaction), so a
|
||||
bank that omits narration still works - its table just starts unticked.
|
||||
"""
|
||||
if not final_transactions:
|
||||
return False
|
||||
|
||||
return any(column.get("maps_to") == "Description" for column in table.get("column_mapping", []))
|
||||
|
||||
|
||||
def _clean_cell(cell) -> str:
|
||||
"""Normalize a pdfplumber cell: None -> '', collapse wrapped newlines, strip."""
|
||||
if cell is None:
|
||||
@@ -1085,43 +1055,6 @@ def get_float_amount(amount):
|
||||
return amount
|
||||
|
||||
|
||||
# A "CR"/"DR" marker on the amount itself, at either end: "2,378.00Cr", "Cr 100",
|
||||
# "INR 50.90 Cr.", "DR 1,234.50".
|
||||
# `(?![a-zA-Z])` rather than `\b` on the leading form: there is no word boundary between
|
||||
# the "r" of "Cr100" and the digit, but there IS one inside "CREDIT" and "DRAFT".
|
||||
AMOUNT_CR_DR_PATTERN = re.compile(r"^\s*(cr|dr)(?![a-zA-Z])\.?|(?:^|[\s\d.)])(cr|dr)\b\.?\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def get_amount_cr_dr_marker(amount) -> str | None:
|
||||
"""
|
||||
Return "cr" or "dr" if the amount cell carries a direction marker of its own, else None.
|
||||
|
||||
What is left after removing the marker has to look like an amount - it must hold a digit
|
||||
and at most a short currency token - so that text which merely starts or ends with the
|
||||
letters is not read as a marker. That guard is what separates "Cr 100" from a
|
||||
description that bled into the amount column, like "Dr Smith Clinic 500".
|
||||
"""
|
||||
if not isinstance(amount, str):
|
||||
return None
|
||||
|
||||
match = AMOUNT_CR_DR_PATTERN.search(amount)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Only the marker itself is removed - the surrounding character the pattern needed to
|
||||
# anchor on (a digit, say) stays part of the remainder.
|
||||
group = 1 if match.group(1) else 2
|
||||
start, end = match.span(group)
|
||||
remainder = amount[:start] + amount[end:]
|
||||
|
||||
if not any(char.isdigit() for char in remainder):
|
||||
return None
|
||||
if sum(char.isalpha() for char in remainder) > 3:
|
||||
return None
|
||||
|
||||
return match.group(group).lower()
|
||||
|
||||
|
||||
def get_file_properties(transactions: list):
|
||||
"""
|
||||
From the transaction rows, try to figure out the following:
|
||||
@@ -1142,8 +1075,6 @@ def get_file_properties(transactions: list):
|
||||
'Transaction type column has "C"/"D" values': 0,
|
||||
}
|
||||
|
||||
amount_column_has_cr_dr = False
|
||||
|
||||
for transaction in transactions:
|
||||
date_format = transaction.get("date_format")
|
||||
|
||||
@@ -1161,40 +1092,33 @@ def get_file_properties(transactions: list):
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
debit_credit = str(transaction.get("debit_credit") or "").strip().lower()
|
||||
|
||||
# One vote per row, most specific signal first. Order matters: "withdrawal" contains
|
||||
# "dr", so it must be matched before the loose cr/dr check or a Deposit/Withdrawal
|
||||
# column reads as CR/DR. "debit" needs listing because, unlike "credit", it does not
|
||||
# contain "dr". The final else means every row votes, even an unrecognised type.
|
||||
if get_amount_cr_dr_marker(amount):
|
||||
amount_column_has_cr_dr = True
|
||||
if isinstance(amount, str) and ("cr" in amount.lower() or "dr" in amount.lower()):
|
||||
amount_format_frequency['Amount column has "CR"/"DR" values'] += 1
|
||||
elif "deposit" in debit_credit or "withdrawal" in debit_credit:
|
||||
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
|
||||
elif debit_credit in ("c", "d"):
|
||||
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
|
||||
elif any(token in debit_credit for token in ("cr", "dr", "debit")):
|
||||
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
|
||||
|
||||
# Check if there's a debit_credit column containing "cr"/"dr"
|
||||
if transaction.get("debit_credit", None):
|
||||
if (
|
||||
"cr" in transaction.get("debit_credit", "").lower()
|
||||
or "dr" in transaction.get("debit_credit", "").lower()
|
||||
):
|
||||
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
|
||||
elif (
|
||||
"deposit" in transaction.get("debit_credit", "").lower()
|
||||
or "withdrawal" in transaction.get("debit_credit", "").lower()
|
||||
):
|
||||
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
|
||||
elif (transaction.get("debit_credit", "").lower().strip() == "c") or (
|
||||
transaction.get("debit_credit", "").lower().strip() == "d"
|
||||
):
|
||||
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
|
||||
|
||||
# Else assume that the amount is expressed as positive/negative value
|
||||
else:
|
||||
# Nothing said which direction this is, so assume the amount carries the sign.
|
||||
amount_format_frequency["Amount column has positive/negative values"] += 1
|
||||
|
||||
most_common_date_format = max(date_format_frequency, key=date_format_frequency.get)
|
||||
most_common_amount_format = max(amount_format_frequency, key=amount_format_frequency.get)
|
||||
|
||||
# With no votes at all (no rows, or every amount blank) max() would return whichever key
|
||||
# happens to be first in the dict. Say what we mean instead.
|
||||
if not amount_format_frequency[most_common_amount_format]:
|
||||
most_common_amount_format = "Amount column has positive/negative values"
|
||||
|
||||
# A CR/DR amount column is proved by a single marker, not by a majority: both formats
|
||||
# describe the same column, and an unmarked row is only the default direction, not
|
||||
# evidence against the notation. Statements mark just the exceptions - one HDFC
|
||||
# credit-card page has 18 rows and a single "50.90Cr".
|
||||
if amount_column_has_cr_dr and most_common_amount_format == "Amount column has positive/negative values":
|
||||
most_common_amount_format = 'Amount column has "CR"/"DR" values'
|
||||
|
||||
return most_common_date_format, most_common_amount_format
|
||||
|
||||
|
||||
|
||||
@@ -11,14 +11,12 @@ from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_lo
|
||||
detect_column_mapping,
|
||||
detect_header_row,
|
||||
extract_pdf_tables,
|
||||
get_amount_cr_dr_marker,
|
||||
get_float_amount,
|
||||
get_statement_details,
|
||||
guess_column_mapping_by_content,
|
||||
reextract_pdf_table,
|
||||
set_header_index,
|
||||
set_pdf_table_header,
|
||||
should_include_table,
|
||||
update_column_mapping,
|
||||
update_pdf_tables,
|
||||
)
|
||||
@@ -126,184 +124,6 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIsNone(get_float_amount("ABCD"))
|
||||
self.assertIsNone(get_float_amount("****"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Amount format detection
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_amount_cr_dr_marker(self):
|
||||
"""The marker is read at either end of the cell, but only next to the amount."""
|
||||
for amount in ("2,378.00Cr", "50.90 CR", "INR 50.90 Cr.", "1000cr", "5cr", "(100) Cr"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
|
||||
|
||||
for amount in ("2,378.00Dr", "50.90 DR", "1000dr", "-100 Dr"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
|
||||
|
||||
# Some banks put the marker in front of the digits instead.
|
||||
for amount in ("Cr 100", "Cr100", "CR INR 100", "cr 0.00"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
|
||||
|
||||
for amount in ("Dr 100", "Dr100", "Dr. 1,234.50"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
|
||||
|
||||
for amount in ("100.00", "-2,000.00", "INR 25,236.00", "", None, 100.0):
|
||||
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
|
||||
|
||||
# Text that merely starts or ends with the letters must not be read as a marker, or
|
||||
# a description that bled into the amount column would reclassify the statement.
|
||||
for amount in (
|
||||
"CREDIT CARD PAYMENT 500",
|
||||
"DRAFT 100",
|
||||
"Dr Smith Clinic 500",
|
||||
"DR AMBEDKAR ROAD BRANCH 500",
|
||||
"500 CRC",
|
||||
"Cheque Dr",
|
||||
"Cr",
|
||||
):
|
||||
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
|
||||
|
||||
def test_sparsely_marked_cr_dr_amount_column(self):
|
||||
"""One marker is enough to prove a CR/DR amount column - it is not a majority vote.
|
||||
|
||||
A real HDFC credit-card page carries 18 rows and a single "50.90Cr": the unmarked
|
||||
rows are ordinary purchases, and only the exceptions are marked. A frequency vote
|
||||
therefore picked "positive/negative" 17-1 and imported that lone credit as a debit.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Transaction Description", "Amount (in Rs.)"],
|
||||
["21/07/2026", "ITC MAURYA NEW DELHI", "2,495.00"],
|
||||
["22/07/2026", "ZOMATO LIMITED Gurugram", "1,288.68"],
|
||||
["23/07/2026", "SWIGGY Bangalore", "532.00"],
|
||||
["26/07/2026", "SWIGGY Bangalore", "1,043.00"],
|
||||
["27/07/2026", "PETRO SURCHARGE WAIVER", "50.90Cr"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
# Only "Cr" appears, so it is the marked exception and unmarked rows are debits.
|
||||
self.assertEqual(doc.total_credits, 50.90)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
self.assertEqual(doc.total_debits, 5358.68)
|
||||
self.assertEqual(doc.total_debit_transactions, 4)
|
||||
|
||||
def test_dr_only_statement_treats_unmarked_rows_as_deposits(self):
|
||||
"""The mirror image of a Cr-only statement: only withdrawals are marked.
|
||||
|
||||
The unmarked default cannot be hardcoded to the debit, because which side gets
|
||||
marked varies by bank. It is derived from the markers the statement actually uses -
|
||||
here only "Dr" appears, so "Dr" is the exception and everything unmarked is a
|
||||
deposit.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Amount"],
|
||||
["01/04/2026", "ATM WITHDRAWAL", "2,000.00Dr"],
|
||||
["03/04/2026", "SALARY", "20,000.00"],
|
||||
["05/04/2026", "INTEREST", "150.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
self.assertEqual(doc.total_debits, 2000.0)
|
||||
self.assertEqual(doc.total_debit_transactions, 1)
|
||||
self.assertEqual(doc.total_credits, 20150.0)
|
||||
self.assertEqual(doc.total_credit_transactions, 2)
|
||||
|
||||
def test_leading_cr_dr_markers(self):
|
||||
"""Some banks print the marker in front of the amount."""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Amount"],
|
||||
["01/04/2026", "ATM WITHDRAWAL", "Dr 2,000.00"],
|
||||
["03/04/2026", "SALARY", "Cr 20,000.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
self.assertEqual(doc.total_debits, 2000.0)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
|
||||
def test_partially_marked_cr_dr_amount_column(self):
|
||||
"""A CR/DR amount column stays CR/DR even when some rows carry no marker.
|
||||
|
||||
Every unmarked row used to also vote for "positive/negative", so an ordinary
|
||||
statement with a few unmarked rows was detected as positive/negative and a
|
||||
"2000.00Dr" was then imported as a deposit.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Amount", "Balance"],
|
||||
["01/04/2026", "OPENING FEE", "100.00", "9,900.00"],
|
||||
["03/04/2026", "SALARY", "20000.00Cr", "29,900.00"],
|
||||
["05/04/2026", "ATM WDL", "2000.00Dr", "27,900.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
# Both markers appear, so an unmarked row is undetermined and stays a debit.
|
||||
self.assertEqual(doc.total_debits, 2100.0)
|
||||
self.assertEqual(doc.total_debit_transactions, 2)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
|
||||
def test_deposit_withdrawal_type_column(self):
|
||||
"""The word Withdrawal contains "dr", so a loose CR/DR check claims this column first.
|
||||
|
||||
It then reads "Deposit" (which has no "cr" in it) as a withdrawal, flipping the
|
||||
direction of every credit in the statement.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Transaction Type", "Amount"],
|
||||
["01/04/2026", "ATM WDL", "Withdrawal", "2,000.00"],
|
||||
["03/04/2026", "SALARY", "Deposit", "20,000.00"],
|
||||
["05/04/2026", "ATM WDL", "Withdrawal", "500.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
doc.detected_amount_format, 'Transaction type column has "Deposit"/"Withdrawal" values'
|
||||
)
|
||||
self.assertEqual(doc.total_debits, 2500.0)
|
||||
self.assertEqual(doc.total_debit_transactions, 2)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
|
||||
def test_unrecognised_type_column_falls_back_to_signed_amount(self):
|
||||
"""An unrecognised transaction type must not stop the amount being read.
|
||||
|
||||
No tally was incremented for these rows, so max() returned the first key -
|
||||
"Separate columns for withdrawal and deposit" - and, with no such columns in the
|
||||
file, every amount came through as None.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Transaction Type", "Amount"],
|
||||
["01/04/2026", "ATM WDL", "NEFT", "-2,000.00"],
|
||||
["03/04/2026", "SALARY", "IMPS", "20,000.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, "Amount column has positive/negative values")
|
||||
self.assertEqual(doc.total_debits, 2000.0)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
|
||||
def test_blank_transaction_type_cell(self):
|
||||
"""A blank type cell used to raise - `None.lower()` - instead of parsing the row."""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Transaction Type", "Amount"],
|
||||
["01/04/2026", "ATM WDL", "Dr", "2,000.00"],
|
||||
["03/04/2026", "SALARY", "Cr", "20,000.00"],
|
||||
["05/04/2026", "UNKNOWN", None, "500.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Transaction type column has "CR"/"DR" values')
|
||||
# The unmarked row has no direction of its own, so it counts as a withdrawal.
|
||||
self.assertEqual(doc.total_debits, 2500.0)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PDF statement import
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -339,8 +159,7 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
else:
|
||||
table["header_index"] = None
|
||||
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||
final_transactions, _df, _af = build_table_transactions(table)
|
||||
table["included"] = should_include_table(table, final_transactions)
|
||||
table["included"] = True
|
||||
return table
|
||||
|
||||
def test_pdf_multi_page_kept_separate_and_unioned(self):
|
||||
@@ -378,74 +197,6 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
final, _df, _af = build_table_transactions(ad_table)
|
||||
self.assertEqual(final, [])
|
||||
|
||||
def test_pdf_summary_box_not_auto_included(self):
|
||||
"""A summary box that happens to parse as one transaction must not start included.
|
||||
|
||||
The "Payment Due Date / Total Dues / Minimum Amount Due" block on an HDFC
|
||||
credit-card statement has a date column and a figures column, so it yields a single
|
||||
transaction - the due date and the minimum amount - and used to import as a phantom
|
||||
row. What it does not have, and a real transaction table always does, is a narration.
|
||||
"""
|
||||
summary_box = {
|
||||
"header_index": 1,
|
||||
"rows": [
|
||||
["Statement Date:17/08/2025", "Card No: 4341 55XX XXXX 2754", ""],
|
||||
["Payment Due Date", "Total Dues", "Minimum Amount Due"],
|
||||
["06/09/2025", "73,200.00", "3,660.00"],
|
||||
["Credit Limit", "Available Credit Limit", "Available Cash Limit"],
|
||||
["", "32,800", ""],
|
||||
],
|
||||
"column_mapping": [
|
||||
{"index": 0, "header_text": "Payment Due Date", "variable": "a", "maps_to": "Date"},
|
||||
{"index": 1, "header_text": "Total Dues", "variable": "b", "maps_to": "Do not import"},
|
||||
{"index": 2, "header_text": "Minimum Amount Due", "variable": "c", "maps_to": "Amount"},
|
||||
],
|
||||
}
|
||||
|
||||
final, _df, _af = build_table_transactions(summary_box)
|
||||
# It really does parse as a transaction - that is why the previous check missed it.
|
||||
self.assertEqual(len(final), 1)
|
||||
self.assertFalse(should_include_table(summary_box, final))
|
||||
|
||||
# The transaction table beside it, which does carry a narration, still starts included.
|
||||
transactions = self._auto_map(
|
||||
{
|
||||
"rows": [
|
||||
["Date", "Transaction Description", "Amount (in Rs.)"],
|
||||
["21/07/2025", "ITC MAURYA NEW DELHI", "2,495.00"],
|
||||
["27/07/2025", "PETRO SURCHARGE WAIVER", "50.90Cr"],
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertTrue(transactions["included"])
|
||||
|
||||
def test_pdf_table_without_description_still_importable(self):
|
||||
"""No narration column means "starts unticked", NOT "cannot be imported".
|
||||
|
||||
`description` is not mandatory on Bank Transaction, so a bank that omits narration
|
||||
must still import once the user ticks the table.
|
||||
"""
|
||||
table = {
|
||||
"header_index": 0,
|
||||
"rows": [
|
||||
["Date", "Amount", "Balance"],
|
||||
["01/04/2025", "500.00", "9,500.00"],
|
||||
["03/04/2025", "20000.00", "29,500.00"],
|
||||
],
|
||||
"column_mapping": [
|
||||
{"index": 0, "header_text": "Date", "variable": "a", "maps_to": "Date"},
|
||||
{"index": 1, "header_text": "Amount", "variable": "b", "maps_to": "Amount"},
|
||||
{"index": 2, "header_text": "Balance", "variable": "c", "maps_to": "Balance"},
|
||||
],
|
||||
}
|
||||
|
||||
final, _df, _af = build_table_transactions(table)
|
||||
self.assertFalse(should_include_table(table, final))
|
||||
|
||||
# The transactions themselves are intact and importable.
|
||||
self.assertEqual(len(final), 2)
|
||||
self.assertEqual([t["date"] for t in final], ["2025-04-01", "2025-04-03"])
|
||||
|
||||
def test_headerless_content_mapping(self):
|
||||
"""Without a header row, columns are guessed from their contents."""
|
||||
rows = [
|
||||
|
||||
@@ -110,6 +110,18 @@ frappe.ui.form.on("Chart of Accounts Importer", {
|
||||
args: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message === false) {
|
||||
frm.set_value("company", "");
|
||||
frappe.throw(
|
||||
__(
|
||||
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
frm.trigger("refresh");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,13 +70,7 @@ def validate_company(company):
|
||||
frappe.throw(msg, title=_("Wrong Company"))
|
||||
|
||||
if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
|
||||
)
|
||||
)
|
||||
|
||||
validate_user_perms(company)
|
||||
return False
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -85,22 +79,16 @@ def import_coa(file_name, company):
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
file_doc, extension = get_file(file_name)
|
||||
validate_accounts(file_doc, extension)
|
||||
|
||||
if extension == "csv":
|
||||
data = generate_data_from_csv(file_doc)
|
||||
else:
|
||||
data = generate_data_from_excel(file_doc, extension)
|
||||
|
||||
validate_columns(data)
|
||||
|
||||
validate_company(company)
|
||||
|
||||
unset_existing_data(company)
|
||||
|
||||
frappe.local.flags.ignore_root_company_validation = True
|
||||
forest = build_forest(data)
|
||||
create_charts(company, custom_chart=forest, from_coa_importer=True)
|
||||
@@ -463,6 +451,7 @@ def get_mandatory_account_types():
|
||||
|
||||
def unset_existing_data(company):
|
||||
# remove accounts data from company
|
||||
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
@@ -472,30 +461,13 @@ def unset_existing_data(company):
|
||||
# remove accounts data from various doctypes
|
||||
for doctype in [
|
||||
"Account",
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
"Party Account",
|
||||
"Mode of Payment Account",
|
||||
"Tax Withholding Account",
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}).run()
|
||||
|
||||
|
||||
def validate_user_perms(company):
|
||||
# User Permission Check for Account Deletion
|
||||
company_accounts_count = frappe.get_query(
|
||||
"Account", fields=[{"COUNT": "name"}], filters={"company": company}
|
||||
).run()[0][0]
|
||||
company_accounts_user_has_access_to = frappe.get_query(
|
||||
"Account", fields=[{"COUNT": "name"}], filters={"company": company}, ignore_permissions=False
|
||||
).run()[0][0]
|
||||
|
||||
if company_accounts_count != company_accounts_user_has_access_to:
|
||||
frappe.throw(
|
||||
_("Accounts cannot be removed, as user doesn't have access to all the accounts of {0}").format(
|
||||
frappe.bold(company)
|
||||
)
|
||||
)
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -234,10 +234,8 @@ frappe.ui.form.on("Dunning", {
|
||||
dn: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
}
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -275,7 +275,6 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
.join(overdue_payment)
|
||||
.on(overdue_payment.parent == dunning.name)
|
||||
.select(dunning.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(dunning.status == state)
|
||||
& (dunning.docstatus != 2)
|
||||
|
||||
@@ -122,41 +122,6 @@ class TestDunning(ERPNextTestSuite):
|
||||
self.assertEqual(sales_invoice.status, "Overdue")
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
|
||||
"""
|
||||
When an invoice has more than one overdue installment, its Dunning holds one
|
||||
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
|
||||
must resolve the Dunning without raising a TimestampMismatchError caused by the
|
||||
same Dunning being loaded and saved more than once.
|
||||
"""
|
||||
create_payment_terms_template_for_dunning()
|
||||
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
|
||||
sales_invoice = create_sales_invoice_against_cost_center(
|
||||
posting_date=add_days(today(), -15),
|
||||
qty=1,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
|
||||
sales_invoice.submit()
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
|
||||
# Two overdue installments -> two overdue payment rows for the same invoice.
|
||||
self.assertEqual(len(dunning.overdue_payments), 2)
|
||||
dunning.submit()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
|
||||
pe.reference_no, pe.reference_date = "3", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
sales_invoice.reload()
|
||||
dunning.reload()
|
||||
self.assertEqual(sales_invoice.outstanding_amount, 0)
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_resolution_from_credit_note(self):
|
||||
"""
|
||||
Test that dunning is resolved when a credit note is issued against the original invoice.
|
||||
|
||||
@@ -31,7 +31,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
get_columns,
|
||||
@@ -479,10 +478,7 @@ class DataCollector:
|
||||
if company:
|
||||
query = query.where(account.company == company)
|
||||
|
||||
# filters are optional: no filter means all (enabled, non-group) accounts of the company.
|
||||
# invalid filters can't reach here — build_conditions raises on them (raise_on_invalid).
|
||||
conditions = filter_parser.build_conditions(account_rows, account, raise_on_invalid=True)
|
||||
if conditions is not None:
|
||||
if conditions := filter_parser.build_conditions(account_rows, account):
|
||||
query = query.where(conditions)
|
||||
|
||||
return query.run(pluck=True)
|
||||
@@ -794,20 +790,17 @@ class FilterExpressionParser:
|
||||
def __init__(self):
|
||||
self.validator = AccountFilterValidator()
|
||||
|
||||
def build_conditions(self, report_rows, table, raise_on_invalid=False):
|
||||
def build_conditions(self, report_rows, table):
|
||||
conditions = []
|
||||
for row in report_rows or []:
|
||||
condition = self.build_condition(row, table, raise_on_invalid=raise_on_invalid)
|
||||
condition = self.build_condition(row, table)
|
||||
if condition is not None:
|
||||
conditions.append(condition)
|
||||
|
||||
if not conditions:
|
||||
return None
|
||||
|
||||
# ensure brackets in or condition
|
||||
return reduce(lambda a, b: (a) | (b), conditions)
|
||||
|
||||
def build_condition(self, report_row, table, raise_on_invalid=False):
|
||||
def build_condition(self, report_row, table):
|
||||
"""
|
||||
Build SQL condition directly from filter formula.
|
||||
|
||||
@@ -837,11 +830,9 @@ class FilterExpressionParser:
|
||||
if not filter_formula:
|
||||
return None
|
||||
|
||||
errors = self.validator.validate_filter(report_row)
|
||||
errors = self.validator.validate(report_row)
|
||||
if not errors.is_valid:
|
||||
error_messages = [str(issue) for issue in errors.issues]
|
||||
if raise_on_invalid:
|
||||
frappe.throw("<br><br>".join(error_messages), title=_("Invalid Filter"))
|
||||
frappe.log_error(f"Filter validation errors found:\n{'<br><br>'.join(error_messages)}")
|
||||
return None
|
||||
|
||||
@@ -1031,11 +1022,7 @@ class FormulaFieldUpdater:
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_filtered_accounts(company: str, account_rows: str | list):
|
||||
if not company:
|
||||
frappe.throw(_("Company is required"), title=_("Missing Company"))
|
||||
|
||||
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
|
||||
frappe.has_permission("Company", doc=company, throw=True)
|
||||
|
||||
if isinstance(account_rows, str):
|
||||
account_rows = json.loads(account_rows, object_hook=frappe._dict)
|
||||
@@ -1177,12 +1164,10 @@ class RowProcessor:
|
||||
|
||||
def _process_api_row(self, row) -> RowData:
|
||||
api_path = row.calculation_formula
|
||||
|
||||
method = get_valid_api_method(api_path)
|
||||
# TODO
|
||||
|
||||
try:
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
|
||||
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
|
||||
if row.reverse_sign:
|
||||
values = [-1 * v for v in values]
|
||||
|
||||
@@ -163,7 +163,7 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
fieldname: "company",
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
label: __("Company"),
|
||||
label: "Company",
|
||||
reqd: 1,
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
onchange: () => {
|
||||
@@ -176,7 +176,7 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
fieldname: "view_type",
|
||||
fieldtype: "Select",
|
||||
options: ["Missing Accounts", "Filtered Accounts"],
|
||||
label: __("View"),
|
||||
label: "View",
|
||||
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
|
||||
reqd: 1,
|
||||
onchange: () => {
|
||||
@@ -192,10 +192,10 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
{
|
||||
fieldname: "tip",
|
||||
fieldtype: "HTML",
|
||||
label: __("Tip"),
|
||||
label: "Tip",
|
||||
options: `
|
||||
<div class="alert alert-success" role="alert">
|
||||
${__("Tip: Select report lines to view their accounts")}
|
||||
Tip: Select report lines to view their accounts
|
||||
</div>
|
||||
`,
|
||||
depends_on: has_selection ? "eval: false" : "eval: true",
|
||||
@@ -203,7 +203,7 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
{
|
||||
fieldname: "tree_area",
|
||||
fieldtype: "HTML",
|
||||
label: __("Chart of Accounts"),
|
||||
label: "Chart of Accounts",
|
||||
read_only: 1,
|
||||
depends_on: "eval: doc.company",
|
||||
},
|
||||
@@ -288,14 +288,14 @@ function update_formula_label(frm, data_source) {
|
||||
if (!field) return;
|
||||
|
||||
const labels = {
|
||||
"Account Data": __("Account Filter"),
|
||||
"Custom API": __("API Method Path"),
|
||||
"Account Data": "Account Filter",
|
||||
"Custom API": "API Method Path",
|
||||
};
|
||||
|
||||
grid.update_docfield_property(
|
||||
"calculation_formula",
|
||||
"label",
|
||||
labels[data_source] || __("Calculation Formula")
|
||||
labels[data_source] || "Calculation Formula"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ function update_formula_description(frm, data_source) {
|
||||
description_html = `
|
||||
<div ${container_style}>
|
||||
<h5 ${title_style}>Custom API Setup</h5>
|
||||
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
|
||||
<p ${text_style}>Path to your custom method that returns financial data.</p>
|
||||
|
||||
<h6 ${subtitle_style}>Format:</h6>
|
||||
<ul ${list_style}>
|
||||
@@ -380,8 +380,7 @@ function update_formula_description(frm, data_source) {
|
||||
|
||||
<h6 ${subtitle_style}>Method Signature:</h6>
|
||||
<div ${code_style}>
|
||||
<!-- is used for line breaks since frappe.render replaces newlines with spaces -->
|
||||
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"]) def get_custom_data(filters, periods, row): # filters: dict — report filters (company, period, etc.) # periods: list[dict] — period definitions # row: dict — the current report row return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br> # filters: dict — report filters (company, period, etc.) <br> # periods: list[dict] — period definitions <br> # row: dict — the current report row <br><br> return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
</div>
|
||||
|
||||
<h6 ${subtitle_style}>Return Format:</h6>
|
||||
|
||||
@@ -10,41 +10,18 @@ from enum import Enum
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import frappe
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe import _
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.database.query import SQLFunctionParser
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
|
||||
method = frappe.get_attr(api_path)
|
||||
is_whitelisted(method)
|
||||
|
||||
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
|
||||
frappe.throw(
|
||||
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
|
||||
frappe.PermissionError,
|
||||
title=_("Method Not Allowed"),
|
||||
)
|
||||
|
||||
return method
|
||||
|
||||
|
||||
def get_formula_field_label(data_source: str) -> str:
|
||||
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
|
||||
labels = {
|
||||
"Account Data": _("Account Filter"),
|
||||
"Custom API": _("API Method Path"),
|
||||
}
|
||||
return labels.get(data_source, _("Calculation Formula"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""Represents a single validation issue"""
|
||||
|
||||
message: str
|
||||
row_idx: int | None = None
|
||||
field: str | None = None
|
||||
details: dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -52,9 +29,10 @@ class ValidationIssue:
|
||||
self.details = {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.row_idx:
|
||||
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
|
||||
return self.message
|
||||
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
|
||||
field_info = f"[{self.field}] " if self.field else ""
|
||||
message = f"{prefix}{field_info}{self.message}"
|
||||
return _(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -156,9 +134,7 @@ class TemplateStructureValidator(Validator):
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_(
|
||||
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
|
||||
).format(ref_code),
|
||||
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -167,7 +143,7 @@ class TemplateStructureValidator(Validator):
|
||||
if ref_code in used_codes:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Duplicate line reference: '{0}'").format(ref_code),
|
||||
message=f"Duplicate line reference: '{ref_code}'",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -183,7 +159,7 @@ class TemplateStructureValidator(Validator):
|
||||
if row.data_source == "Account Data" and not row.balance_type:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Balance Type is required for Account Data"),
|
||||
message="Balance Type is required for Account Data",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -193,11 +169,7 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required when {1} is {2}").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
row.meta.get_translated_label("data_source"),
|
||||
_(row.data_source),
|
||||
),
|
||||
message=f"Formula is required for {row.data_source}",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -224,14 +196,7 @@ class DependencyValidator(Validator):
|
||||
|
||||
for row in self.template.rows:
|
||||
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
|
||||
# skip self-reference, `CalculationFormulaValidator` already reports it
|
||||
deps = [
|
||||
code
|
||||
for code in extract_reference_codes_from_formula(
|
||||
row.calculation_formula, list(available_codes)
|
||||
)
|
||||
if code != row.reference_code
|
||||
]
|
||||
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
|
||||
if deps:
|
||||
graph[row.reference_code] = deps
|
||||
|
||||
@@ -261,7 +226,7 @@ class DependencyValidator(Validator):
|
||||
cycle = [*path[cycle_start:], node]
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Circular dependency detected: {0}").format(" → ".join(cycle)),
|
||||
message=f"Circular dependency detected: {' → '.join(cycle)}",
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -293,9 +258,7 @@ class DependencyValidator(Validator):
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
message=f"Line References undefined in Formula: {', '.join(undefined)}",
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
@@ -322,6 +285,16 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.data_source != "Calculated Amount":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Formula is required for Calculated Amount",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
@@ -329,7 +302,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if not self._are_parentheses_balanced(formula):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Formula has unbalanced parentheses"),
|
||||
message="Formula has unbalanced parentheses",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -341,7 +314,17 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.reference_code and row.reference_code in refs:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Formula references itself ('{0}')").format(row.reference_code),
|
||||
message=f"Formula references itself ('{row.reference_code}')",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Check undefined references
|
||||
undefined = set(refs) - set(available_codes)
|
||||
if undefined:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula references undefined codes: {', '.join(undefined)}",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -351,7 +334,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if eval_error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Formula evaluation error: {0}").format(eval_error),
|
||||
message=f"Formula evaluation error: {eval_error}",
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -388,7 +371,7 @@ class CalculationFormulaValidator(Validator):
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
|
||||
if not isinstance(result, (int, float)): # noqa: UP038
|
||||
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
|
||||
return f"Formula must return a numeric value, got {type(result).__name__}"
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -403,20 +386,21 @@ class AccountFilterValidator(Validator):
|
||||
self.account_fields = account_fields or set(self.account_meta._valid_columns)
|
||||
|
||||
def validate(self, row) -> ValidationResult:
|
||||
# dispatch-path guard: only account-data rows are validated here
|
||||
if row.data_source != "Account Data":
|
||||
return ValidationResult()
|
||||
|
||||
return self.validate_filter(row)
|
||||
|
||||
def validate_filter(self, row) -> ValidationResult:
|
||||
"""Validate calculation_formula as an Account filter, regardless of data_source.
|
||||
|
||||
The caller has already decided this row is an account filter, so unlike
|
||||
`validate()` this does not opt out based on `data_source`.
|
||||
"""
|
||||
result = ValidationResult()
|
||||
|
||||
if row.data_source != "Account Data":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Account filter is required for Account Data",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
filter_config = json.loads(row.calculation_formula)
|
||||
error = self._validate_filter_structure(
|
||||
@@ -428,21 +412,18 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label("Account Data"), error
|
||||
),
|
||||
message=error,
|
||||
row_idx=row.idx,
|
||||
field="Account Filter",
|
||||
)
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label("Account Data"),
|
||||
_("Invalid JSON format: {0}").format(str(e)),
|
||||
),
|
||||
message=f"Invalid JSON format: {e!s}",
|
||||
row_idx=row.idx,
|
||||
field="Account Filter",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -457,35 +438,36 @@ class AccountFilterValidator(Validator):
|
||||
# simple condition: [field, operator, value]
|
||||
if isinstance(filter_config, list):
|
||||
if len(filter_config) != 3:
|
||||
return _("Filter must be [field, operator, value]")
|
||||
return "Filter must be [field, operator, value]"
|
||||
|
||||
field, operator, value = filter_config
|
||||
|
||||
if not isinstance(field, str) or not isinstance(operator, str):
|
||||
return _("Field and operator must be strings")
|
||||
return "Field and operator must be strings"
|
||||
|
||||
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
|
||||
|
||||
if field not in account_fields:
|
||||
# escape: `field` is caller-supplied and this message renders as HTML
|
||||
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
|
||||
return f"Field '{display}' is not a valid Account field"
|
||||
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
return f"Invalid operator '{operator}'"
|
||||
|
||||
if operator in ["in", "not in"] and not isinstance(value, list):
|
||||
return _("Operator '{0}' requires a list value").format(operator)
|
||||
return f"Operator '{operator}' requires a list value"
|
||||
|
||||
# logical condition: {"and": [condition1, condition2]}
|
||||
elif isinstance(filter_config, dict):
|
||||
if len(filter_config) != 1:
|
||||
return _("Logical condition must have exactly one operator")
|
||||
return "Logical condition must have exactly one operator"
|
||||
|
||||
op = next(iter(filter_config.keys())).lower()
|
||||
if op not in ["and", "or"]:
|
||||
return _("Logical operators must be 'and' or 'or'")
|
||||
return "Logical operators must be 'and' or 'or'"
|
||||
|
||||
conditions = filter_config[next(iter(filter_config.keys()))]
|
||||
if not isinstance(conditions, list) or len(conditions) < 1:
|
||||
return _("Logical conditions need at least 1 sub-condition")
|
||||
return "Logical conditions need at least 1 sub-condition"
|
||||
|
||||
# recursive
|
||||
for condition in conditions:
|
||||
@@ -493,7 +475,7 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
return error
|
||||
else:
|
||||
return _("Filter must be a list or dict")
|
||||
return "Filter must be a list or dict"
|
||||
|
||||
return None
|
||||
|
||||
@@ -529,32 +511,34 @@ class FormulaValidator(Validator):
|
||||
if "." not in api_path:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} should be in format: app.module.method").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
message="Custom API path should be in format: app.module.method",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# Method exists?
|
||||
try:
|
||||
get_valid_api_method(api_path)
|
||||
module_path, method_name = api_path.rsplit(".", 1)
|
||||
module = frappe.get_module(module_path)
|
||||
|
||||
if not hasattr(module, method_name):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
|
||||
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
|
||||
frappe.clear_last_message()
|
||||
|
||||
if isinstance(e, frappe.PermissionError):
|
||||
message = _("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Could not validate API path: {e!s}",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
else:
|
||||
message = _("Could not validate {0}: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
)
|
||||
|
||||
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
AccountFilterValidator,
|
||||
FormulaValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -78,173 +72,3 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def not_whitelisted_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["POST"])
|
||||
def whitelisted_post_only_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["GET"])
|
||||
def whitelisted_get_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
|
||||
"""Custom API rows must point to whitelisted methods that permit GET"""
|
||||
|
||||
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
|
||||
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
|
||||
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
|
||||
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
|
||||
|
||||
def create_api_template(self, api_path):
|
||||
template = self.create_test_template_with_rows(
|
||||
[
|
||||
{
|
||||
"reference_code": "API001",
|
||||
"display_name": "API Row",
|
||||
"data_source": "Custom API",
|
||||
"calculation_formula": api_path,
|
||||
}
|
||||
]
|
||||
)
|
||||
template.report_type = "Profit and Loss Statement"
|
||||
return template
|
||||
|
||||
def test_get_valid_api_method(self):
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
|
||||
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
|
||||
|
||||
def test_save_rejects_invalid_api_methods(self):
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
self.assertRaises(frappe.ValidationError, template.insert)
|
||||
|
||||
def test_save_allows_get_whitelisted_method(self):
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
template.insert()
|
||||
template.delete()
|
||||
|
||||
def test_engine_rejects_invalid_api_methods(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
|
||||
|
||||
def test_engine_calls_valid_api_method(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
row_data = processor._process_api_row(template.rows[0])
|
||||
self.assertEqual(row_data.values, [42.0])
|
||||
|
||||
def test_validation_keeps_message_log_clean(self):
|
||||
validator = FormulaValidator(frappe._dict(rows=[]))
|
||||
message_count = len(frappe.local.message_log)
|
||||
|
||||
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
|
||||
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
|
||||
result = validator.validate(row)
|
||||
self.assertFalse(result.is_valid)
|
||||
self.assertEqual(len(frappe.local.message_log), message_count)
|
||||
|
||||
|
||||
class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
"""Filter fields must be validated on the account-filter parser path."""
|
||||
|
||||
@staticmethod
|
||||
def _row(formula, **extra):
|
||||
return frappe._dict(calculation_formula=formula, idx=1, **extra)
|
||||
|
||||
def test_validate_filter_enforces_allow_list_without_data_source(self):
|
||||
# the parser path has no `data_source`; the field allow-list must still apply
|
||||
validator = AccountFilterValidator()
|
||||
self.assertFalse(validator.validate_filter(self._row('["bad_field", "=", "x"]')).is_valid)
|
||||
self.assertTrue(validator.validate_filter(self._row('["root_type", "=", "Income"]')).is_valid)
|
||||
|
||||
def test_validate_gate_still_opts_out_for_non_account_data(self):
|
||||
# validate() is the dispatch gate: it must not validate non "Account Data" rows
|
||||
validator = AccountFilterValidator()
|
||||
row = self._row('["bad_field", "=", "x"]', data_source="Custom API")
|
||||
self.assertTrue(validator.validate(row).is_valid)
|
||||
|
||||
def test_error_message_labels_and_escapes_field(self):
|
||||
validator = AccountFilterValidator()
|
||||
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
|
||||
message = str(result.issues[0])
|
||||
self.assertIn("[Account Filter]", message)
|
||||
self.assertIn("<script>", message)
|
||||
self.assertNotIn("<script>", message)
|
||||
|
||||
def test_build_conditions_raises_on_invalid_field_when_opted_in(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FilterExpressionParser,
|
||||
)
|
||||
|
||||
account = frappe.qb.DocType("Account")
|
||||
rows = [self._row('["bad_field", "=", "x"]')]
|
||||
parser = FilterExpressionParser()
|
||||
|
||||
# default: invalid rows are skipped, not raised
|
||||
self.assertIsNone(parser.build_conditions(rows, account))
|
||||
|
||||
# opted in (the get_filtered_accounts path): invalid rows raise
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, parser.build_conditions, rows, account, raise_on_invalid=True
|
||||
)
|
||||
|
||||
def test_build_conditions_empty_returns_none(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FilterExpressionParser,
|
||||
)
|
||||
|
||||
account = frappe.qb.DocType("Account")
|
||||
self.assertIsNone(FilterExpressionParser().build_conditions([], account))
|
||||
|
||||
def test_endpoint_requires_company(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
get_filtered_accounts,
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, get_filtered_accounts, "", "[]")
|
||||
|
||||
def test_endpoint_rejects_invalid_field(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
get_filtered_accounts,
|
||||
)
|
||||
|
||||
company = frappe.get_all("Company", limit=1, pluck="name")[0]
|
||||
rows = frappe.as_json([{"calculation_formula": '["bad_field", "=", "x"]'}])
|
||||
self.assertRaises(frappe.ValidationError, get_filtered_accounts, company, rows)
|
||||
|
||||
def test_endpoint_empty_rows_returns_all_company_accounts(self):
|
||||
# filters are optional: no filter returns every enabled, non-group account of the company
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
get_filtered_accounts,
|
||||
)
|
||||
|
||||
company = frappe.get_all("Company", limit=1, pluck="name")[0]
|
||||
expected = frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": company, "disabled": 0, "is_group": 0},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
|
||||
|
||||
@@ -8,6 +8,6 @@ def get_data():
|
||||
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
|
||||
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
|
||||
{"label": _("Stock"), "items": ["Item Group", "Item"]},
|
||||
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
|
||||
if (frm.doc.docstatus == 1) {
|
||||
frm.add_custom_button(
|
||||
__("Reverse Journal Entry"),
|
||||
function () {
|
||||
@@ -623,7 +623,7 @@ $.extend(erpnext.journal_entry, {
|
||||
} else {
|
||||
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn);
|
||||
}
|
||||
frm.get_field("accounts").grid.refresh_row(cdn);
|
||||
refresh_field("exchange_rate", cdn, "accounts");
|
||||
},
|
||||
|
||||
quick_entry: function (frm) {
|
||||
|
||||
@@ -162,7 +162,7 @@ class JournalEntry(AccountsController):
|
||||
|
||||
JournalTaxWithholding(self).on_validate()
|
||||
|
||||
if not self.title or (self.is_new() and self.amended_from):
|
||||
if self.is_new() or not self.title:
|
||||
self.title = self.get_title()
|
||||
|
||||
def validate_advance_accounts(self):
|
||||
@@ -906,16 +906,6 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
)
|
||||
|
||||
if reference_type == "Purchase Invoice" and invoice.invoice_is_blocked():
|
||||
msg = (
|
||||
_("{0} {1} is blocked and on hold until {2}.").format(
|
||||
invoice.doctype, invoice.name, invoice.release_date
|
||||
)
|
||||
if invoice.release_date
|
||||
else _("{0} {1} is blocked.").format(invoice.doctype, invoice.name)
|
||||
)
|
||||
frappe.throw(msg)
|
||||
|
||||
def set_against_account(self):
|
||||
accounts_debited, accounts_credited = [], []
|
||||
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
|
||||
@@ -1402,7 +1392,6 @@ def get_payment_entry_against_order(
|
||||
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
@@ -1448,8 +1437,6 @@ def get_payment_entry_against_invoice(
|
||||
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
@@ -1485,8 +1472,6 @@ def get_payment_entry_against_invoice(
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
frappe.has_permission("Journal Entry", ptype="create", throw=True)
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
@@ -1782,20 +1767,6 @@ def make_inter_company_journal_entry(name, voucher_type, company):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name, target_doc=None):
|
||||
# `get_mapped_doc` checks this as well, but the guards below disclose which entry
|
||||
# reverses which, so read access has to be settled before they run
|
||||
if not frappe.has_permission("Journal Entry", doc=source_name):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
|
||||
if reversal_of:
|
||||
frappe.throw(
|
||||
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
|
||||
get_link_to_form("Journal Entry", source_name),
|
||||
get_link_to_form("Journal Entry", reversal_of),
|
||||
)
|
||||
)
|
||||
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
from frappe.utils import flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
|
||||
@@ -248,27 +248,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_disallow_reversal_of_a_reversal_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
|
||||
|
||||
rjv = make_reverse_journal_entry(jv.name)
|
||||
rjv.posting_date = nowdate()
|
||||
rjv.submit()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"is already a Reverse Journal Entry",
|
||||
make_reverse_journal_entry,
|
||||
rjv.name,
|
||||
)
|
||||
|
||||
# the guard must not disclose the reversal to a user who cannot read the entry
|
||||
frappe.set_user("Guest")
|
||||
self.addCleanup(frappe.set_user, "Administrator")
|
||||
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
|
||||
|
||||
def test_disallow_change_in_account_currency_for_a_party(self):
|
||||
# create jv in USD
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)
|
||||
@@ -630,69 +609,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
jv.save()
|
||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||
|
||||
def make_jv_against_purchase_invoice(self, invoice, amount=100):
|
||||
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
|
||||
jv.accounts[0].party_type = "Supplier"
|
||||
jv.accounts[0].party = invoice.supplier
|
||||
jv.accounts[0].reference_type = "Purchase Invoice"
|
||||
jv.accounts[0].reference_name = invoice.name
|
||||
return jv
|
||||
|
||||
def test_jv_against_purchase_invoice_respects_hold_state(self):
|
||||
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
release_date = add_days(nowdate(), 10)
|
||||
|
||||
def never_held():
|
||||
return make_purchase_invoice()
|
||||
|
||||
def held_until_a_future_date():
|
||||
invoice = make_purchase_invoice()
|
||||
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
|
||||
return invoice
|
||||
|
||||
def held_without_a_release_date():
|
||||
invoice = make_purchase_invoice()
|
||||
invoice.block_invoice(hold_comment="Under dispute")
|
||||
return invoice
|
||||
|
||||
def held_until_a_date_that_has_passed():
|
||||
invoice = held_until_a_future_date()
|
||||
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
|
||||
return invoice
|
||||
|
||||
def unblocked_again():
|
||||
invoice = held_until_a_future_date()
|
||||
invoice.unblock_invoice()
|
||||
return invoice
|
||||
|
||||
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
|
||||
with self.subTest(build_invoice.__name__):
|
||||
jv = self.make_jv_against_purchase_invoice(build_invoice())
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is blocked", jv.insert)
|
||||
|
||||
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
|
||||
with self.subTest(build_invoice.__name__):
|
||||
invoice = build_invoice()
|
||||
jv = self.make_jv_against_purchase_invoice(invoice)
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
|
||||
|
||||
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
|
||||
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -56,9 +56,7 @@ class LedgerMerge(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_merge(docname):
|
||||
lm_doc = frappe.get_doc("Ledger Merge", docname)
|
||||
lm_doc.check_permission("write")
|
||||
return lm_doc.start_merge()
|
||||
return frappe.get_doc("Ledger Merge", docname).start_merge()
|
||||
|
||||
|
||||
def start_merge(docname):
|
||||
|
||||
@@ -297,9 +297,6 @@ def start_import(invoices):
|
||||
invoice_number = d.invoice_number
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
# the outstanding amount is entered inclusive of tax, so taxes must not
|
||||
# be added on top of it
|
||||
doc.flags.dont_auto_add_taxes = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
if not frappe.in_test:
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -128,55 +126,6 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
|
||||
)
|
||||
def test_opening_invoice_creation_without_taxes(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Taxes and Charges Template",
|
||||
"company": company,
|
||||
"title": "_Test Opening Invoice Tax",
|
||||
"taxes": [
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": create_account(
|
||||
account_name="_Test Opening Tax Account",
|
||||
parent_account="Duties and Taxes - _TOIC",
|
||||
account_type="Tax",
|
||||
company=company,
|
||||
),
|
||||
"description": "Test taxes",
|
||||
"rate": 9,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# makes the template the default for the party, as it would be on a live site
|
||||
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
|
||||
|
||||
tool = self.make_invoices(company=company, return_doc=True)
|
||||
invoices = tool.make_invoices()
|
||||
self.assertEqual(len(invoices), 2)
|
||||
|
||||
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
|
||||
for invoice in invoices:
|
||||
si = frappe.get_doc("Sales Invoice", invoice)
|
||||
self.assertFalse(si.taxes)
|
||||
self.assertEqual(si.grand_total, 200)
|
||||
self.assertEqual(si.outstanding_amount, 200)
|
||||
|
||||
# the same invoice created outside the tool keeps the default taxes,
|
||||
# since adding them there is the user's decision
|
||||
si = frappe.get_doc(tool.get_invoices()[0])
|
||||
si.flags.ignore_mandatory = True
|
||||
si.insert()
|
||||
self.assertTrue(si.taxes)
|
||||
self.assertEqual(si.grand_total, 218)
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
|
||||
@@ -2893,11 +2893,7 @@ def get_payment_entry(
|
||||
reference_date=None,
|
||||
created_from_payment_request=False,
|
||||
):
|
||||
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
||||
|
||||
doc = frappe.get_doc(dt, dn)
|
||||
doc.check_permission()
|
||||
|
||||
over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
|
||||
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))
|
||||
|
||||
@@ -83,7 +83,6 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
def make_payment_records(name, supplier, mode_of_payment=None):
|
||||
doc = frappe.get_doc("Payment Order", name)
|
||||
doc.check_permission()
|
||||
make_journal_entry(doc, supplier, mode_of_payment)
|
||||
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) {
|
||||
freeze: true,
|
||||
callback: function (data) {
|
||||
if (!data.exc) {
|
||||
frm.clear_table("subscription_plans");
|
||||
$.each(data.message || [], function (i, v) {
|
||||
var d = frappe.model.add_child(
|
||||
frm.doc,
|
||||
|
||||
@@ -474,7 +474,7 @@ class PaymentRequest(Document):
|
||||
}
|
||||
|
||||
if self.message:
|
||||
return frappe.render_template(self.message, context, restrict_globals=True)
|
||||
return frappe.render_template(self.message, context)
|
||||
|
||||
def set_failed(self):
|
||||
pass
|
||||
@@ -711,7 +711,6 @@ def make_payment_request(**args):
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
subscription_plans = get_subscription_details(ref_doc.doctype, ref_doc.name)
|
||||
pr.update(
|
||||
{
|
||||
"payment_gateway_account": gateway_account.get("name"),
|
||||
@@ -743,24 +742,12 @@ def make_payment_request(**args):
|
||||
or gateway_account.get("payment_channel", "Email") != "Email"
|
||||
),
|
||||
"phone_number": args.get("phone_number") if args.get("phone_number") else None,
|
||||
"is_a_subscription": 1 if subscription_plans else 0,
|
||||
}
|
||||
)
|
||||
|
||||
if selected_payment_schedules:
|
||||
apply_payment_references(pr, payment_reference)
|
||||
|
||||
if subscription_plans:
|
||||
pr.set(
|
||||
"subscription_plans",
|
||||
[
|
||||
{
|
||||
"plan": row.plan,
|
||||
"qty": row.qty,
|
||||
}
|
||||
for row in subscription_plans
|
||||
],
|
||||
)
|
||||
# Dimensions
|
||||
pr.update(
|
||||
{
|
||||
@@ -1074,25 +1061,19 @@ def get_dummy_message(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_subscription_details(reference_doctype: str, reference_name: str) -> list[dict]:
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
|
||||
if not frappe.get_meta(reference_doctype).has_field("subscription"):
|
||||
return []
|
||||
|
||||
subscription = frappe.db.get_value(reference_doctype, reference_name, "subscription")
|
||||
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Subscription Plan Detail",
|
||||
filters={"parent": subscription, "parenttype": "Subscription", "parentfield": "plans"},
|
||||
fields=[
|
||||
"plan",
|
||||
"qty",
|
||||
],
|
||||
)
|
||||
def get_subscription_details(reference_doctype, reference_name):
|
||||
if reference_doctype == "Sales Invoice":
|
||||
subscriptions = frappe.db.sql(
|
||||
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
|
||||
reference_name,
|
||||
as_dict=1,
|
||||
)
|
||||
subscription_plans = []
|
||||
for subscription in subscriptions:
|
||||
plans = frappe.get_doc("Subscription", subscription.sub_name).plans
|
||||
for plan in plans:
|
||||
subscription_plans.append(plan)
|
||||
return subscription_plans
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1188,7 +1169,6 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
|
||||
@frappe.whitelist()
|
||||
def get_available_payment_schedules(reference_doctype, reference_name):
|
||||
ref_doc = frappe.get_doc(reference_doctype, reference_name)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
|
||||
return []
|
||||
|
||||
@@ -6,10 +6,9 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, flt, formatdate, getdate
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
@@ -19,8 +18,6 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
|
||||
|
||||
class PeriodClosingVoucher(AccountsController):
|
||||
@@ -142,121 +139,6 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if account_currency != company_currency:
|
||||
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
|
||||
|
||||
def before_submit(self):
|
||||
if not self.has_stock_transactions():
|
||||
return
|
||||
|
||||
self.validate_stock_accounts_balance()
|
||||
self.validate_stock_closing_entry()
|
||||
|
||||
def has_stock_transactions(self):
|
||||
if not is_perpetual_inventory_enabled(self.company):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"company": self.company,
|
||||
"is_cancelled": 0,
|
||||
"posting_date": ("<=", self.period_end_date),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_stock_accounts_balance(self):
|
||||
precision = frappe.get_precision("GL Entry", "debit")
|
||||
account_balance = flt(self.get_stock_accounts_balance(), precision)
|
||||
stock_value = flt(
|
||||
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
|
||||
)
|
||||
|
||||
if account_balance == stock_value:
|
||||
return
|
||||
|
||||
currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
frappe.throw(
|
||||
_(
|
||||
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
|
||||
).format(
|
||||
frappe.bold(fmt_money(account_balance, currency=currency)),
|
||||
frappe.bold(fmt_money(stock_value, currency=currency)),
|
||||
frappe.bold(formatdate(self.period_end_date)),
|
||||
),
|
||||
title=_("Stock Value Mismatch"),
|
||||
)
|
||||
|
||||
def get_stock_accounts_balance(self):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
stock_accounts = (
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(
|
||||
(account.account_type == "Stock")
|
||||
& (account.company == self.company)
|
||||
& (account.is_group == 0)
|
||||
)
|
||||
)
|
||||
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit - gle.credit))
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (gle.posting_date <= self.period_end_date)
|
||||
& gle.account.isin(stock_accounts)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(balance[0][0]) if balance else 0.0
|
||||
|
||||
def validate_stock_closing_entry(self):
|
||||
closing_entry = frappe.db.get_value(
|
||||
"Stock Closing Entry",
|
||||
apply_unscoped_filters(
|
||||
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
|
||||
),
|
||||
["name", "status", "modified"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not closing_entry:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry Required"),
|
||||
)
|
||||
|
||||
if closing_entry.status != "Completed":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry In Progress"),
|
||||
)
|
||||
|
||||
self.validate_stock_closing_entry_is_fresh(closing_entry)
|
||||
|
||||
def validate_stock_closing_entry_is_fresh(self, closing_entry):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
last_change = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Max(sle.modified))
|
||||
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
|
||||
).run()
|
||||
|
||||
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
|
||||
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
|
||||
title=_("Stock Closing Entry Outdated"),
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, today
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
@@ -307,218 +307,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
repost_doc.posting_date = today()
|
||||
repost_doc.save()
|
||||
|
||||
def test_stock_validations_before_period_closing(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Stock Closing Entry": [
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
"label": "Warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": pcv.period_start_date,
|
||||
"to_date": pcv.period_end_date,
|
||||
"warehouse": "Stores - TPC",
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name},
|
||||
["name", "stock_value_difference"],
|
||||
as_dict=1,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
"Test PCV Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TPCVB.####",
|
||||
},
|
||||
)
|
||||
se1 = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=200,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-06-15",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
from_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2022-04-01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
)
|
||||
self.assertEqual(flt(stock_value_difference, 2), -750.0)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"frozen",
|
||||
make_stock_entry,
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
|
||||
|
||||
def test_period_closing_blocks_stale_stock_closing_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def make_completed_stock_closing_entry(self, from_date, to_date):
|
||||
from unittest.mock import patch
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
return sce
|
||||
|
||||
def rebuild_stock_closing_balance(self, sce):
|
||||
sce.remove_stock_closing()
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
@@ -261,15 +261,12 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
def get_invoices(start, end, pos_profile, user):
|
||||
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
|
||||
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
|
||||
|
||||
frappe.has_permission("Sales Invoice", throw=True)
|
||||
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
|
||||
|
||||
query = sales_inv_query
|
||||
|
||||
if invoice_doctype == "POS Invoice":
|
||||
frappe.has_permission("POS Invoice", throw=True)
|
||||
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
|
||||
query = query + pos_inv_query
|
||||
|
||||
|
||||
@@ -497,7 +497,6 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -1637,7 +1636,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-05-28 12:22:50.253090",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -238,7 +238,6 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -859,7 +858,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -280,40 +280,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
user = frappe.session["user"]
|
||||
company = filters.get("company") or frappe.defaults.get_user_default("company")
|
||||
|
||||
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
|
||||
args = {
|
||||
"user": user,
|
||||
"start": start,
|
||||
"company": company,
|
||||
"page_len": page_len,
|
||||
"txt": "%%%s%%" % txt,
|
||||
}
|
||||
|
||||
if not allowed_pos_profiles:
|
||||
return {}
|
||||
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.inner_join(pfu)
|
||||
.on(pfu.parent == pf.name)
|
||||
.select(pf.name)
|
||||
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
|
||||
.where(pf.name.isin(allowed_pos_profiles))
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf, `tabPOS Profile User` pfu
|
||||
where
|
||||
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
|
||||
and (pf.name like %(txt)s)
|
||||
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
|
||||
args,
|
||||
)
|
||||
|
||||
if not pos_profile:
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.left_join(pfu)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user.isnull() | (pfu.user == ""))
|
||||
& (pf.company == company)
|
||||
& pf.name.like(f"%{txt}%")
|
||||
& (pf.disabled == 0)
|
||||
& (pf.name.isin(allowed_pos_profiles))
|
||||
)
|
||||
.run()
|
||||
del args["user"]
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
|
||||
on
|
||||
pf.name = pfu.parent
|
||||
where
|
||||
ifnull(pfu.user, '') = ''
|
||||
and pf.company = %(company)s
|
||||
and pf.name like %(txt)s
|
||||
and pf.disabled = 0""",
|
||||
args,
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"read_only": 1
|
||||
"hidden": 1,
|
||||
"label": "Fieldname"
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
@@ -27,7 +26,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-31 20:41:12.000000",
|
||||
"modified": "2025-07-29 18:08:40.323579",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Search Fields",
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
function is_valid_invoice_field(df) {
|
||||
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
|
||||
}
|
||||
let search_fields_datatypes = [
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
];
|
||||
let do_not_include_fields = [
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
];
|
||||
|
||||
frappe.ui.form.on("POS Settings", {
|
||||
onload: function (frm) {
|
||||
@@ -11,97 +42,74 @@ frappe.ui.form.on("POS Settings", {
|
||||
frm.trigger("add_search_options");
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
frm.trigger("get_invoice_fields");
|
||||
},
|
||||
|
||||
get_invoice_fields: function (frm) {
|
||||
const invoice_type = frm.doc.invoice_type;
|
||||
if (!invoice_type) return;
|
||||
|
||||
frappe.model.with_doctype(invoice_type, () => {
|
||||
// the invoice type can change again while the meta loads
|
||||
if (frm.doc.invoice_type !== invoice_type) return;
|
||||
|
||||
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
|
||||
frappe.model.with_doctype("POS Invoice", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
if (
|
||||
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
|
||||
["Button"].includes(d.fieldtype)
|
||||
) {
|
||||
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
frm.fields_dict.invoice_fields.grid.update_docfield_property(
|
||||
"fieldname",
|
||||
"options",
|
||||
[""].concat(
|
||||
fields.map((df) => {
|
||||
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
|
||||
})
|
||||
)
|
||||
[""].concat(fields)
|
||||
);
|
||||
|
||||
frm.trigger("validate_invoice_fields");
|
||||
});
|
||||
},
|
||||
|
||||
validate_invoice_fields: function (frm) {
|
||||
const valid_fieldnames = frappe
|
||||
.get_doc("DocType", frm.doc.invoice_type)
|
||||
.fields.filter(is_valid_invoice_field)
|
||||
.map((df) => df.fieldname);
|
||||
|
||||
const invalid_fields = (frm.doc.invoice_fields || [])
|
||||
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
|
||||
.map((row) => `#${row.idx} ${row.fieldname}`);
|
||||
|
||||
if (!invalid_fields.length) return;
|
||||
|
||||
frappe.msgprint({
|
||||
title: __("Invalid POS Fields"),
|
||||
indicator: "orange",
|
||||
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
|
||||
frm.doc.invoice_type.bold(),
|
||||
invalid_fields.join(", "),
|
||||
]),
|
||||
});
|
||||
},
|
||||
|
||||
add_search_options: function (frm) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
|
||||
callback: ({ message }) => {
|
||||
const fields = message || [];
|
||||
frappe.model.with_doctype("Item", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (
|
||||
search_fields_datatypes.includes(d.fieldtype) &&
|
||||
!do_not_include_fields.includes(d.fieldname)
|
||||
) {
|
||||
return [d.label];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
frm.searchable_item_fields = Object.fromEntries(
|
||||
fields.map((df) => [df.option, df.fieldname])
|
||||
);
|
||||
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
|
||||
"field",
|
||||
"options",
|
||||
[""].concat(fields.map((df) => df.option))
|
||||
);
|
||||
},
|
||||
fields.unshift("");
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Search Fields", {
|
||||
field: function (frm, doctype, name) {
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
|
||||
return d;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})[0];
|
||||
|
||||
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
|
||||
frm.refresh_field("pos_search_fields");
|
||||
doc.fieldname = df.fieldname;
|
||||
frm.refresh_field("fields");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Field", {
|
||||
fieldname: function (frm, doctype, name) {
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
|
||||
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
|
||||
if (!df) return;
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
return doc.fieldname == d.fieldname ? d : null;
|
||||
})[0];
|
||||
|
||||
doc.label = df.label;
|
||||
doc.reqd = df.reqd;
|
||||
doc.options = df.options;
|
||||
doc.fieldtype = df.fieldtype;
|
||||
doc.default_value = df.default;
|
||||
frm.refresh_field("invoice_fields");
|
||||
frm.refresh_field("fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,46 +5,8 @@ from collections import Counter
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model import no_value_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
SEARCH_FIELD_TYPES = (
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
)
|
||||
|
||||
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
|
||||
DO_NOT_INCLUDE_FIELDS = (
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
)
|
||||
|
||||
|
||||
class POSSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -70,10 +32,17 @@ class POSSettings(Document):
|
||||
if old_doc.invoice_type != self.invoice_type:
|
||||
self.validate_invoice_type()
|
||||
|
||||
self.validate_duplicate_invoice_fields()
|
||||
self.validate_invoice_fields()
|
||||
self.validate_duplicate_pos_search_fields()
|
||||
self.validate_pos_search_fields()
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
|
||||
def validate_invoice_type(self):
|
||||
pos_opening_entries_count = frappe.db.count(
|
||||
@@ -86,94 +55,3 @@ class POSSettings(Document):
|
||||
),
|
||||
title=_("Invoice Document Type Selection Error"),
|
||||
)
|
||||
|
||||
def validate_duplicate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
if not self.invoice_type:
|
||||
return
|
||||
|
||||
meta = frappe.get_meta(self.invoice_type)
|
||||
|
||||
for field in self.invoice_fields:
|
||||
df = meta.get_field(field.fieldname)
|
||||
|
||||
if not df or not is_valid_invoice_field(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Field"),
|
||||
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
|
||||
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
|
||||
),
|
||||
)
|
||||
|
||||
# read only in the form, so keep them in sync with the invoice
|
||||
field.label = df.label
|
||||
field.fieldtype = df.fieldtype
|
||||
field.options = df.options
|
||||
|
||||
def validate_duplicate_pos_search_fields(self):
|
||||
fieldnames = [field.fieldname for field in self.pos_search_fields]
|
||||
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
|
||||
|
||||
for fieldname in duplicate_fieldnames:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Search Fields"),
|
||||
msg=_("'{0}' has been already added.").format(fieldname),
|
||||
)
|
||||
|
||||
def validate_pos_search_fields(self):
|
||||
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
|
||||
|
||||
for field in self.pos_search_fields:
|
||||
df = searchable_fields.get(field.fieldname)
|
||||
|
||||
if not df:
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
|
||||
field.idx, frappe.bold(field.fieldname or "")
|
||||
),
|
||||
)
|
||||
|
||||
if field.field != get_search_field_option(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' does not match {2}.").format(
|
||||
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def is_valid_invoice_field(df):
|
||||
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
|
||||
|
||||
|
||||
def get_searchable_item_fields():
|
||||
return [
|
||||
df
|
||||
for df in frappe.get_meta("Item").fields
|
||||
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
|
||||
]
|
||||
|
||||
|
||||
def get_search_field_option(df):
|
||||
# the fieldname keeps the option unique, two Item fields can share a label
|
||||
return f"{df.label} ({df.fieldname})"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pos_search_field_options():
|
||||
frappe.has_permission("POS Settings", throw=True)
|
||||
|
||||
return [
|
||||
{"option": get_search_field_option(df), "fieldname": df.fieldname}
|
||||
for df in get_searchable_item_fields()
|
||||
]
|
||||
|
||||
@@ -2,135 +2,8 @@
|
||||
# See license.txt
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPOSSettings(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.settings = frappe.get_single("POS Settings")
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.pos_search_fields = []
|
||||
|
||||
def assertInvalid(self, message):
|
||||
with self.assertRaises(frappe.ValidationError) as context:
|
||||
self.settings.save()
|
||||
|
||||
self.assertIn(message, str(context.exception))
|
||||
|
||||
def test_invoice_field_is_validated_against_invoice_type(self):
|
||||
# consolidated_invoice exists on POS Invoice only
|
||||
self.settings.invoice_type = "POS Invoice"
|
||||
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
|
||||
self.settings.save()
|
||||
|
||||
self.settings.invoice_type = "Sales Invoice"
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_field_common_to_both_invoice_types_is_allowed(self):
|
||||
for invoice_type in ("POS Invoice", "Sales Invoice"):
|
||||
self.settings.invoice_type = invoice_type
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.append("invoice_fields", {"fieldname": "po_no"})
|
||||
self.settings.save()
|
||||
|
||||
def test_unknown_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_layout_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_invoice_field_properties_are_set_from_the_invoice(self):
|
||||
self.settings.append(
|
||||
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
field = self.settings.invoice_fields[0]
|
||||
self.assertEqual(field.label, "Customer")
|
||||
self.assertEqual(field.fieldtype, "Link")
|
||||
self.assertEqual(field.options, "Customer")
|
||||
|
||||
def test_searchable_item_field_is_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
|
||||
|
||||
def test_excluded_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_of_unsearchable_type_is_not_allowed(self):
|
||||
# maintain stock is a Check field
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_unknown_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_without_a_fieldname_is_not_allowed(self):
|
||||
# the form fills the fieldname in, it cannot be picked on its own
|
||||
self.settings.append("pos_search_fields", {"field": "Description (description)"})
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_option_must_match_its_fieldname(self):
|
||||
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_bare_label_is_not_accepted_as_a_search_field(self):
|
||||
# the stored option carries the fieldname, the patch backfills older rows
|
||||
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_duplicate_search_fields_are_not_allowed(self):
|
||||
for _ in range(2):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
|
||||
self.assertInvalid("has been already added")
|
||||
|
||||
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
|
||||
|
||||
append_fieldname()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_patch_leaves_an_already_migrated_search_field_alone(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
append_fieldname()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
|
||||
self.settings.invoice_type = None
|
||||
self.settings.append("invoice_fields", {"fieldname": "customer"})
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")
|
||||
pass
|
||||
|
||||
@@ -142,8 +142,6 @@ def start_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -159,8 +157,6 @@ def pause_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -175,8 +171,6 @@ def cancel_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
|
||||
@@ -240,8 +240,10 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
unblock_invoice() {
|
||||
const me = this;
|
||||
me.frm.call("unblock_invoice", null, () => {
|
||||
me.frm.reload_doc();
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
|
||||
args: { name: me.frm.doc.name },
|
||||
callback: (r) => me.frm.reload_doc(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,16 +294,15 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
this.dialog.set_primary_action(__("Save"), function () {
|
||||
const dialog_data = me.dialog.get_values();
|
||||
me.frm.call(
|
||||
"block_invoice",
|
||||
{
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
|
||||
args: {
|
||||
name: me.frm.doc.name,
|
||||
hold_comment: dialog_data.hold_comment,
|
||||
release_date: dialog_data.release_date,
|
||||
},
|
||||
() => {
|
||||
me.frm.reload_doc();
|
||||
}
|
||||
);
|
||||
callback: (r) => me.frm.reload_doc(),
|
||||
});
|
||||
me.dialog.hide();
|
||||
});
|
||||
|
||||
@@ -340,9 +341,10 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
|
||||
set_release_date(data) {
|
||||
const me = this;
|
||||
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
|
||||
me.frm.reload_doc();
|
||||
return frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
|
||||
args: data,
|
||||
callback: (r) => this.frm.reload_doc(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -352,7 +352,6 @@
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.on_hold",
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
"fieldname": "sb_14",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Hold Invoice"
|
||||
@@ -504,7 +503,6 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1704,7 +1702,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-07-12 23:54:21.263951",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _, qb, throw
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
|
||||
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.deferred_revenue import validate_service_stop_date
|
||||
@@ -42,7 +42,6 @@ from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
update_billed_amount_based_on_po,
|
||||
)
|
||||
@@ -287,7 +286,9 @@ class PurchaseInvoice(BuyingController):
|
||||
self.check_conversion_rate()
|
||||
self.validate_credit_to_acc()
|
||||
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
|
||||
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
self.validate_with_previous_doc()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
@@ -299,7 +300,6 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
self.validate_exchange_rate_with_purchase_receipt()
|
||||
validate_inter_company_party(
|
||||
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
|
||||
)
|
||||
@@ -309,9 +309,6 @@ class PurchaseInvoice(BuyingController):
|
||||
PurchaseTaxWithholding(self).on_validate()
|
||||
self.set_percentage_received()
|
||||
|
||||
if self.on_hold:
|
||||
self.validate_invoice_hold()
|
||||
|
||||
def set_percentage_received(self):
|
||||
total_billed_qty = 0.0
|
||||
total_received_qty = 0.0
|
||||
@@ -323,54 +320,6 @@ class PurchaseInvoice(BuyingController):
|
||||
if total_billed_qty and total_received_qty:
|
||||
self.per_received = total_received_qty / total_billed_qty * 100
|
||||
|
||||
def validate_exchange_rate_with_purchase_receipt(self):
|
||||
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
return
|
||||
|
||||
stock_items = self.get_stock_items()
|
||||
receipts = {
|
||||
item.purchase_receipt
|
||||
for item in self.items
|
||||
if item.purchase_receipt and item.item_code in stock_items
|
||||
}
|
||||
if not receipts:
|
||||
return
|
||||
|
||||
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
|
||||
return
|
||||
|
||||
mismatched = [
|
||||
f"{frappe.bold(row.name)} ({row.conversion_rate})"
|
||||
for row in frappe.get_all(
|
||||
"Purchase Receipt",
|
||||
filters={"name": ("in", list(receipts))},
|
||||
fields=["name", "currency", "conversion_rate"],
|
||||
)
|
||||
if row.currency == self.currency
|
||||
and flt(row.conversion_rate)
|
||||
and flt(row.conversion_rate) != flt(self.conversion_rate)
|
||||
]
|
||||
if not mismatched:
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
|
||||
).format(
|
||||
frappe.bold(self.conversion_rate),
|
||||
", ".join(mismatched),
|
||||
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
|
||||
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_invoice_hold(self):
|
||||
if self.is_return:
|
||||
frappe.throw(_("Return Purchase Invoice cannot be held."))
|
||||
|
||||
if self.docstatus < 1:
|
||||
frappe.throw(_("Purchase Invoice can be held after submitting."))
|
||||
|
||||
def validate_release_date(self):
|
||||
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
|
||||
frappe.throw(_("Release date must be in the future"))
|
||||
@@ -1793,7 +1742,9 @@ class PurchaseInvoice(BuyingController):
|
||||
super().on_cancel()
|
||||
PurchaseTaxWithholding(self).on_cancel()
|
||||
|
||||
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_order:
|
||||
# NOTE status updating bypassed for is_return
|
||||
@@ -1950,38 +1901,14 @@ class PurchaseInvoice(BuyingController):
|
||||
def on_recurring(self, reference_doc, auto_repeat_doc):
|
||||
self.due_date = None
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
|
||||
self.check_permission("write")
|
||||
self.on_hold = 1
|
||||
self.release_date = release_date
|
||||
self.validate_block_invoice()
|
||||
|
||||
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def unblock_invoice(self):
|
||||
self.check_permission("write")
|
||||
self.db_set({"on_hold": 0, "release_date": None})
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
|
||||
self.check_permission("write")
|
||||
|
||||
if not self.on_hold:
|
||||
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
|
||||
|
||||
self.release_date = release_date
|
||||
self.validate_block_invoice()
|
||||
|
||||
def block_invoice(self, hold_comment=None, release_date=None):
|
||||
self.db_set("on_hold", 1)
|
||||
self.db_set("hold_comment", cstr(hold_comment))
|
||||
self.db_set("release_date", release_date)
|
||||
|
||||
def validate_block_invoice(self):
|
||||
self.validate_invoice_hold()
|
||||
if self.outstanding_amount <= 0:
|
||||
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
|
||||
|
||||
self.validate_release_date()
|
||||
def unblock_invoice(self):
|
||||
self.db_set("on_hold", 0)
|
||||
self.db_set("release_date", None)
|
||||
|
||||
def set_status(self, update=False, status=None, update_modified=True):
|
||||
if self.is_new():
|
||||
@@ -2106,6 +2033,28 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_release_date(name, release_date=None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def unblock_invoice(name):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.unblock_invoice()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def block_invoice(name, release_date, hold_comment=None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.block_invoice(hold_comment, release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_sales_invoice(source_name, target_doc=None):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
|
||||
@@ -2120,11 +2069,6 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
|
||||
|
||||
def received_and_mapped_qty(obj):
|
||||
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
set_missing_values(source_parent, target_parent)
|
||||
@@ -2148,13 +2092,15 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
or {}
|
||||
)
|
||||
|
||||
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
|
||||
|
||||
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = pending_qty
|
||||
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
|
||||
obj.conversion_factor
|
||||
)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -2184,9 +2130,7 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
abs(received_and_mapped_qty(doc)) < abs(doc.qty) and select_item(doc)
|
||||
),
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -278,166 +278,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
def test_purchase_invoice_explicit_block(self):
|
||||
pi = make_purchase_invoice()
|
||||
release_date = add_days(nowdate(), 10)
|
||||
|
||||
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
|
||||
pi.block_invoice()
|
||||
|
||||
self.assertEqual(pi.on_hold, 1)
|
||||
|
||||
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
|
||||
)
|
||||
self.assertEqual(on_hold, 1)
|
||||
self.assertEqual(hold_comment, "Waiting for the goods")
|
||||
self.assertEqual(getdate(saved_release_date), getdate(release_date))
|
||||
|
||||
pi.unblock_invoice()
|
||||
|
||||
self.assertEqual(pi.on_hold, 0)
|
||||
|
||||
on_hold, saved_release_date = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
|
||||
)
|
||||
self.assertEqual(on_hold, 0)
|
||||
self.assertIsNone(saved_release_date)
|
||||
|
||||
def test_purchase_invoice_cannot_be_held_before_submission(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
pi.on_hold = 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.save)
|
||||
|
||||
pi.on_hold = 0
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pi.block_invoice()
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
|
||||
|
||||
def test_return_purchase_invoice_cannot_be_held(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.on_hold = 1
|
||||
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
|
||||
|
||||
return_pi.on_hold = 0
|
||||
return_pi.save()
|
||||
return_pi.submit()
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
|
||||
|
||||
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
# a return has a negative outstanding amount, which must not be mistaken
|
||||
# for an invalid hold on a document that was never held
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.save()
|
||||
return_pi.submit()
|
||||
|
||||
self.assertEqual(return_pi.docstatus, 1)
|
||||
self.assertEqual(return_pi.on_hold, 0)
|
||||
self.assertLess(return_pi.outstanding_amount, 0)
|
||||
|
||||
def test_settled_purchase_invoice_cannot_be_held(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = nowdate()
|
||||
pe.save()
|
||||
pe.submit()
|
||||
|
||||
pi.reload()
|
||||
self.assertEqual(pi.outstanding_amount, 0)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
|
||||
|
||||
def test_release_date_of_held_invoice_must_be_in_future(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
|
||||
|
||||
def test_rejected_hold_does_not_partially_update_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
|
||||
|
||||
pi.reload()
|
||||
self.assertEqual(pi.on_hold, 0)
|
||||
self.assertIsNone(pi.release_date)
|
||||
|
||||
def test_change_release_date_of_held_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
|
||||
|
||||
new_release_date = add_days(nowdate(), 20)
|
||||
pi.change_release_date(new_release_date)
|
||||
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
|
||||
getdate(new_release_date),
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
|
||||
|
||||
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Invoice is not blocked",
|
||||
pi.change_release_date,
|
||||
add_days(nowdate(), 10),
|
||||
)
|
||||
|
||||
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
|
||||
|
||||
def test_hold_methods_are_whitelisted_document_methods(self):
|
||||
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
|
||||
|
||||
pi = frappe.new_doc("Purchase Invoice")
|
||||
|
||||
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
|
||||
# raises if the method is not whitelisted for client side calls
|
||||
pi.is_whitelisted(method)
|
||||
|
||||
self.assertFalse(
|
||||
hasattr(purchase_invoice_module, method),
|
||||
f"{method} should only be exposed as a document method",
|
||||
)
|
||||
|
||||
def test_hold_methods_require_write_permission(self):
|
||||
pi = make_purchase_invoice()
|
||||
user = "test_pi_hold_permission@example.com"
|
||||
|
||||
if not frappe.db.exists("User", user):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": user,
|
||||
"first_name": "Test PI Hold",
|
||||
"roles": [{"role": "Employee"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.set_user(user)
|
||||
try:
|
||||
self.assertRaises(frappe.PermissionError, pi.block_invoice)
|
||||
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
|
||||
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
|
||||
|
||||
def test_gl_entries_with_perpetual_inventory_against_pr(self):
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -507,12 +355,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -524,15 +366,25 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.insert)
|
||||
|
||||
pi.conversion_rate = 70
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[0].base_net_amount - pr.items[0].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
@@ -540,17 +392,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
original_value = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
# Creating Purchase Invoice with USD currency
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.currency = "USD"
|
||||
pr.company = "_Test Company with perpetual inventory"
|
||||
@@ -564,20 +406,34 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
pr.append(
|
||||
"items",
|
||||
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
|
||||
)
|
||||
pr.insert()
|
||||
pr.submit()
|
||||
|
||||
# Createing purchase invoice against Purchase Receipt
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
pi.credit_to = "_Test Payable USD - TCP1"
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "credit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[1].base_net_amount - pr.items[1].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(flt(discrepancy_caused_by_exchange_rate_diff, 2), amount)
|
||||
|
||||
def test_purchase_invoice_change_naming_series(self):
|
||||
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][1])
|
||||
pi.insert()
|
||||
|
||||
@@ -237,7 +237,6 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -1018,7 +1017,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -27,7 +27,6 @@ def start_payment_ledger_repost(docname=None):
|
||||
"""
|
||||
if docname:
|
||||
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
|
||||
repost_doc.check_permission("submit")
|
||||
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
|
||||
try:
|
||||
for entry in repost_doc.repost_vouchers:
|
||||
|
||||
@@ -368,6 +368,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
let filters = {
|
||||
docstatus: 1,
|
||||
status: ["not in", ["Closed", "On Hold"]],
|
||||
per_billed: ["<", 99.99],
|
||||
company: me.frm.doc.company,
|
||||
};
|
||||
|
||||
@@ -386,8 +387,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
customer: me.frm.doc.customer || undefined,
|
||||
},
|
||||
get_query_filters: filters,
|
||||
get_query_method:
|
||||
"erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders",
|
||||
allow_child_item_selection: true,
|
||||
child_fieldname: "items",
|
||||
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],
|
||||
@@ -1181,16 +1180,7 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
|
||||
// metadata, so this always reflects the original (Customize Form) hidden value.
|
||||
const hidden_by_customization = cint(
|
||||
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
|
||||
);
|
||||
frm.set_df_property(
|
||||
"update_stock",
|
||||
"hidden",
|
||||
cint(frm.doc.has_subcontracted) || hidden_by_customization
|
||||
);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -612,7 +612,6 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -2365,7 +2364,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-05-28 12:15:12.486443",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -108,14 +108,6 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.save()
|
||||
self.assertEqual(si.items[0].qty, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self):
|
||||
"""allow_negative_rates_for_items must not bypass the >=0 guard for a non-return
|
||||
invoice, since invoices post to the GL (unlike Sales Order)."""
|
||||
si = create_sales_invoice(qty=1, rate=100, do_not_save=True)
|
||||
si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
def test_timestamp_change(self):
|
||||
w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0])
|
||||
w.docstatus = 0
|
||||
|
||||
@@ -228,7 +228,6 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -1037,7 +1036,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -7,9 +7,10 @@ def get_data():
|
||||
"non_standard_fieldnames": {
|
||||
"Tax Rule": "sales_tax_template",
|
||||
"Subscription": "sales_tax_template",
|
||||
"Restaurant": "default_tax_template",
|
||||
},
|
||||
"transactions": [
|
||||
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -254,9 +254,6 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
return
|
||||
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
elif (
|
||||
@@ -608,11 +605,6 @@ class Subscription(Document):
|
||||
1. `process_for_active`
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
# Snapshot before update_subscription_period() below can roll this forward,
|
||||
# so the cancel_at_period_end check further down still targets the period
|
||||
# that just ended, not the next one.
|
||||
current_period_end = self.current_invoice_end
|
||||
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
@@ -633,8 +625,8 @@ class Subscription(Document):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(current_period_end)
|
||||
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
):
|
||||
self.cancel_subscription()
|
||||
|
||||
|
||||
@@ -614,32 +614,6 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_subscription_cancels_at_period_end_without_end_date(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls
|
||||
# current_invoice_end forward to the next period before this check runs, so
|
||||
# with no end_date to fall back on, cancel_at_period_end must compare
|
||||
# against the period that just ended, not the (already advanced) next one.
|
||||
create_plan(
|
||||
plan_name="_Test plan name 11",
|
||||
cost=80,
|
||||
currency="INR",
|
||||
billing_interval="Day",
|
||||
billing_interval_count=3,
|
||||
)
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
cancel_at_period_end=1,
|
||||
generate_invoice_at="End of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 11", "qty": 1}],
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
period_end = subscription.current_invoice_end
|
||||
|
||||
subscription.process(posting_date=period_end)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
@@ -800,38 +774,6 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
cancel_at_period_end=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
cancelation_date = getdate(subscription.cancelation_date)
|
||||
self.assertIsNotNone(cancelation_date)
|
||||
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
|
||||
|
||||
invoice_count = len(subscription.invoices)
|
||||
subscription.process()
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoice_count)
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
|
||||
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
@@ -94,15 +94,10 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
options: get_party_type_options(),
|
||||
on_change: function () {
|
||||
frappe.query_report.set_filter_value("party", "");
|
||||
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
|
||||
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
|
||||
if (supplier_group_filter) {
|
||||
supplier_group_filter.df.hidden = !is_supplier;
|
||||
}
|
||||
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
|
||||
if (!is_supplier) {
|
||||
frappe.query_report.set_filter_value("supplier_group", []);
|
||||
}
|
||||
frappe.query_report.toggle_filter_display(
|
||||
"supplier_group",
|
||||
frappe.query_report.get_filter_value("party_type") !== "Supplier"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder, scrub
|
||||
from frappe.database.schema import get_definition
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Date, Substring, Sum
|
||||
from frappe.utils import cint, cstr, flt, getdate, nowdate
|
||||
@@ -53,7 +52,6 @@ class ReceivablePayableReport:
|
||||
self.filters = frappe._dict(filters or {})
|
||||
self.qb_selection_filter = []
|
||||
self.ple = qb.DocType("Payment Ledger Entry")
|
||||
self.sales_person_records = None
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
@@ -94,7 +92,6 @@ class ReceivablePayableReport:
|
||||
self.party_type = get_party_types_from_account_type(self.account_type)
|
||||
self.party_details = {}
|
||||
self.invoices = set()
|
||||
self.sales_person_records = None
|
||||
self.skip_total_row = 0
|
||||
self.advance_payment_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
@@ -209,7 +206,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_invoices(self, ple):
|
||||
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
if self.sales_person_records is not None:
|
||||
if self.filters.get("sales_person"):
|
||||
if ple.voucher_no in self.sales_person_records.get(
|
||||
"Sales Invoice", []
|
||||
) or ple.party in self.sales_person_records.get("Customer", []):
|
||||
@@ -240,7 +237,7 @@ class ReceivablePayableReport:
|
||||
]
|
||||
|
||||
def get_voucher_balance(self, ple):
|
||||
if self.sales_person_records is not None:
|
||||
if self.filters.get("sales_person"):
|
||||
if not (
|
||||
ple.party in self.sales_person_records.get("Customer", [])
|
||||
or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
|
||||
@@ -899,54 +896,25 @@ class ReceivablePayableReport:
|
||||
|
||||
self.ple_query = query
|
||||
|
||||
def get_permitted_sales_persons(self, parenttype):
|
||||
if self.account_type != "Receivable":
|
||||
return None
|
||||
|
||||
permissions = frappe.permissions.get_user_permissions(frappe.session.user).get("Sales Person", [])
|
||||
if not permissions:
|
||||
return None
|
||||
|
||||
return get_allowed_docs_for_doctype(permissions, parenttype)
|
||||
|
||||
def get_sales_invoices_or_customers_based_on_sales_person(self):
|
||||
parenttypes = ["Customer", "Sales Invoice"]
|
||||
permitted = {p: self.get_permitted_sales_persons(p) for p in parenttypes}
|
||||
|
||||
if not (self.filters.get("sales_person") or any(p is not None for p in permitted.values())):
|
||||
return
|
||||
|
||||
steam = frappe.qb.DocType("Sales Team")
|
||||
|
||||
scope = []
|
||||
for parenttype in parenttypes:
|
||||
criterion = steam.parenttype == parenttype
|
||||
if (allowed := permitted[parenttype]) is not None:
|
||||
criterion &= steam.sales_person.isin(allowed or [""])
|
||||
scope.append(criterion)
|
||||
|
||||
conditions = [Criterion.any(scope)]
|
||||
|
||||
if self.filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
conditions.append(
|
||||
steam.sales_person.isin(
|
||||
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
|
||||
)
|
||||
|
||||
# nosemgrep
|
||||
records = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, parenttype
|
||||
from `tabSales Team` steam
|
||||
where parenttype in ('Customer', 'Sales Invoice')
|
||||
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
|
||||
""",
|
||||
(lft, rgt),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
records = (
|
||||
frappe.qb.from_(steam)
|
||||
.select(steam.parent, steam.parenttype)
|
||||
.distinct()
|
||||
.where(Criterion.all(conditions))
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
self.sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
self.sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
|
||||
@@ -269,13 +269,9 @@ def add_total_row_account(
|
||||
consolidated=False,
|
||||
add_blank_row=True,
|
||||
):
|
||||
name_key = "account" if consolidated else "section"
|
||||
parent_key = "parent_account" if consolidated else "parent_section"
|
||||
label_str = "'" + str(label) + "'"
|
||||
|
||||
total_row = {
|
||||
f"{name_key}_name": label_str,
|
||||
name_key: label_str,
|
||||
"section_name": "'" + _("{0}").format(label) + "'",
|
||||
"section": "'" + _("{0}").format(label) + "'",
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
@@ -286,15 +282,15 @@ def add_total_row_account(
|
||||
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
|
||||
|
||||
for row in data:
|
||||
if row.get(parent_key):
|
||||
if row.get("parent_section"):
|
||||
for period in period_list:
|
||||
key = period if consolidated else period["key"]
|
||||
total_row.setdefault(key, 0.0)
|
||||
total_row[key] += row.get(key, 0.0)
|
||||
summary_data[label] += row.get(key) or 0.0
|
||||
summary_data[label] += row.get(key)
|
||||
|
||||
total_row.setdefault("total", 0.0)
|
||||
total_row["total"] += row.get("total", 0.0)
|
||||
total_row["total"] += row["total"]
|
||||
|
||||
out.append(total_row)
|
||||
|
||||
@@ -435,6 +431,7 @@ def get_opening_range_using_fiscal_year(company, period_list):
|
||||
|
||||
def get_report_summary(summary_data, currency):
|
||||
report_summary = []
|
||||
|
||||
for label, value in summary_data.items():
|
||||
report_summary.append({"value": value, "label": label, "datatype": "Currency", "currency": currency})
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ frappe.query_reports["General Ledger"] = {
|
||||
fieldname: "include_dimensions",
|
||||
label: __("Consider Accounting Dimensions"),
|
||||
fieldtype: "Check",
|
||||
default: frappe.boot.sysdefaults.disable_include_dimensions ? 0 : 1,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "disable_opening_balance_calculation",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user