mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-19 19:37:56 +00:00
Merge branch 'develop' into fix/issue-55219-bank-reconciliation-document-types
This commit is contained in:
16
.github/POSTGRES_COMPATIBILITY.md
vendored
16
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -60,10 +60,13 @@ Flag a changed query that uses any of these:
|
||||
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
|
||||
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
|
||||
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
|
||||
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
|
||||
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
|
||||
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
|
||||
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
|
||||
- **A direct `.like()`/`.ilike()` on a pypika field (or raw `LIKE`) on a NON-text column** — `idx`,
|
||||
`docstatus`, a date, etc. frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text`
|
||||
operator (`operator does not exist: bigint ~~* unknown`). Cast the column to text first —
|
||||
**`Cast_(col, "varchar")`**, not `Cast(col, "char")` (see below). MariaDB coerces the int
|
||||
implicitly, so the cast is a no-op there. A `["like", …]` filter passed to `get_all`/`get_list`/
|
||||
`qb.get_query`/`reportview` needs no cast: the framework casts non-text fields itself
|
||||
(frappe/frappe#42449).
|
||||
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
|
||||
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
|
||||
Use `VARCHAR` / `Cast_(x, "varchar")`.
|
||||
@@ -192,8 +195,9 @@ pick a bound for a stated reason, and cover the varying-group case with a test.
|
||||
These are auto-handled by the framework and are **not** breaks:
|
||||
|
||||
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
|
||||
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
|
||||
is a hard break, `bigint ILIKE`; see §1.)*
|
||||
case-sensitivity bug. A `["like", …]` filter on a **non-text** field is also cast to text by
|
||||
the framework. *(Exception: a direct `.like()` on a **non-text** pypika field — `idx`,
|
||||
`docstatus` — is a hard break, `bigint ILIKE`; see §1.)*
|
||||
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
|
||||
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
|
||||
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
|
||||
|
||||
30
.github/helper/install.sh
vendored
30
.github/helper/install.sh
vendored
@@ -4,6 +4,36 @@ 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:-}
|
||||
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -121,6 +121,8 @@ 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: |
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,6 +129,8 @@ 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: |
|
||||
|
||||
2
.github/workflows/server-tests-mariadb.yml
vendored
2
.github/workflows/server-tests-mariadb.yml
vendored
@@ -102,6 +102,8 @@ 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
|
||||
|
||||
2
.github/workflows/server-tests-postgres.yml
vendored
2
.github/workflows/server-tests-postgres.yml
vendored
@@ -103,6 +103,8 @@ jobs:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_BRANCH: develop
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Warm up test data
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ 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 {
|
||||
@@ -104,6 +105,10 @@ 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(() => {
|
||||
@@ -153,7 +158,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>
|
||||
<CommandList ref={listRef}>
|
||||
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
|
||||
|
||||
{recommendedAccounts.length > 0 && (
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -149,6 +150,10 @@ 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(() => {
|
||||
@@ -264,7 +269,7 @@ const LinkFieldCombobox = ({
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
|
||||
<CommandList>
|
||||
<CommandList ref={listRef}>
|
||||
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items?.map((result) => (
|
||||
@@ -272,7 +277,7 @@ const LinkFieldCombobox = ({
|
||||
<span className="font-medium">
|
||||
{result.label || result.value}
|
||||
</span>
|
||||
{result.description && <span className="text-xs text-ink-gray-5">
|
||||
{result.description && <span className="text-p-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 { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Form } from "@/components/ui/form"
|
||||
@@ -26,50 +26,109 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const BankBalance = () => {
|
||||
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 bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<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 />
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const OpeningBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const OpeningBalanceRow = () => {
|
||||
const currency = useBankCurrency()
|
||||
const { data, isLoading } = useGetAccountOpeningBalance()
|
||||
|
||||
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>
|
||||
return <BalanceRow label={_("Opening Balance")}>
|
||||
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
}
|
||||
|
||||
const ClosingBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const SystemClosingBalanceRow = () => {
|
||||
const currency = useBankCurrency()
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
return (
|
||||
<StatContainer className="min-w-48">
|
||||
<div className="flex items-start gap-1">
|
||||
<StatLabel>
|
||||
{_("Closing Balance as per system")}
|
||||
</StatLabel>
|
||||
<BalanceRow
|
||||
label={_("Closing (system)")}
|
||||
info={
|
||||
<HoverCard openDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
|
||||
<Info className="size-3.5 text-ink-gray-6" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96" align="start" side="right">
|
||||
<H4 className="text-base">{_("Closing balance as per system")}</H4>
|
||||
@@ -84,15 +143,111 @@ const ClosingBalance = () => {
|
||||
</Paragraph>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</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>
|
||||
}
|
||||
>
|
||||
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
)
|
||||
}
|
||||
|
||||
const Difference = () => {
|
||||
const StatementClosingBalanceRow = () => {
|
||||
|
||||
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()
|
||||
|
||||
@@ -102,16 +257,15 @@ const Difference = () => {
|
||||
|
||||
const isError = difference !== 0
|
||||
|
||||
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>
|
||||
return <BalanceRow label={_("Difference")} emphasis>
|
||||
{isLoading
|
||||
? <BalanceSkeleton />
|
||||
: <BalanceValue emphasis tone={isError ? 'red' : undefined}>{formatCurrency(difference, currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
}
|
||||
|
||||
const ReconcileProgress = () => {
|
||||
/** Reconciliation progress through the selected date range: a count plus a slim bar. */
|
||||
const ReconciledRow = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
@@ -132,75 +286,14 @@ const ReconcileProgress = () => {
|
||||
|
||||
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
|
||||
|
||||
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>
|
||||
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" />
|
||||
</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()
|
||||
@@ -302,7 +395,7 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
|
||||
|
||||
return <div>
|
||||
<Separator className="my-8" />
|
||||
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<p className="text-p-sm text-center pb-2">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -331,4 +424,4 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
|
||||
|
||||
}
|
||||
|
||||
export default BankBalance
|
||||
export default BankAccountBalancePanel
|
||||
|
||||
@@ -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="space-y-4 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -220,8 +220,9 @@ const BankClearanceSummaryView = () => {
|
||||
data={data.message.result}
|
||||
columns={clearanceColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -74,7 +74,10 @@ const BankPicker = ({ className }: { className?: string }) => {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
|
||||
// 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",
|
||||
banks?.length > 4 ? 'pb-2' : '', className,
|
||||
)}
|
||||
style={{
|
||||
@@ -108,12 +111,12 @@ const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
|
||||
role="button"
|
||||
title={`Select ${bank.account_name}`}
|
||||
onClick={onSelect}
|
||||
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
|
||||
// `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',
|
||||
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,107 +5,179 @@ 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, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Command, CommandGroup, 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 { data: fiscalYear } = useFiscalYear()
|
||||
const { fiscalYear } = useFiscalYear()
|
||||
|
||||
const timePeriodOptions = useMemo(() => {
|
||||
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const today = useMemo(() => dayjs().format(DATE_FORMAT), [])
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
const standardOptions: DateOption[] = 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,
|
||||
translatedLabel: dates.translatedLabel
|
||||
isDefault: true,
|
||||
}
|
||||
})
|
||||
|
||||
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 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 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'
|
||||
}
|
||||
|
||||
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]
|
||||
if (!fiscalYear) {
|
||||
return standardOptions
|
||||
}
|
||||
|
||||
return standardOptions
|
||||
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 label = yearsAgo === 0
|
||||
? 'This Fiscal Year'
|
||||
: yearsAgo === 1
|
||||
? 'Last Fiscal Year'
|
||||
: `FY ${yearLabel}`
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// "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]
|
||||
}, [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) {
|
||||
// Check if the from and to dates match any predefined time period
|
||||
for (const period of timePeriodOptions) {
|
||||
for (const period of allOptions) {
|
||||
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
|
||||
return period.label;
|
||||
}
|
||||
@@ -114,10 +186,11 @@ const BankRecDateFilter = () => {
|
||||
} else {
|
||||
return "Date Range";
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, allOptions]);
|
||||
|
||||
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
|
||||
setBankRecDate({ fromDate, toDate })
|
||||
setValue("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -130,7 +203,9 @@ 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}>
|
||||
@@ -141,30 +216,57 @@ const BankRecDateFilter = () => {
|
||||
size='md'
|
||||
className='rounded-e-none border-e-0'
|
||||
role="combobox">
|
||||
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
{allOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-84 p-1" align='start'>
|
||||
<Command>
|
||||
<Command shouldFilter={false}>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -199,77 +301,97 @@ const BankRecDateFilter = () => {
|
||||
}
|
||||
|
||||
const referentialKeywords = ["last", "this", "next", "previous"]
|
||||
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
|
||||
|
||||
const dates = useMemo(() => {
|
||||
if (value) {
|
||||
// Try parsing the value
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
/** 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 ?? {}
|
||||
|
||||
if (parsedDate && parsedDate.length > 0) {
|
||||
const startDate = parsedDate[0].start.date()
|
||||
const endDate = parsedDate[0].end?.date()
|
||||
/**
|
||||
* 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 (!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'))
|
||||
if (!candidate.isAfter(today, 'date') || knownValues.year !== undefined) {
|
||||
return { amount: 0, unit: 'year' as const }
|
||||
}
|
||||
|
||||
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
|
||||
// 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
|
||||
|
||||
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>
|
||||
for (let i = 0; i < 200 && candidate.isAfter(today, 'date'); i++) {
|
||||
candidate = candidate.subtract(step, unit)
|
||||
amount += step
|
||||
}
|
||||
|
||||
return { amount, unit }
|
||||
}
|
||||
|
||||
export default BankRecDateFilter
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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="space-y-4 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -201,16 +201,18 @@ const BankReconciliationStatementView = () => {
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && <SummarySection data={data} />}
|
||||
{data && <div className="shrink-0"><SummarySection data={data} /></div>}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<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>
|
||||
<ListView
|
||||
data={statementRows}
|
||||
columns={statementColumns}
|
||||
getRowId={(row) => row.payment_entry}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
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="space-y-2 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<div className="flex shrink-0 gap-2 justify-between items-center">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -278,8 +278,9 @@ const BankTransactionListView = () => {
|
||||
data={filteredResults}
|
||||
columns={transactionColumns}
|
||||
getRowId={(row) => row.name}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
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="space-y-4 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
@@ -198,13 +198,15 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<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>
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={incorrectlyClearedColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
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 = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const MatchAndReconcile = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!selectedBank) {
|
||||
@@ -52,15 +52,15 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
}
|
||||
|
||||
return <>
|
||||
<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 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>
|
||||
<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} />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
<TransferModal />
|
||||
@@ -69,16 +69,19 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
</>
|
||||
}
|
||||
|
||||
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
@@ -100,8 +103,7 @@ function VirtualizedListBody<T>({
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto contain-strict"
|
||||
style={{ height }}
|
||||
className="min-h-0 flex-1 overflow-auto contain-strict"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
@@ -123,7 +125,7 @@ function VirtualizedListBody<T>({
|
||||
)
|
||||
}
|
||||
|
||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const UnreconciledTransactions = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
@@ -187,14 +189,13 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
const listHeight = contentHeight - 72
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
}
|
||||
|
||||
return <div className="space-y-1">
|
||||
<div className="flex py-2 w-full gap-2">
|
||||
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">
|
||||
|
||||
<InputGroup variant='outline'>
|
||||
<label className="sr-only">{_("Search transactions")}</label>
|
||||
@@ -278,7 +279,6 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
|
||||
<VirtualizedListBody
|
||||
items={results}
|
||||
height={listHeight}
|
||||
estimateSize={74}
|
||||
getItemKey={(transaction) => transaction.name}
|
||||
>
|
||||
@@ -381,7 +381,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
|
||||
}
|
||||
|
||||
|
||||
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const VouchersSection = () => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
@@ -402,8 +402,8 @@ const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
|
||||
return <OptionsForMultipleTransactions transactions={selectedTransactions} />
|
||||
}
|
||||
|
||||
return <div style={{ minHeight: contentHeight }} className="mt-2">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} />
|
||||
return <div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -535,11 +535,11 @@ const OptionsForMultipleTransactions = ({ transactions }: { transactions: Unreco
|
||||
}
|
||||
|
||||
|
||||
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
const OptionsForSingleTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
|
||||
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
|
||||
|
||||
return <div className="flex flex-col gap-3">
|
||||
return <div className="flex min-h-0 flex-1 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, contentHeight }: { transacti
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
|
||||
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} />
|
||||
<VouchersForTransaction transaction={transaction} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -774,12 +774,11 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
)
|
||||
}
|
||||
|
||||
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
const voucherList = vouchers?.message ?? []
|
||||
const listHeight = contentHeight - 120
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
@@ -801,8 +800,8 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="relative space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
|
||||
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">
|
||||
<Separator className="flex-1" />
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
@@ -818,7 +817,6 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
</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-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
<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}
|
||||
{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-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
<span className='text-p-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,10 +83,13 @@ 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() ?? ""
|
||||
@@ -112,7 +115,9 @@ 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...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
{loading ? _("Importing...") : data.final_transactions?.length === 1
|
||||
? _("Import 1 transaction")
|
||||
: _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-start gap-4'>
|
||||
@@ -129,7 +134,9 @@ 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'>{_("Importing {0} transactions", [progress.toString()])}
|
||||
<span className='text-sm'>{imported.total === 1
|
||||
? _("Importing 1 transaction")
|
||||
: _("Importing {0} of {1} transactions", [imported.current.toString(), imported.total.toString()])}
|
||||
</span>
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ function ListViewInner<TData>({
|
||||
)}
|
||||
role="columnheader"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<div className="min-w-0 flex-1 truncate leading-snug">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useMemo } from "react"
|
||||
import dayjs from "dayjs"
|
||||
import { useCurrentCompany } from "./useCurrentCompany"
|
||||
|
||||
const useFiscalYear = () => {
|
||||
|
||||
return useFrappeGetCall("erpnext.accounts.utils.get_fiscal_year", undefined, 'fiscal_year', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false
|
||||
})
|
||||
|
||||
export type FiscalYear = {
|
||||
name: string
|
||||
year_start_date: string
|
||||
year_end_date: string
|
||||
}
|
||||
|
||||
export default useFiscalYear
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
23
banking/src/hooks/useResetScrollOnSearch.ts
Normal file
23
banking/src/hooks/useResetScrollOnSearch.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./styles/scroll-fade.css";
|
||||
|
||||
@font-face {
|
||||
font-family: InterVariable;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankAccountBalancePanel 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,10 +9,9 @@ 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, useLayoutEffect, useRef, useState } from "react"
|
||||
import { lazy, Suspense } 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"
|
||||
@@ -25,23 +24,13 @@ 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>
|
||||
<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">
|
||||
{/* 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="flex items-center gap-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -54,7 +43,7 @@ const BankReconciliation = () => {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
<div className="flex gap-1 items-center">
|
||||
{_("Banking")} <Badge theme="violet" variant="subtle">{_("Beta")}</Badge>
|
||||
{_("Banking")}
|
||||
</div>
|
||||
|
||||
</BreadcrumbPage>
|
||||
@@ -71,10 +60,8 @@ const BankReconciliation = () => {
|
||||
<BankRecDateFilter />
|
||||
</div>
|
||||
</div>
|
||||
<BankPicker />
|
||||
<BankBalance />
|
||||
</div>
|
||||
<BankRecTabs remainingHeightAfterTabs={remainingHeightAfterTabs} />
|
||||
<BankRecWorkspace />
|
||||
<BankTransactionUnreconcileModal />
|
||||
</div>
|
||||
<div className="md:hidden flex h-screen items-center justify-between">
|
||||
@@ -104,42 +91,53 @@ const BankReconciliation = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: number }) => {
|
||||
const BankRecWorkspace = () => {
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!selectedBankAccount) {
|
||||
return null
|
||||
}
|
||||
|
||||
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" />
|
||||
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>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
{selectedBankAccount && <BankAccountBalancePanel />}
|
||||
</div>
|
||||
|
||||
{selectedBankAccount && <>
|
||||
<TabsContent value="Match and Reconcile" className="flex min-h-0 flex-col">
|
||||
<MatchAndReconcile />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
<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>
|
||||
</>}
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ const StatementImportLog = () => {
|
||||
field: "creation",
|
||||
order: "desc"
|
||||
},
|
||||
limit: 10
|
||||
limit: 20
|
||||
}, bankAccount ? undefined : null, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
94
banking/src/styles/scroll-fade.css
Normal file
94
banking/src/styles/scroll-fade.css
Normal file
@@ -0,0 +1,94 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1489,10 +1489,10 @@ balanced-match@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38:
|
||||
version "2.10.40"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz#f372c8eb36ff4ad0b5e7ae467014abef124554ba"
|
||||
integrity sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==
|
||||
baseline-browser-mapping@^2.11.12:
|
||||
version "2.11.20"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
|
||||
integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
|
||||
|
||||
brace-expansion@^5.0.5:
|
||||
version "5.0.7"
|
||||
@@ -1509,15 +1509,15 @@ brace-expansion@^5.0.8:
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
browserslist@^4.24.0:
|
||||
version "4.28.4"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.4.tgz#dd8b8167a32845ff5f8cd6ce13f5abba16cd04c9"
|
||||
integrity sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==
|
||||
version "4.28.8"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
|
||||
integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
|
||||
dependencies:
|
||||
baseline-browser-mapping "^2.10.38"
|
||||
caniuse-lite "^1.0.30001799"
|
||||
electron-to-chromium "^1.5.376"
|
||||
node-releases "^2.0.48"
|
||||
update-browserslist-db "^1.2.3"
|
||||
baseline-browser-mapping "^2.11.12"
|
||||
caniuse-lite "^1.0.30001809"
|
||||
electron-to-chromium "^1.5.402"
|
||||
node-releases "^2.0.53"
|
||||
update-browserslist-db "^1.3.0"
|
||||
|
||||
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -1527,10 +1527,10 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
|
||||
caniuse-lite@^1.0.30001799:
|
||||
version "1.0.30001800"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz#b896c773e1c39400809415162bb5320371291b36"
|
||||
integrity sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==
|
||||
caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001810"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
|
||||
integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -1697,10 +1697,10 @@ dunder-proto@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
electron-to-chromium@^1.5.376:
|
||||
version "1.5.383"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz#5bd22306497d454103b289b0fef97260c56d0855"
|
||||
integrity sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==
|
||||
electron-to-chromium@^1.5.402:
|
||||
version "1.5.420"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44"
|
||||
integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==
|
||||
|
||||
engine.io-client@~6.5.1:
|
||||
version "6.5.4"
|
||||
@@ -3012,10 +3012,10 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
node-releases@^2.0.48:
|
||||
version "2.0.50"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.50.tgz#597197a852071ce42fc2550e58e223242bcba969"
|
||||
integrity sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==
|
||||
node-releases@^2.0.53:
|
||||
version "2.0.54"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe"
|
||||
integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
@@ -3589,10 +3589,10 @@ unist-util-visit@^5.0.0:
|
||||
unist-util-is "^6.0.0"
|
||||
unist-util-visit-parents "^6.0.0"
|
||||
|
||||
update-browserslist-db@^1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
|
||||
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
|
||||
update-browserslist-db@^1.3.0:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836"
|
||||
integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==
|
||||
dependencies:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"modified": "2025-12-19 12:37:31.673782",
|
||||
"modified": "2026-09-04 12:37:31.673782",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss",
|
||||
@@ -17,7 +17,6 @@
|
||||
"owner": "Administrator",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"roles": [],
|
||||
"show_values_over_chart": 1,
|
||||
"timeseries": 0,
|
||||
"type": "Line",
|
||||
"use_report_chart": 1,
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"description": "Setting Account Type helps in selecting this Account in transactions.",
|
||||
"fieldname": "account_type",
|
||||
"fieldtype": "Select",
|
||||
"in_preview": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Account Type",
|
||||
"oldfieldname": "account_type",
|
||||
@@ -203,7 +204,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:37.851001",
|
||||
"modified": "2026-09-03 12:59:42.190900",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Account",
|
||||
|
||||
@@ -62,7 +62,7 @@ frappe.treeview_settings["Account"] = {
|
||||
|
||||
const flags = [];
|
||||
if (node.data.account_number) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_number, size: "sm" }));
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_number }));
|
||||
}
|
||||
|
||||
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
|
||||
@@ -72,26 +72,21 @@ frappe.treeview_settings["Account"] = {
|
||||
company_currency &&
|
||||
node.data.account_currency !== company_currency
|
||||
) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue", size: "sm" }));
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue" }));
|
||||
}
|
||||
|
||||
if (node.data.freeze_account === "Yes") {
|
||||
flags.push(
|
||||
$(
|
||||
`<span class="inline-flex text-ink-gray-4" title="${__("Frozen — entries restricted")}">
|
||||
${frappe.utils.icon("lock", "sm")}
|
||||
</span>`
|
||||
)[0]
|
||||
frappe.ui.badge({
|
||||
label: __("Frozen"),
|
||||
icon: "lock",
|
||||
title: __("Frozen - entries restricted"),
|
||||
theme: "orange",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (flags.length) {
|
||||
const $flags = $(
|
||||
'<span class="tree-node-flags inline-flex items-center gap-1.5 ms-2 shrink-0"></span>'
|
||||
);
|
||||
flags.forEach((flag) => $flags.append(flag));
|
||||
$flags.insertAfter(node.$tree_link.find("a.tree-label"));
|
||||
}
|
||||
erpnext.utils.render_tree_node_flags(node, flags);
|
||||
},
|
||||
on_node_render: function (node, deep) {
|
||||
const render_balances = () => {
|
||||
|
||||
@@ -102,6 +102,8 @@ def identify_is_group(child):
|
||||
def get_chart(chart_template: str | None, existing_company: str | None = None):
|
||||
chart = {}
|
||||
if existing_company:
|
||||
frappe.has_permission("Company", doc=existing_company, throw=True)
|
||||
|
||||
return get_account_tree_from_existing_company(existing_company)
|
||||
|
||||
elif chart_template == "Standard":
|
||||
|
||||
@@ -225,7 +225,8 @@
|
||||
"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 (%)"
|
||||
"label": "Over Billing Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -805,7 +806,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-14 15:26:49.070889",
|
||||
"modified": "2026-09-04 10:08:30.115003",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -222,6 +222,13 @@ class AccountsSettings(Document):
|
||||
set_allow_on_submit_for_dimension_fields(doctypes)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def get_posting_date_confirmation() -> int:
|
||||
return cint(
|
||||
frappe.db.get_single_value("Accounts Settings", "confirm_before_resetting_posting_date", cache=False)
|
||||
)
|
||||
|
||||
|
||||
def toggle_accounting_dimension_sections(hide):
|
||||
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
|
||||
for doctype in accounting_dimension_doctypes:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.accounts_settings.accounts_settings import get_posting_date_confirmation
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAccountsSettings(ERPNextTestSuite):
|
||||
def test_posting_date_confirmation_uses_current_setting(self):
|
||||
for enabled in (0, 1, 0):
|
||||
frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", enabled)
|
||||
self.assertEqual(get_posting_date_confirmation(), enabled)
|
||||
|
||||
def test_stale_days(self):
|
||||
cur_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
|
||||
cur_settings.allow_stale = 0
|
||||
|
||||
@@ -912,7 +912,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
|
||||
|
||||
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
|
||||
|
||||
if not days:
|
||||
if days is None:
|
||||
days = 3
|
||||
|
||||
min_date = frappe.utils.add_days(date, -days)
|
||||
@@ -1337,9 +1337,11 @@ 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_equality = pe.paid_amount == transaction.unallocated_amount
|
||||
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_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
|
||||
amount_condition = amount_equality if exact_match else amount_field > 0.0
|
||||
|
||||
party_condition = (
|
||||
(pe.party_type == transaction.party_type) & (pe.party == transaction.party) & pe.party.isnotnull()
|
||||
@@ -1356,7 +1358,7 @@ def get_pe_matching_query(
|
||||
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
|
||||
ConstantColumn("Payment Entry").as_("doctype"),
|
||||
pe.name,
|
||||
pe.base_paid_amount_after_tax.as_("paid_amount"),
|
||||
amount_field.as_("paid_amount"),
|
||||
pe.reference_no,
|
||||
pe.reference_date,
|
||||
pe.party,
|
||||
|
||||
@@ -100,13 +100,14 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
||||
def make_bank_transaction(self, date, deposit=100):
|
||||
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",
|
||||
}
|
||||
@@ -115,6 +116,16 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
.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))]
|
||||
@@ -124,6 +135,58 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
bank_transaction = self.make_bank_transaction(date=today())
|
||||
self.assertEqual(get_linked_payments(bank_transaction.name), [])
|
||||
|
||||
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")
|
||||
|
||||
@@ -375,8 +375,7 @@ 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)
|
||||
# Tables with no detectable transactions (ads, summaries, headers) start excluded.
|
||||
table["included"] = bool(final_transactions)
|
||||
table["included"] = should_include_table(table, final_transactions)
|
||||
|
||||
self.pdf_tables = json.dumps(tables)
|
||||
return tables
|
||||
@@ -542,6 +541,8 @@ 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,
|
||||
@@ -551,6 +552,7 @@ class BankStatementImportLog(Document):
|
||||
"bank-rec-statement-import-progress",
|
||||
{
|
||||
"progress": 100,
|
||||
"current": total_transactions,
|
||||
"total": total_transactions,
|
||||
},
|
||||
doctype="Bank Statement Import Log",
|
||||
@@ -821,6 +823,15 @@ 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(
|
||||
@@ -829,44 +840,43 @@ 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 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
|
||||
if marker:
|
||||
return (0, abs(signed_amount)) if marker == "cr" else (abs(signed_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"))
|
||||
amount = get_float_amount(transaction_row.get("amount", "0")) or 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':
|
||||
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
|
||||
# "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)
|
||||
|
||||
if amount_format == 'Transaction type column has "C"/"D" values':
|
||||
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
|
||||
return (0, amount) if transaction_type == "c" else (amount, 0)
|
||||
|
||||
if amount_format == 'Transaction type column has "Deposit"/"Withdrawal" values':
|
||||
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, amount) if "deposit" in transaction_type else (amount, 0)
|
||||
|
||||
return 0, 0
|
||||
|
||||
@@ -910,6 +920,26 @@ 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:
|
||||
@@ -1055,6 +1085,43 @@ 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:
|
||||
@@ -1075,6 +1142,8 @@ 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")
|
||||
|
||||
@@ -1092,33 +1161,40 @@ def get_file_properties(transactions: list):
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
if isinstance(amount, str) and ("cr" in amount.lower() or "dr" in amount.lower()):
|
||||
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
|
||||
amount_format_frequency['Amount 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
|
||||
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
|
||||
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,12 +11,14 @@ 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,
|
||||
)
|
||||
@@ -124,6 +126,184 @@ 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
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -159,7 +339,8 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
else:
|
||||
table["header_index"] = None
|
||||
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||
table["included"] = True
|
||||
final_transactions, _df, _af = build_table_transactions(table)
|
||||
table["included"] = should_include_table(table, final_transactions)
|
||||
return table
|
||||
|
||||
def test_pdf_multi_page_kept_separate_and_unioned(self):
|
||||
@@ -197,6 +378,74 @@ 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 = [
|
||||
|
||||
@@ -8,6 +8,7 @@ from functools import reduce
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.file.utils import find_file_by_url
|
||||
from frappe.desk.form.linked_with import get_linked_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr
|
||||
@@ -58,6 +59,8 @@ def validate_columns(data):
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_company(company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
|
||||
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
|
||||
)
|
||||
@@ -110,7 +113,10 @@ def import_coa(file_name: str, company: str):
|
||||
|
||||
|
||||
def get_file(file_name):
|
||||
file_doc = frappe.get_doc("File", {"file_url": file_name})
|
||||
file_doc = find_file_by_url(file_name)
|
||||
if not file_doc:
|
||||
raise frappe.PermissionError
|
||||
|
||||
parts = file_doc.get_extension()
|
||||
extension = parts[1]
|
||||
extension = extension.lstrip(".")
|
||||
@@ -179,6 +185,8 @@ def get_coa(
|
||||
):
|
||||
"""called by tree view (to fetch node's children)"""
|
||||
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
file_doc, extension = get_file(file_name)
|
||||
parent = None if parent == _("All Accounts") else parent
|
||||
|
||||
@@ -326,6 +334,8 @@ def build_response_as_excel(writer):
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_template(file_type: str, template_type: str, company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
writer = get_template(template_type, company)
|
||||
|
||||
if file_type == "CSV":
|
||||
@@ -378,7 +388,6 @@ def get_sample_template(writer, company):
|
||||
return writer
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_accounts(file_doc: Document, extension: str):
|
||||
if extension == "csv":
|
||||
accounts = generate_data_from_csv(file_doc, as_dict=True)
|
||||
|
||||
@@ -12,6 +12,19 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
],
|
||||
root_label: "Cost Centers",
|
||||
get_tree_nodes: "erpnext.accounts.utils.get_children",
|
||||
get_label: function (node) {
|
||||
// clean display name — the number renders as a badge (see onrender)
|
||||
return frappe.utils.escape_html(node.data.cost_center_name || node.title || node.label);
|
||||
},
|
||||
onrender: function (node) {
|
||||
if (node.is_root || !node.data) return;
|
||||
|
||||
const flags = [];
|
||||
if (node.data.cost_center_number) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.cost_center_number }));
|
||||
}
|
||||
erpnext.utils.render_tree_node_flags(node, flags);
|
||||
},
|
||||
add_tree_node: "erpnext.accounts.utils.add_cc",
|
||||
menu_items: [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,8 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.utils import getdate
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, getdate
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
@@ -147,6 +148,31 @@ class Dunning(AccountsController):
|
||||
)
|
||||
row.dunning_level = len(past_dunnings) + 1
|
||||
|
||||
def get_unpaid_base_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in company currency."""
|
||||
if not self.base_dunning_amount:
|
||||
return 0.0
|
||||
|
||||
return flt(
|
||||
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
|
||||
self.precision("base_dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
|
||||
return flt(
|
||||
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
|
||||
self.precision("dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_overdue_payments(self):
|
||||
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
|
||||
return [
|
||||
(row, outstanding)
|
||||
for row in self.overdue_payments
|
||||
if (outstanding := get_current_outstanding(row)) > 0
|
||||
]
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
self.ignore_linked_doctypes = [
|
||||
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
|
||||
"Unreconcile Payment Entries",
|
||||
"Payment Ledger Entry",
|
||||
"Serial and Batch Bundle",
|
||||
"Payment Entry",
|
||||
]
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -259,11 +286,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
|
||||
if has_outstanding:
|
||||
break
|
||||
|
||||
new_status = "Resolved" if not has_outstanding else "Unresolved"
|
||||
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
|
||||
|
||||
if dunning.status != new_status:
|
||||
dunning.status = new_status
|
||||
dunning.save()
|
||||
|
||||
def update_dunnings_linked_to_payment(payment_entry):
|
||||
"""Refresh dunnings whose interest and fee are settled by this payment."""
|
||||
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
|
||||
|
||||
for name in dunnings:
|
||||
dunning = frappe.get_doc("Dunning", name)
|
||||
if dunning.docstatus != 1:
|
||||
continue
|
||||
|
||||
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
|
||||
|
||||
|
||||
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
|
||||
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
|
||||
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
|
||||
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
|
||||
|
||||
# resolving by hand waives the interest, only an invoice that is owed again reopens it
|
||||
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
|
||||
return
|
||||
|
||||
if dunning.status != new_status:
|
||||
dunning.db_set("status", new_status, notify=True)
|
||||
|
||||
|
||||
def get_paid_dunning_amount(dunning: str) -> float:
|
||||
"""Interest and fee collected for this dunning, in company currency."""
|
||||
deduction = frappe.qb.DocType("Payment Entry Deduction")
|
||||
payment_entry = frappe.qb.DocType("Payment Entry")
|
||||
|
||||
paid = (
|
||||
frappe.qb.from_(deduction)
|
||||
.join(payment_entry)
|
||||
.on(payment_entry.name == deduction.parent)
|
||||
.select(Sum(deduction.amount))
|
||||
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
|
||||
).run()
|
||||
|
||||
# the dunning amount is booked as a negative deduction, against the income account
|
||||
return -flt(paid[0][0]) if paid else 0.0
|
||||
|
||||
|
||||
def get_current_outstanding(overdue_payment) -> float:
|
||||
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
|
||||
invoice = frappe.db.get_value(
|
||||
"Sales Invoice",
|
||||
overdue_payment.sales_invoice,
|
||||
["outstanding_amount", "currency", "party_account_currency"],
|
||||
as_dict=True,
|
||||
)
|
||||
schedule_outstanding = (
|
||||
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
|
||||
if overdue_payment.payment_schedule
|
||||
else flt(overdue_payment.outstanding)
|
||||
)
|
||||
|
||||
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
|
||||
return 0.0
|
||||
|
||||
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
|
||||
if invoice.currency == invoice.party_account_currency:
|
||||
outstanding = min(outstanding, flt(invoice.outstanding_amount))
|
||||
|
||||
return outstanding
|
||||
|
||||
|
||||
def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
|
||||
@@ -55,6 +55,125 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
|
||||
"""
|
||||
Regression for #58220: paying the invoice without the interest and fee must not
|
||||
resolve the dunning, the interest is still owed and has to stay claimable.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "4", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the interest and fee can still be collected on their own
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "5", nowdate()
|
||||
self.assertEqual(pe.references, [])
|
||||
self.assertEqual(round(pe.paid_amount, 2), 10.41)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
|
||||
|
||||
# cancelling the interest payment makes the dunning claimable again
|
||||
pe.cancel()
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
|
||||
"""
|
||||
The payment collecting the interest links back to the dunning, which must not stand in
|
||||
the way of cancelling it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "6", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
dunning.cancel()
|
||||
self.assertEqual(dunning.docstatus, 2)
|
||||
|
||||
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
|
||||
"""
|
||||
Resolving a dunning by hand waives its interest, so a later payment of the invoice
|
||||
must not reopen it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
# what the "Resolve" button does
|
||||
dunning.reload()
|
||||
dunning.status = "Resolved"
|
||||
dunning.save()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "7", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
|
||||
"""
|
||||
The interest and fee are collected as a Payment Entry deduction, a company currency
|
||||
field, so what is left to collect has to be measured in the same currency.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(today(), -15),
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
rate=100,
|
||||
debit_to="Debtors - _TC",
|
||||
)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si.name)
|
||||
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
|
||||
dunning.dunning_type = dunning_type.name
|
||||
dunning.rate_of_interest = dunning_type.rate_of_interest
|
||||
dunning.dunning_fee = dunning_type.dunning_fee
|
||||
dunning.income_account = dunning_type.income_account
|
||||
dunning.cost_center = dunning_type.cost_center
|
||||
dunning.save()
|
||||
|
||||
self.assertEqual(dunning.currency, "USD")
|
||||
self.assertEqual(dunning.conversion_rate, 50)
|
||||
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
|
||||
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
|
||||
|
||||
# nothing collected yet, in either currency
|
||||
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the deduction booking the interest is in company currency
|
||||
dunning.submit()
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
|
||||
|
||||
def test_fetch_overdue_payments(self):
|
||||
"""
|
||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
||||
|
||||
@@ -32,6 +32,7 @@ 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,
|
||||
@@ -490,7 +491,10 @@ class DataCollector:
|
||||
if company:
|
||||
query = query.where(account.company == company)
|
||||
|
||||
if conditions := filter_parser.build_conditions(account_rows, account):
|
||||
# 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:
|
||||
query = query.where(conditions)
|
||||
|
||||
return query.run(pluck=True)
|
||||
@@ -802,17 +806,20 @@ class FilterExpressionParser:
|
||||
def __init__(self):
|
||||
self.validator = AccountFilterValidator()
|
||||
|
||||
def build_conditions(self, report_rows, table):
|
||||
def build_conditions(self, report_rows, table, raise_on_invalid=False):
|
||||
conditions = []
|
||||
for row in report_rows or []:
|
||||
condition = self.build_condition(row, table)
|
||||
condition = self.build_condition(row, table, raise_on_invalid=raise_on_invalid)
|
||||
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):
|
||||
def build_condition(self, report_row, table, raise_on_invalid=False):
|
||||
"""
|
||||
Build SQL condition directly from filter formula.
|
||||
|
||||
@@ -842,9 +849,11 @@ class FilterExpressionParser:
|
||||
if not filter_formula:
|
||||
return None
|
||||
|
||||
errors = self.validator.validate(report_row)
|
||||
errors = self.validator.validate_filter(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
|
||||
|
||||
@@ -1041,7 +1050,11 @@ 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)
|
||||
|
||||
account_rows = [frappe._dict(row) for row in frappe.parse_json(account_rows)]
|
||||
|
||||
@@ -1182,10 +1195,12 @@ class RowProcessor:
|
||||
|
||||
def _process_api_row(self, row) -> RowData:
|
||||
api_path = row.calculation_formula
|
||||
# TODO
|
||||
|
||||
method = get_valid_api_method(api_path)
|
||||
|
||||
try:
|
||||
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
|
||||
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
|
||||
if row.reverse_sign:
|
||||
values = [-1 * v for v in values]
|
||||
|
||||
@@ -372,7 +372,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 method that returns financial data.</p>
|
||||
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
|
||||
|
||||
<h6 ${subtitle_style}>Format:</h6>
|
||||
<ul ${list_style}>
|
||||
@@ -382,7 +382,8 @@ function update_formula_description(frm, data_source) {
|
||||
|
||||
<h6 ${subtitle_style}>Method Signature:</h6>
|
||||
<div ${code_style}>
|
||||
<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>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<h6 ${subtitle_style}>Return Format:</h6>
|
||||
|
||||
@@ -8,10 +8,25 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
|
||||
|
||||
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 = {
|
||||
@@ -175,8 +190,10 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for {1}").format(
|
||||
get_formula_field_label(row.data_source), row.data_source
|
||||
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),
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
@@ -204,7 +221,14 @@ class DependencyValidator(Validator):
|
||||
|
||||
for row in self.template.rows:
|
||||
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
|
||||
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
|
||||
# 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
|
||||
]
|
||||
if deps:
|
||||
graph[row.reference_code] = deps
|
||||
|
||||
@@ -266,7 +290,9 @@ class DependencyValidator(Validator):
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
@@ -293,17 +319,6 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.data_source != "Calculated Amount":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for Calculated Amount").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
@@ -328,16 +343,6 @@ class CalculationFormulaValidator(Validator):
|
||||
)
|
||||
)
|
||||
|
||||
# Check undefined references
|
||||
undefined = set(refs) - set(available_codes)
|
||||
if undefined:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Try to evaluate with dummy values
|
||||
eval_error = self._test_formula_evaluation(formula, available_codes)
|
||||
if eval_error:
|
||||
@@ -395,21 +400,19 @@ class AccountFilterValidator(Validator):
|
||||
self.account_fields = account_fields or set(self.account_meta._valid_columns)
|
||||
|
||||
def validate(self, row) -> ValidationResult:
|
||||
result = ValidationResult()
|
||||
|
||||
# dispatch-path guard: only account-data rows are validated here
|
||||
if row.data_source != "Account Data":
|
||||
return result
|
||||
return ValidationResult()
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for Account Data").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
return result
|
||||
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()
|
||||
|
||||
try:
|
||||
filter_config = json.loads(row.calculation_formula)
|
||||
@@ -422,7 +425,9 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label("Account Data"), error
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -430,8 +435,9 @@ class AccountFilterValidator(Validator):
|
||||
except json.JSONDecodeError as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0}: Invalid JSON format: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label("Account Data"),
|
||||
_("Invalid JSON format: {0}").format(str(e)),
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
@@ -455,12 +461,9 @@ class AccountFilterValidator(Validator):
|
||||
if not isinstance(field, str) or not isinstance(operator, str):
|
||||
return _("Field and operator must be strings")
|
||||
|
||||
display = (
|
||||
field if advanced_filtering else self.account_meta.get_translated_label(field)
|
||||
) or field
|
||||
|
||||
if field not in account_fields:
|
||||
return _("Field '{0}' is not a valid Account field").format(display)
|
||||
# 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))
|
||||
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
@@ -531,29 +534,24 @@ class FormulaValidator(Validator):
|
||||
)
|
||||
return result
|
||||
|
||||
# Method exists?
|
||||
try:
|
||||
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=_(
|
||||
"{0}: Method '{1}' not found in module '{2}' (might be environment-specific)"
|
||||
).format(get_formula_field_label(row.data_source), method_name, module_path),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
get_valid_api_method(api_path)
|
||||
except Exception as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Could not validate {0}: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
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),
|
||||
)
|
||||
)
|
||||
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,7 +2,13 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -72,3 +78,173 @@ 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))
|
||||
|
||||
@@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", {
|
||||
],
|
||||
primary_action: function () {
|
||||
var data = d.get_values();
|
||||
data.company = frm.doc.company;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_import": 1,
|
||||
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
|
||||
"creation": "2019-03-07 12:01:56.296952",
|
||||
@@ -170,7 +171,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:52.746196",
|
||||
"modified": "2026-09-09 17:04:59.512294",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Invoice Discounting",
|
||||
@@ -187,14 +188,15 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
|
||||
@frappe.whitelist()
|
||||
def get_invoices(filters: str | dict):
|
||||
filters = frappe._dict(frappe.parse_json(filters))
|
||||
|
||||
if not filters.get("company"):
|
||||
frappe.throw(_("Please set company on the Document before requesting for invoices."))
|
||||
|
||||
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
|
||||
frappe.has_permission("Invoice Discounting", throw=True)
|
||||
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
di = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
|
||||
@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
|
||||
total_credit += flt(row.credit, precision("credit", row));
|
||||
});
|
||||
|
||||
frm.doc.total_debit = total_debit;
|
||||
frm.doc.total_credit = total_credit;
|
||||
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
|
||||
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
|
||||
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
|
||||
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
|
||||
},
|
||||
|
||||
@@ -674,12 +674,14 @@ class JournalEntry(AccountsController):
|
||||
if d.debit and d.credit:
|
||||
frappe.throw(_("You cannot credit and debit same account at the same time"))
|
||||
|
||||
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
|
||||
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
|
||||
self.total_debit = flt(
|
||||
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
|
||||
)
|
||||
self.total_credit = flt(
|
||||
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
|
||||
)
|
||||
|
||||
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
|
||||
self.total_credit, self.precision("total_credit")
|
||||
)
|
||||
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
|
||||
|
||||
def validate_multi_currency(self):
|
||||
alternate_currency = []
|
||||
|
||||
@@ -461,6 +461,59 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def make_jv_with_fractional_totals(self):
|
||||
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = nowdate()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = "Journal Entry"
|
||||
jv.remark = "test"
|
||||
for amount in (0.10, 0.20):
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 0.30,
|
||||
},
|
||||
)
|
||||
jv.insert()
|
||||
return jv
|
||||
|
||||
def test_totals_are_rounded_to_precision(self):
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
stored = frappe.db.get_value(
|
||||
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
|
||||
)
|
||||
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
|
||||
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
|
||||
self.assertEqual(jv.total_debit, stored.total_debit)
|
||||
self.assertEqual(jv.total_credit, stored.total_credit)
|
||||
self.assertEqual(jv.difference, stored.difference)
|
||||
|
||||
def test_update_after_submit_with_fractional_totals(self):
|
||||
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
jv.pay_to_recd_from = "_Test Supplier"
|
||||
jv.save()
|
||||
|
||||
self.assertEqual(jv.docstatus, 1)
|
||||
self.assertEqual(
|
||||
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
|
||||
)
|
||||
|
||||
def test_jv_account_and_party_balance_with_cost_centre(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
@@ -97,6 +97,8 @@ def get_loyalty_program_details_with_points(
|
||||
include_expired_entry: bool = False,
|
||||
current_transaction_amount: int | float = 0,
|
||||
):
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
lp_details = get_loyalty_program_details(customer, loyalty_program, company=company, silent=silent)
|
||||
loyalty_program = frappe.get_doc("Loyalty Program", loyalty_program)
|
||||
loyalty_details = get_loyalty_details(
|
||||
|
||||
@@ -46,23 +46,27 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.set_query("paid_from", function () {
|
||||
frm.set_query("paid_from", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
|
||||
filters.name = ["!=", doc.paid_to];
|
||||
}
|
||||
|
||||
return {
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -106,21 +110,25 @@ frappe.ui.form.on("Payment Entry", {
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query("paid_to", function () {
|
||||
frm.set_query("paid_to", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
|
||||
filters.name = ["!=", doc.paid_from];
|
||||
}
|
||||
return {
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ class PaymentEntry(AccountsController):
|
||||
self.set_liability_account()
|
||||
self.set_missing_ref_details(force=True)
|
||||
self.validate_payment_type()
|
||||
self.validate_internal_transfer_accounts()
|
||||
self.validate_party_details()
|
||||
self.set_exchange_rate()
|
||||
self.validate_mandatory()
|
||||
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule()
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_linked_dunnings(self):
|
||||
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
|
||||
|
||||
update_dunnings_linked_to_payment(self)
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
@@ -315,6 +322,7 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule(cancel=1)
|
||||
self.make_gl_entries(cancel=1)
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
@@ -627,6 +635,10 @@ class PaymentEntry(AccountsController):
|
||||
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
|
||||
|
||||
def validate_internal_transfer_accounts(self):
|
||||
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
|
||||
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
|
||||
|
||||
def validate_party_details(self):
|
||||
if self.party and not frappe.db.exists(self.party_type, self.party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
|
||||
@@ -2725,7 +2737,7 @@ def get_payment_entry(
|
||||
pe.append("references", reference)
|
||||
else:
|
||||
if dt == "Dunning":
|
||||
for overdue_payment in doc.overdue_payments:
|
||||
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
@@ -2733,21 +2745,23 @@ def get_payment_entry(
|
||||
"reference_name": overdue_payment.sales_invoice,
|
||||
"payment_term": overdue_payment.payment_term,
|
||||
"due_date": overdue_payment.due_date,
|
||||
"total_amount": overdue_payment.outstanding,
|
||||
"outstanding_amount": overdue_payment.outstanding,
|
||||
"allocated_amount": overdue_payment.outstanding,
|
||||
"total_amount": outstanding,
|
||||
"outstanding_amount": outstanding,
|
||||
"allocated_amount": outstanding,
|
||||
},
|
||||
)
|
||||
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * doc.dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
},
|
||||
)
|
||||
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * unpaid_dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
"dunning": doc.name,
|
||||
},
|
||||
)
|
||||
else:
|
||||
pe.append(
|
||||
"references",
|
||||
@@ -3040,8 +3054,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
|
||||
grand_total = doc.rounded_total or doc.grand_total
|
||||
outstanding_amount = doc.outstanding_amount
|
||||
elif dt == "Dunning":
|
||||
grand_total = doc.grand_total
|
||||
outstanding_amount = doc.grand_total
|
||||
# only what is left to collect, the totals on the dunning are the ones it was raised with
|
||||
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
|
||||
grand_total += doc.get_unpaid_dunning_amount()
|
||||
outstanding_amount = grand_total
|
||||
else:
|
||||
if party_account_currency == doc.company_currency:
|
||||
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
||||
|
||||
@@ -782,6 +782,23 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_internal_transfer_rejects_same_account(self):
|
||||
pe = frappe.new_doc("Payment Entry")
|
||||
pe.payment_type = "Internal Transfer"
|
||||
pe.company = "_Test Company"
|
||||
pe.paid_from = "_Test Bank - _TC"
|
||||
pe.paid_to = "_Test Bank - _TC"
|
||||
pe.paid_amount = 100
|
||||
pe.received_amount = 100
|
||||
pe.reference_no = "same-account-transfer"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Paid From and Paid To accounts must be different",
|
||||
pe.insert,
|
||||
)
|
||||
|
||||
def test_bank_charges_deduction(self):
|
||||
bank_charges_account = create_account(
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"amount",
|
||||
"column_break_2",
|
||||
"is_exchange_gain_loss",
|
||||
"description"
|
||||
"description",
|
||||
"dunning"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -55,12 +56,21 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "System Generated",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dunning",
|
||||
"fieldtype": "Link",
|
||||
"label": "Dunning",
|
||||
"no_copy": 1,
|
||||
"options": "Dunning",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-11 14:26:11.312950",
|
||||
"modified": "2026-08-17 11:20:35.482913",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Deduction",
|
||||
|
||||
@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
|
||||
amount: DF.Currency
|
||||
cost_center: DF.Link
|
||||
description: DF.SmallText | None
|
||||
dunning: DF.Link | None
|
||||
is_exchange_gain_loss: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
|
||||
@@ -5,7 +5,7 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
onload: function (frm) {
|
||||
if (!frm.doc.transaction_date) frm.doc.transaction_date = frappe.datetime.obj_to_str(new Date());
|
||||
|
||||
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher"];
|
||||
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher", "MapReduce Job"];
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
|
||||
@@ -5,9 +5,20 @@
|
||||
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 import _, qb
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Count, Max, Min, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
ceil,
|
||||
cint,
|
||||
flt,
|
||||
fmt_money,
|
||||
formatdate,
|
||||
get_datetime,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
)
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
@@ -265,8 +276,17 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.make_gl_entries()
|
||||
else:
|
||||
ppcv = frappe.get_doc({"doctype": "Process Period Closing Voucher", "parent_pcv": self.name})
|
||||
ppcv.save().submit()
|
||||
from frappe.utils.background_jobs import mapreduce
|
||||
|
||||
data = self.get_data_for_mapreduce()
|
||||
mapreduce(
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
|
||||
data,
|
||||
self.doctype,
|
||||
self.name,
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = (
|
||||
@@ -275,11 +295,16 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Payment Ledger Entry",
|
||||
"Account Closing Balance",
|
||||
"Process Period Closing Voucher",
|
||||
"MapReduce Job",
|
||||
)
|
||||
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.validate_accounts_not_frozen(for_cancellation=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
from frappe.utils.background_jobs import cancel_mapreduce_job
|
||||
|
||||
cancel_mapreduce_job(self.doctype, self.name)
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
@@ -292,6 +317,11 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
def on_trash(self):
|
||||
super().on_trash()
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
from frappe.utils.background_jobs import remove_mapreduce_job
|
||||
|
||||
remove_mapreduce_job(self.doctype, self.name)
|
||||
|
||||
ppcvs = frappe.db.get_all(
|
||||
"Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": ["in", [1, 2]]}
|
||||
)
|
||||
@@ -594,6 +624,135 @@ class PeriodClosingVoucher(AccountsController):
|
||||
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
|
||||
)
|
||||
|
||||
def get_data_for_mapreduce(self):
|
||||
return self.generate_tasks_for_normal_balance() + self.generate_tasks_for_opening_balance()
|
||||
|
||||
def get_period_range_for_tasks(self, start_date, end_date, step_size, report_type, balance_type):
|
||||
start_date = getdate(start_date)
|
||||
end_date = getdate(end_date)
|
||||
|
||||
# split period into date ranges
|
||||
curr_date = getdate(start_date)
|
||||
date_splits = []
|
||||
while True:
|
||||
next_date = getdate(add_days(curr_date, step_size))
|
||||
if next_date < end_date:
|
||||
date_splits.append(
|
||||
{
|
||||
"from_date": str(curr_date),
|
||||
"to_date": str(next_date),
|
||||
"pcv": self.name,
|
||||
"report_type": report_type,
|
||||
"balance_type": balance_type,
|
||||
}
|
||||
)
|
||||
curr_date = getdate(add_days(next_date, 1))
|
||||
else:
|
||||
date_splits.append(
|
||||
{
|
||||
"from_date": str(curr_date),
|
||||
"to_date": str(end_date),
|
||||
"pcv": self.name,
|
||||
"report_type": report_type,
|
||||
"balance_type": balance_type,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return date_splits
|
||||
|
||||
def generate_tasks_for_normal_balance(self):
|
||||
# estimation can be wrong by a factor of 2
|
||||
gl = qb.DocType("GL Entry")
|
||||
raw_query = (
|
||||
qb.from_(gl)
|
||||
.select(Count(gl.star))
|
||||
.where(
|
||||
gl.is_cancelled.eq(0) & gl.posting_date.between(self.period_start_date, self.period_end_date)
|
||||
)
|
||||
.get_sql()
|
||||
)
|
||||
|
||||
# estimation can be wrong by a factor of 2
|
||||
correction_factor = 2
|
||||
if frappe.db.db_type == "postgres":
|
||||
analyzer = frappe.json.loads(
|
||||
(
|
||||
frappe.db.sql(
|
||||
f"explain (format json) {raw_query}",
|
||||
)
|
||||
)[0][0]
|
||||
)
|
||||
|
||||
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
|
||||
else:
|
||||
estimated_count = (
|
||||
cint(
|
||||
frappe.db.sql(
|
||||
f"explain {raw_query}",
|
||||
as_dict=True,
|
||||
)[0].rows
|
||||
)
|
||||
* correction_factor
|
||||
)
|
||||
|
||||
job_count = (
|
||||
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
|
||||
) # conservative chunk size
|
||||
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
|
||||
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
|
||||
return self.get_period_range_for_tasks(
|
||||
self.period_start_date, self.period_end_date, step_size, "Balance Sheet", "Normal Balance"
|
||||
) + self.get_period_range_for_tasks(
|
||||
self.period_start_date, self.period_end_date, step_size, "Profit and Loss", "Normal Balance"
|
||||
)
|
||||
|
||||
def generate_tasks_for_opening_balance(self):
|
||||
tasks = []
|
||||
if self.is_first_period_closing_voucher():
|
||||
gl = qb.DocType("GL Entry")
|
||||
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
|
||||
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
|
||||
|
||||
raw_query = (
|
||||
qb.from_(gl)
|
||||
.select(Count(gl.star))
|
||||
.where(gl.is_cancelled.eq(0) & gl.is_opening.eq("Yes") & gl.posting_date.between(min, max))
|
||||
.get_sql()
|
||||
)
|
||||
|
||||
# estimation can be wrong by a factor of 2
|
||||
correction_factor = 2
|
||||
if frappe.db.db_type == "postgres":
|
||||
analyzer = frappe.json.loads(
|
||||
(
|
||||
frappe.db.sql(
|
||||
f"explain (format json) {raw_query}",
|
||||
)
|
||||
)[0][0]
|
||||
)
|
||||
|
||||
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
|
||||
else:
|
||||
estimated_count = (
|
||||
cint(
|
||||
frappe.db.sql(
|
||||
f"explain {raw_query};",
|
||||
as_dict=True,
|
||||
)[0].rows
|
||||
)
|
||||
* correction_factor
|
||||
)
|
||||
|
||||
job_count = (
|
||||
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
|
||||
) # conservative chunk size
|
||||
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
|
||||
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
|
||||
tasks = self.get_period_range_for_tasks(min, max, step_size, "Balance Sheet", "Opening Balance")
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def process_gl_and_closing_entries(doc):
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
@@ -673,3 +832,119 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
|
||||
order_by="period_end_date desc",
|
||||
)
|
||||
return prev_closed_period_end_date
|
||||
|
||||
|
||||
def mapper(val):
|
||||
start_date = val.from_date
|
||||
end_date = val.to_date
|
||||
pcv = val.pcv
|
||||
report_type = val.report_type
|
||||
balance_type = val.balance_type
|
||||
company = frappe.db.get_value("Period Closing Voucher", pcv, "company")
|
||||
dimensions = get_dimensions()
|
||||
|
||||
accounts = frappe.db.get_all(
|
||||
"Account", filters={"company": company, "report_type": report_type}, pluck="name"
|
||||
)
|
||||
|
||||
gle = qb.DocType("GL Entry")
|
||||
query = qb.from_(gle).select(gle.account)
|
||||
for dim in dimensions:
|
||||
query = query.select(gle[dim])
|
||||
query = query.select(
|
||||
Sum(gle.debit).as_("debit"),
|
||||
Sum(gle.credit).as_("credit"),
|
||||
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
|
||||
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
|
||||
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
|
||||
Max(gle.account_currency).as_("account_currency"),
|
||||
ConstantColumn(balance_type).as_("balance_type"),
|
||||
ConstantColumn(report_type).as_("report_type"),
|
||||
).where(
|
||||
(gle.company.eq(company))
|
||||
& (gle.is_cancelled.eq(0))
|
||||
& (gle.posting_date.between(start_date, end_date))
|
||||
& (gle.account.isin(accounts))
|
||||
)
|
||||
|
||||
if balance_type == "Opening Balance":
|
||||
query = query.where(gle.is_opening.eq("Yes"))
|
||||
else:
|
||||
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
|
||||
query = query.where(gle.is_opening.eq("No"))
|
||||
|
||||
query = query.groupby(gle.account)
|
||||
for dim in dimensions:
|
||||
query = query.groupby(gle[dim])
|
||||
|
||||
res = query.run(as_dict=True)
|
||||
return res
|
||||
|
||||
|
||||
def reducer(final, partial_res):
|
||||
if final is None:
|
||||
final = []
|
||||
|
||||
if partial_res:
|
||||
final.extend([frappe._dict(x) for x in partial_res])
|
||||
|
||||
return final
|
||||
|
||||
|
||||
def get_dimensions():
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
|
||||
default_dimensions = ["cost_center", "finance_book", "project"]
|
||||
dimensions = default_dimensions + get_accounting_dimensions()
|
||||
return dimensions
|
||||
|
||||
|
||||
def summarize_and_post_ledger(result, ref_dt, ref_dn):
|
||||
pcv = frappe.get_doc(ref_dt, ref_dn)
|
||||
|
||||
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
|
||||
build_dimension_wise_balance_dict,
|
||||
get_bs_closing_entries,
|
||||
get_closing_account_closing_entry,
|
||||
get_gle_for_closing_account,
|
||||
get_gle_for_pl_account,
|
||||
get_p_l_closing_entries,
|
||||
)
|
||||
|
||||
result = [frappe._dict(x) for x in result]
|
||||
|
||||
# generate and post closing entries for P&L accounts
|
||||
pl_entries = [x for x in result if x.report_type == "Profit and Loss"]
|
||||
pl_dimension_wise_acc_balance = build_dimension_wise_balance_dict(pl_entries)
|
||||
|
||||
# build gl map
|
||||
pl_accounts_reverse_gle = []
|
||||
closing_account_gle = []
|
||||
|
||||
for dimensions, account_balances in pl_dimension_wise_acc_balance.items():
|
||||
for acc, balances in account_balances.items():
|
||||
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
|
||||
if balance_in_company_currency:
|
||||
pl_accounts_reverse_gle.append(get_gle_for_pl_account(pcv, acc, balances, dimensions))
|
||||
|
||||
closing_account_gle.append(get_gle_for_closing_account(pcv, account_balances["balances"], dimensions))
|
||||
|
||||
gl_entries = pl_accounts_reverse_gle + closing_account_gle
|
||||
if gl_entries:
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
|
||||
make_gl_entries(gl_entries, merge_entries=False)
|
||||
|
||||
# generate and post account closing balance for balance sheet accounts
|
||||
bs_entries = [x for x in result if x.report_type == "Balance Sheet"]
|
||||
bs_dimension_wise_acc_balance = build_dimension_wise_balance_dict(bs_entries)
|
||||
pl_closing_entries = get_p_l_closing_entries(pl_accounts_reverse_gle, pcv)
|
||||
bs_closing_entries = get_bs_closing_entries(bs_dimension_wise_acc_balance, pcv)
|
||||
closing_entries_for_closing_account = get_closing_account_closing_entry(closing_account_gle, pcv)
|
||||
closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from frappe import _
|
||||
|
||||
|
||||
def get_data():
|
||||
return {
|
||||
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
|
||||
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
|
||||
}
|
||||
@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
const me = this;
|
||||
super.refresh();
|
||||
|
||||
hide_fields(this.frm.doc);
|
||||
hide_fields(this.frm);
|
||||
// Show / Hide button
|
||||
this.show_general_ledger();
|
||||
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
|
||||
@@ -418,7 +418,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
|
||||
is_paid() {
|
||||
hide_fields(this.frm.doc);
|
||||
hide_fields(this.frm);
|
||||
if (cint(this.frm.doc.is_paid)) {
|
||||
this.frm.set_value("allocate_advances_automatically", 0);
|
||||
this.frm.set_value("payment_terms_template", "");
|
||||
@@ -482,28 +482,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
|
||||
|
||||
// Hide Fields
|
||||
// ------------
|
||||
function hide_fields(doc) {
|
||||
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
|
||||
function hide_fields(frm) {
|
||||
const doc = frm.doc;
|
||||
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
|
||||
|
||||
if (cint(doc.is_paid) == 1) {
|
||||
hide_field(parent_fields);
|
||||
frm.toggle_display(parent_fields, false);
|
||||
} else {
|
||||
for (var i in parent_fields) {
|
||||
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
|
||||
if (!docfield.hidden) unhide_field(parent_fields[i]);
|
||||
for (const fieldname of parent_fields) {
|
||||
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
|
||||
if (!docfield.hidden) frm.toggle_display(fieldname, true);
|
||||
}
|
||||
}
|
||||
|
||||
var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
|
||||
const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
|
||||
|
||||
if (cur_frm.fields_dict["items"]) {
|
||||
cur_frm.fields_dict["items"].grid.set_column_disp(
|
||||
item_fields_stock,
|
||||
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
|
||||
);
|
||||
if (frm.fields_dict["items"]) {
|
||||
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
|
||||
}
|
||||
|
||||
cur_frm.refresh_fields();
|
||||
frm.refresh_fields();
|
||||
}
|
||||
|
||||
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
|
||||
@@ -712,7 +710,7 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
},
|
||||
|
||||
update_stock: function (frm) {
|
||||
hide_fields(frm.doc);
|
||||
hide_fields(frm);
|
||||
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
|
||||
},
|
||||
|
||||
|
||||
@@ -3061,6 +3061,23 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
self.assertRaises(StockOverReturnError, return_doc.save)
|
||||
|
||||
def test_partial_returns_ignore_received_qty_without_update_stock(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
invoice = make_purchase_invoice(qty=10, received_qty=10)
|
||||
|
||||
first_return = make_return_doc(invoice.doctype, invoice.name)
|
||||
first_return.items[0].qty = -4
|
||||
first_return.save().submit()
|
||||
|
||||
self.assertEqual(first_return.items[0].received_qty, -10)
|
||||
|
||||
second_return = make_return_doc(invoice.doctype, invoice.name)
|
||||
second_return.items[0].qty = -6
|
||||
second_return.save().submit()
|
||||
|
||||
self.assertEqual(second_return.docstatus, 1)
|
||||
|
||||
def test_apply_discount_on_grand_total(self):
|
||||
"""
|
||||
To test if after applying discount on grand total,
|
||||
|
||||
@@ -123,6 +123,8 @@ class RepostPaymentLedger(Document):
|
||||
def execute_repost_payment_ledger(docname: str):
|
||||
"""Repost Payment Ledger Entries by background job."""
|
||||
|
||||
frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True)
|
||||
|
||||
job_name = "payment_ledger_repost_" + docname
|
||||
|
||||
frappe.enqueue(
|
||||
|
||||
@@ -368,7 +368,6 @@ 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,
|
||||
};
|
||||
|
||||
@@ -387,6 +386,8 @@ 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"],
|
||||
|
||||
@@ -106,6 +106,8 @@ def get_linked_payments_for_doc(
|
||||
company: str | None = None, doctype: str | None = None, docname: str | None = None
|
||||
) -> list:
|
||||
if company and doctype and docname:
|
||||
frappe.has_permission(doctype, doc=docname, throw=True)
|
||||
|
||||
_dt = doctype
|
||||
_dn = docname
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
|
||||
@@ -26,6 +26,7 @@ import erpnext
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen
|
||||
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
|
||||
try:
|
||||
@@ -394,12 +395,17 @@ def set_other_values(party_details, party, party_type):
|
||||
|
||||
|
||||
def get_default_price_list(party):
|
||||
"""Return default price list for party (Document object)"""
|
||||
if party.get("default_price_list"):
|
||||
return party.default_price_list
|
||||
"""Return the first enabled default price list for party (Document object)"""
|
||||
price_list = party.get("default_price_list")
|
||||
if is_price_list_enabled(price_list):
|
||||
return price_list
|
||||
|
||||
if party.doctype == "Customer":
|
||||
return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
|
||||
if party.doctype != "Customer":
|
||||
return
|
||||
|
||||
price_list = frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
|
||||
if is_price_list_enabled(price_list):
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
|
||||
@@ -412,7 +418,7 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
|
||||
elif pos and party_type == "Customer":
|
||||
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
|
||||
|
||||
if customer_price_list:
|
||||
if is_price_list_enabled(customer_price_list):
|
||||
price_list = customer_price_list
|
||||
else:
|
||||
pos_price_list = frappe.get_value("POS Profile", pos, "selling_price_list")
|
||||
@@ -420,6 +426,9 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
|
||||
else:
|
||||
price_list = get_default_price_list(party) or given_price_list
|
||||
|
||||
if price_list and not is_price_list_enabled(price_list):
|
||||
price_list = None
|
||||
|
||||
if price_list:
|
||||
party_details.price_list_currency = frappe.db.get_value(
|
||||
"Price List", price_list, "currency", cache=True
|
||||
|
||||
@@ -171,6 +171,7 @@ class ReceivablePayableReport:
|
||||
party_account=ple.account,
|
||||
posting_date=ple.posting_date,
|
||||
account_currency=ple.account_currency,
|
||||
cost_center=ple.cost_center,
|
||||
remarks=ple.remarks,
|
||||
invoiced=0.0,
|
||||
paid=0.0,
|
||||
|
||||
@@ -1337,6 +1337,28 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
row = report[1][0]
|
||||
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
|
||||
|
||||
def test_cost_center_on_payment_before_invoice(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"party": [self.customer],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.posting_date = add_days(today(), 1)
|
||||
si.due_date = si.posting_date
|
||||
si.payment_schedule[0].due_date = si.posting_date
|
||||
si.save().submit()
|
||||
|
||||
pe = self.create_payment_entry(si.name, do_not_submit=True)
|
||||
pe.cost_center = self.cost_center
|
||||
pe.save().submit()
|
||||
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
|
||||
self.assertEqual(row.cost_center, pe.cost_center)
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
|
||||
{% } %}
|
||||
</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
|
||||
</tr>
|
||||
{% } else { %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{%= data[i]["payment_entry"] %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
|
||||
</tr>
|
||||
{% } %}
|
||||
{% } %}
|
||||
|
||||
@@ -114,6 +114,7 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
columns[0]["fieldname"] = "sales_invoice"
|
||||
columns[0]["options"] = "Item"
|
||||
columns[0]["width"] = 300
|
||||
# removing Item Code and Item Name columns
|
||||
# removing the duplicate Item Code column and moving Item Name before Customer
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
|
||||
del columns[4:6]
|
||||
del columns[4]
|
||||
columns.insert(1, columns.pop(4))
|
||||
else:
|
||||
del columns[5:7]
|
||||
del columns[5]
|
||||
columns.insert(1, columns.pop(5))
|
||||
|
||||
total_base_amount = 0
|
||||
total_buying_amount = 0
|
||||
|
||||
@@ -854,6 +854,7 @@
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"filters": "{\"is_return\": 1}",
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
@@ -864,8 +865,7 @@
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link",
|
||||
"filters": "{\"is_return\": 1}"
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
@@ -931,6 +931,7 @@
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"filters": "{\"is_return\": 1}",
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
@@ -941,8 +942,7 @@
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link",
|
||||
"filters": "{\"is_return\": 1}"
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
@@ -1154,20 +1154,6 @@
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 1,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
@@ -1418,27 +1404,12 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 1,
|
||||
"label": "Financial Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "wallet",
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
@@ -1877,7 +1848,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified": "2026-09-01 17:33:21.459367",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.party import get_default_price_list
|
||||
from erpnext.accounts.party import get_default_price_list, set_price_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -16,3 +16,46 @@ class PartyTestCase(ERPNextTestSuite):
|
||||
customer.save()
|
||||
price_list = get_default_price_list(customer)
|
||||
assert price_list is None
|
||||
|
||||
def test_disabled_party_default_should_fall_back_to_given_price_list(self):
|
||||
customer = self.create_customer(default_price_list=self.create_price_list(enabled=0))
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
set_price_list(party_details, customer, "Customer", given_price_list)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_disabled_given_price_list_should_not_be_set(self):
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
set_price_list(party_details, customer, "Customer", self.create_price_list(enabled=0))
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_price_list(self, enabled):
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": 1,
|
||||
"enabled": enabled,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return price_list.name
|
||||
|
||||
def create_customer(self, **values):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": frappe.generate_hash(length=10),
|
||||
**values,
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
customer.customer_group = None
|
||||
customer.save()
|
||||
|
||||
return customer
|
||||
|
||||
@@ -1359,22 +1359,28 @@ def get_children(
|
||||
parent_fieldname = "parent_" + doctype.lower().replace(" ", "_")
|
||||
fields = ["name as value", "is_group as expandable"]
|
||||
filters = [["docstatus", "<", 2]]
|
||||
if frappe.db.has_column(doctype, "disabled") and not include_disabled:
|
||||
filters.append(["disabled", "=", False])
|
||||
if frappe.db.has_column(doctype, "disabled"):
|
||||
if include_disabled:
|
||||
# the tree marks disabled rows, so it needs the flag
|
||||
fields.append("disabled")
|
||||
else:
|
||||
filters.append(["disabled", "=", False])
|
||||
|
||||
# extra columns the tree views render as badges / clean labels
|
||||
node_fields = {
|
||||
"Account": ["root_type", "account_name", "account_number", "account_currency", "freeze_account"],
|
||||
"Cost Center": ["cost_center_name", "cost_center_number"],
|
||||
}
|
||||
fields += node_fields.get(doctype, [])
|
||||
|
||||
if is_root:
|
||||
filters.append(IfNull(Field(parent_fieldname), "") == "")
|
||||
filters.append(["company", "=", company])
|
||||
if doctype == "Account":
|
||||
fields.append("report_type")
|
||||
else:
|
||||
filters.append([parent_fieldname, "=", parent])
|
||||
|
||||
account_fields = ["account_name", "account_number", "account_currency", "freeze_account"]
|
||||
if is_root:
|
||||
fields += ["root_type", "report_type", *account_fields] if doctype == "Account" else []
|
||||
filters.append(["company", "=", company])
|
||||
|
||||
else:
|
||||
fields += ["root_type", *account_fields] if doctype == "Account" else []
|
||||
fields += [parent_fieldname + " as parent"]
|
||||
fields.append(parent_fieldname + " as parent")
|
||||
|
||||
acc = frappe.get_list(doctype, fields=fields, filters=filters)
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "layers",
|
||||
"idx": 0,
|
||||
"items": [
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bulk Transaction Log Detail",
|
||||
"link_to": "Bulk Transaction Log Detail",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bulk Transaction Log",
|
||||
"link_to": "Bulk Transaction Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Bulk Transaction",
|
||||
"name": "Bulk Transaction",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Bulk Transaction"
|
||||
}
|
||||
@@ -581,7 +581,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
var item_length = me.frm.doc.items.length;
|
||||
while (i < item_length) {
|
||||
var qty = me.frm.doc.items[i].qty;
|
||||
(r.message[0] || []).forEach(function (d) {
|
||||
(r.message || []).forEach(function (d) {
|
||||
if (
|
||||
d.qty > 0 &&
|
||||
qty > 0 &&
|
||||
|
||||
@@ -226,6 +226,7 @@ class PurchaseOrder(BuyingController):
|
||||
self.doctype, self.supplier, self.company, self.inter_company_order_reference
|
||||
)
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.set_missing_terms()
|
||||
|
||||
def set_has_unit_price_items(self):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"creation": "2026-08-16 00:00:00",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "shopping-cart",
|
||||
@@ -283,97 +283,6 @@
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "rocket",
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 1,
|
||||
"label": "Subcontracting",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "folder-tree",
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting BOM",
|
||||
"link_to": "Subcontracting BOM",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Inward Order",
|
||||
"link_to": "Subcontracting Inward Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Delivery",
|
||||
"link_to": "Stock Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Order",
|
||||
"link_to": "Subcontracting Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Receipt",
|
||||
"link_to": "Subcontracting Receipt",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
@@ -631,12 +540,11 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified": "2026-09-01 17:37:29.971719",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Buying"
|
||||
}
|
||||
|
||||
139
erpnext/buying/test_utils.py
Normal file
139
erpnext/buying/test_utils.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
import frappe.permissions
|
||||
|
||||
from erpnext.buying.utils import get_linked_material_requests
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def create_user_with_roles(email, *roles):
|
||||
if frappe.db.exists("User", email):
|
||||
user = frappe.get_doc("User", email)
|
||||
else:
|
||||
user = frappe.new_doc("User")
|
||||
user.email = email
|
||||
user.first_name = email.split("@", 1)[0]
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
user.set("roles", [])
|
||||
for role in roles:
|
||||
user.append("roles", {"role": role})
|
||||
user.save(ignore_permissions=True)
|
||||
|
||||
# a user left without roles is downgraded to a Website User on save
|
||||
frappe.db.set_value("User", email, "user_type", "System User")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class TestGetLinkedMaterialRequests(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.material_request = make_material_request(item_code="_Test Item")
|
||||
|
||||
def test_permitted_role_can_fetch_linked_material_requests(self):
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_populated_result_is_a_flat_list_of_rows(self):
|
||||
"""Both callers iterate the response directly, so it has to stay a flat list of rows
|
||||
rather than a list of lists."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIsInstance(rows, list)
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertNotIsInstance(row, list | tuple)
|
||||
self.assertIsInstance(row, dict)
|
||||
for fieldname in ("mr_name", "mr_item", "item_code", "qty"):
|
||||
self.assertIn(fieldname, row)
|
||||
|
||||
def test_empty_result_is_a_flat_empty_list(self):
|
||||
item_without_request = make_item("_Test Item Without Material Request").name
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests([item_without_request])
|
||||
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_a_single_item_code_is_treated_as_one_code(self):
|
||||
"""A lone code must be read as one item code, not iterated character by character."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(json.dumps("_Test Item"))
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_items_that_are_not_item_codes_are_rejected(self):
|
||||
"""Anything that is not a `str` or a `list` is already refused by the type annotation,
|
||||
so these are the malformed inputs that reach the method."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
bad_inputs = (
|
||||
"not json at all",
|
||||
[{"item_code": "_Test Item"}],
|
||||
[["_Test Item"]],
|
||||
[None],
|
||||
)
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
for bad_items in bad_inputs:
|
||||
with self.subTest(items=bad_items):
|
||||
self.assertRaises(frappe.ValidationError, get_linked_material_requests, bad_items)
|
||||
|
||||
def test_manufacturing_manager_can_fetch_linked_material_requests(self):
|
||||
"""Manufacturing Manager holds write on Supplier Quotation and Request for Quotation,
|
||||
both of which call this method, so it must hold Material Request read as well."""
|
||||
create_user_with_roles("test_buying_mfg_manager@example.com", "Manufacturing Manager")
|
||||
|
||||
with self.set_user("test_buying_mfg_manager@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_unpermitted_role_cannot_fetch_linked_material_requests(self):
|
||||
create_user_with_roles("test_buying_sales_user@example.com", "Sales User")
|
||||
|
||||
with self.set_user("test_buying_sales_user@example.com"):
|
||||
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
|
||||
|
||||
def test_role_with_only_select_permission_cannot_fetch_linked_material_requests(self):
|
||||
"""Material Request grants Delivery and Maintenance roles `select` and nothing else.
|
||||
`select` is enough to list names, so the permitted set must be resolved through a
|
||||
filter on the child table, which requires `read`."""
|
||||
create_user_with_roles("test_buying_delivery_user@example.com", "Delivery User")
|
||||
|
||||
with self.set_user("test_buying_delivery_user@example.com"):
|
||||
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
|
||||
|
||||
def test_results_are_restricted_by_user_permissions(self):
|
||||
other_company_request = make_material_request(
|
||||
item_code="_Test Item",
|
||||
company="_Test Company 1",
|
||||
warehouse="_Test Warehouse 2 - _TC1",
|
||||
cost_center="Main - _TC1",
|
||||
)
|
||||
user = create_user_with_roles("test_buying_restricted_user@example.com", "Purchase User")
|
||||
frappe.permissions.add_user_permission("Company", "_Test Company", user.name)
|
||||
|
||||
try:
|
||||
with self.set_user(user.name):
|
||||
mr_names = {row.mr_name for row in get_linked_material_requests(["_Test Item"])}
|
||||
finally:
|
||||
frappe.permissions.remove_user_permission("Company", "_Test Company", user.name)
|
||||
|
||||
self.assertIn(self.material_request.name, mr_names)
|
||||
self.assertNotIn(other_company_request.name, mr_names)
|
||||
@@ -129,7 +129,33 @@ def get_linked_material_requests(items: str | list):
|
||||
Retrieve Material Requests linked to a list of items.
|
||||
"""
|
||||
|
||||
items = frappe.parse_json(items)
|
||||
try:
|
||||
items = frappe.parse_json(items)
|
||||
except (TypeError, ValueError):
|
||||
frappe.throw(_("Items must be a list of Item codes"))
|
||||
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
|
||||
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
|
||||
frappe.throw(_("Items must be a list of Item codes"))
|
||||
|
||||
permitted_material_requests = frappe.get_list(
|
||||
"Material Request",
|
||||
filters=[
|
||||
["material_request_type", "=", "Purchase"],
|
||||
["docstatus", "=", 1],
|
||||
["status", "!=", "Stopped"],
|
||||
["per_ordered", "<", 99.99],
|
||||
["Material Request Item", "item_code", "in", items],
|
||||
],
|
||||
pluck="name",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
if not permitted_material_requests:
|
||||
return []
|
||||
|
||||
mr_list = []
|
||||
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
@@ -146,6 +172,7 @@ def get_linked_material_requests(items: str | list):
|
||||
mr_item.item_code,
|
||||
mr_item.name.as_("mr_item"),
|
||||
)
|
||||
.where(mr.name.isin(permitted_material_requests))
|
||||
.where(mr_item.item_code == item)
|
||||
.where(mr.material_request_type == "Purchase")
|
||||
.where(mr.per_ordered < 99.99)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "messages-square",
|
||||
"idx": 0,
|
||||
"items": [
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Communication Medium",
|
||||
"link_to": "Communication Medium",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Communication",
|
||||
"name": "Communication",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Communication"
|
||||
}
|
||||
@@ -258,6 +258,8 @@ class AccountsController(TransactionBase):
|
||||
if self.get("_action") and self._action != "update_after_submit":
|
||||
self.set_missing_values(for_validate=True)
|
||||
|
||||
self.validate_price_list()
|
||||
|
||||
if self.get("_action") == "submit":
|
||||
self.remove_bundle_for_non_stock_invoices()
|
||||
|
||||
@@ -346,6 +348,28 @@ class AccountsController(TransactionBase):
|
||||
self.set_default_letter_head()
|
||||
self.validate_company_in_accounting_dimension()
|
||||
|
||||
def validate_price_list(self):
|
||||
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
and price_list
|
||||
== frappe.db.get_value(
|
||||
self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
if hasattr(self, "letter_head") and not self.letter_head:
|
||||
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
||||
|
||||
@@ -4,15 +4,22 @@
|
||||
"""Read-side GL / Stock Ledger preview helpers.
|
||||
|
||||
A dry-run consumer of the posting path, shared across accounts and stock vouchers
|
||||
(Sales/Purchase Invoice, Payment Entry, Delivery Note, Purchase Receipt, Stock
|
||||
Entry): it submits-in-memory, reads the resulting GL/SLE entries and formats them
|
||||
for the datatable preview, then rolls back. Lives separately from the posting
|
||||
services it orchestrates. The whitelisted ``show_*_preview`` entry points stay on
|
||||
``stock_controller`` (their dotted path is referenced from client JS).
|
||||
(Sales/Purchase Invoice, Payment Entry, Delivery Note, Purchase Receipt,
|
||||
Subcontracting Receipt, Stock Entry): it submits-in-memory, reads the resulting
|
||||
GL/SLE entries and formats them for the datatable preview, then rolls back. Lives
|
||||
separately from the posting services it orchestrates. The whitelisted
|
||||
``show_*_preview`` entry points stay on ``stock_controller`` (their dotted path is
|
||||
referenced from client JS).
|
||||
"""
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext import get_company_currency
|
||||
|
||||
STOCK_LEDGER_PREVIEW_DOCTYPES = frozenset(
|
||||
("Delivery Note", "Purchase Receipt", "Stock Entry", "Subcontracting Receipt")
|
||||
)
|
||||
|
||||
|
||||
def get_accounting_ledger_preview(doc, filters):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns
|
||||
@@ -37,14 +44,15 @@ def get_accounting_ledger_preview(doc, filters):
|
||||
try:
|
||||
doc.docstatus = 1
|
||||
|
||||
if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"):
|
||||
if doc.get("update_stock") or doc.doctype in STOCK_LEDGER_PREVIEW_DOCTYPES:
|
||||
make_serial_and_batch_bundles_for_preview(doc)
|
||||
doc.update_stock_ledger()
|
||||
|
||||
doc.make_gl_entries()
|
||||
columns = get_gl_columns(filters)
|
||||
gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||
|
||||
gl_columns = get_columns(columns, fields)
|
||||
gl_columns = get_columns(columns, fields, get_company_currency(filters.company))
|
||||
gl_data = get_data(fields, gl_entries)
|
||||
finally:
|
||||
frappe.db.rollback(save_point="ledger_preview")
|
||||
@@ -80,19 +88,19 @@ def get_stock_ledger_preview(doc, filters):
|
||||
"stock_value_difference",
|
||||
]
|
||||
|
||||
if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note", "Stock Entry"):
|
||||
if doc.get("update_stock") or doc.doctype in STOCK_LEDGER_PREVIEW_DOCTYPES:
|
||||
# Dry run: submit in memory to materialise SLEs, read them, then roll back to
|
||||
# the savepoint so the preview never persists anything, regardless of caller.
|
||||
frappe.db.savepoint("ledger_preview")
|
||||
try:
|
||||
doc.docstatus = 1
|
||||
doc.make_bundle_using_old_serial_batch_fields()
|
||||
make_serial_and_batch_bundles_for_preview(doc)
|
||||
doc.update_stock_ledger()
|
||||
|
||||
columns = get_sl_columns(filters)
|
||||
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||
|
||||
sl_columns = get_columns(columns, columns_fields)
|
||||
sl_columns = get_columns(columns, columns_fields, get_company_currency(filters.company))
|
||||
sl_data = get_data(columns_fields, sl_entries)
|
||||
finally:
|
||||
frappe.db.rollback(save_point="ledger_preview")
|
||||
@@ -113,7 +121,8 @@ def get_sl_entries_for_preview(doctype, docname, fields):
|
||||
entry["out_qty"] = abs(entry.actual_qty)
|
||||
entry["in_qty"] = 0
|
||||
|
||||
entry["in_out_rate"] = entry["valuation_rate"]
|
||||
if entry.actual_qty < 0:
|
||||
entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty
|
||||
|
||||
return sl_entries
|
||||
|
||||
@@ -122,12 +131,29 @@ def get_gl_entries_for_preview(doctype, docname, fields):
|
||||
return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields)
|
||||
|
||||
|
||||
def get_columns(raw_columns, fields):
|
||||
return [
|
||||
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
|
||||
for d in raw_columns
|
||||
if not d.get("hidden") and d.get("fieldname") in fields
|
||||
]
|
||||
def make_serial_and_batch_bundles_for_preview(doc):
|
||||
table_names = ("items", "supplied_items") if doc.doctype == "Subcontracting Receipt" else ("items",)
|
||||
for table_name in table_names:
|
||||
doc.make_bundle_using_old_serial_batch_fields(table_name)
|
||||
|
||||
|
||||
def get_columns(raw_columns, fields, currency):
|
||||
columns = []
|
||||
for source_column in raw_columns:
|
||||
if source_column.get("hidden") or source_column.get("fieldname") not in fields:
|
||||
continue
|
||||
|
||||
column = {
|
||||
"name": source_column.get("label"),
|
||||
"editable": False,
|
||||
"width": 110,
|
||||
"fieldtype": source_column.get("fieldtype"),
|
||||
}
|
||||
if column["fieldtype"] == "Currency":
|
||||
column["options"] = currency
|
||||
columns.append(column)
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_data(raw_columns, raw_data):
|
||||
|
||||
@@ -194,7 +194,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
|
||||
if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock:
|
||||
fields = ["qty"]
|
||||
|
||||
if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]:
|
||||
tracks_accepted_rejected_split = doc.doctype in (
|
||||
"Purchase Receipt",
|
||||
"Subcontracting Receipt",
|
||||
) or (doc.doctype == "Purchase Invoice" and doc.update_stock)
|
||||
|
||||
if tracks_accepted_rejected_split:
|
||||
if not args.get("return_qty_from_rejected_warehouse"):
|
||||
fields.extend(["received_qty", "rejected_qty"])
|
||||
else:
|
||||
|
||||
@@ -641,7 +641,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||
from erpnext.controllers.ledger_preview import get_stock_ledger_preview
|
||||
|
||||
filters = frappe._dict(company=company)
|
||||
filters = frappe._dict(company=company, valuation_field_type="Currency")
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_sl_preview")
|
||||
|
||||
@@ -1161,7 +1161,11 @@ def get_fg_reference_names(
|
||||
"Subcontracting Inward Order Item",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
filters={"parent": filters.get("parent"), "item_code": ("like", f"%{txt}%"), "docstatus": 1},
|
||||
filters={"parent": filters.get("parent"), "docstatus": 1},
|
||||
or_filters=[
|
||||
["name", "like", f"%{txt}%"],
|
||||
["item_code", "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name", "item_code", "delivery_warehouse"],
|
||||
as_list=True,
|
||||
order_by="idx",
|
||||
|
||||
@@ -2410,4 +2410,10 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
si.set_posting_time = 1
|
||||
si.posting_date = "2026-01-01"
|
||||
si.save()
|
||||
self.assertEqual(si.name, "SI-01-2026-00001")
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.set_posting_time = 1
|
||||
si.posting_date = "2026-01-15"
|
||||
si.save()
|
||||
self.assertEqual(si.name, "SI-01-2026-00002")
|
||||
|
||||
23
erpnext/controllers/tests/test_ledger_preview.py
Normal file
23
erpnext/controllers/tests/test_ledger_preview.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.ledger_preview import get_sl_entries_for_preview
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestLedgerPreview(ERPNextTestSuite):
|
||||
def test_in_out_rate_is_only_set_for_outgoing_entries(self):
|
||||
stock_ledger_entries = [
|
||||
frappe._dict(actual_qty=5, stock_value_difference=10),
|
||||
frappe._dict(actual_qty=-5, stock_value_difference=-15),
|
||||
]
|
||||
|
||||
with patch("frappe.get_all", return_value=stock_ledger_entries):
|
||||
entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", [])
|
||||
|
||||
self.assertIsNone(entries[0].get("in_out_rate"))
|
||||
self.assertEqual(entries[1].in_out_rate, 3)
|
||||
@@ -236,6 +236,8 @@ class Lead(SellingController, CRMNote):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_prospect_and_contact(self, data: dict):
|
||||
self.check_permission("write")
|
||||
|
||||
data = frappe._dict(data)
|
||||
if data.create_contact:
|
||||
self.create_contact()
|
||||
@@ -382,6 +384,9 @@ def get_lead_with_phone_number(number):
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_lead_to_prospect(lead: str, prospect: str):
|
||||
if lead:
|
||||
frappe.has_permission("Lead", "read", lead, throw=True)
|
||||
|
||||
prospect = frappe.get_doc("Prospect", prospect)
|
||||
prospect.append("leads", {"lead": lead})
|
||||
prospect.save()
|
||||
|
||||
@@ -133,30 +133,6 @@
|
||||
"link_type": "Sidebar",
|
||||
"title": "Quality"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "messages-square",
|
||||
"link_to": "Communication",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Communication"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "phone",
|
||||
"link_to": "Telephony",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Telephony"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "layers",
|
||||
"link_to": "Bulk Transaction",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Bulk Transaction"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
@@ -164,17 +140,9 @@
|
||||
"link_to": "Subcontracting",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Subcontracting"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "file-code",
|
||||
"link_to": "EDI",
|
||||
"link_type": "Sidebar",
|
||||
"title": "EDI"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified": "2026-09-02 14:58:17.082794",
|
||||
"modified_by": "Administrator",
|
||||
"name": "erpnext",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
@@ -78,8 +79,48 @@ class CodeList(Document):
|
||||
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
|
||||
|
||||
|
||||
def _version_key(version: str | None) -> list:
|
||||
"""Natural sort key for the version formats publishers use: integers and ISO dates.
|
||||
|
||||
Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05.
|
||||
"""
|
||||
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")]
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def resolve_code_list(code_list: str) -> str | None:
|
||||
"""Return the Code List for a document name or a canonical URI.
|
||||
|
||||
Code Lists are named after their CanonicalVersionUri, so one canonical URI can
|
||||
map to several documents, one per version. An exact document name takes
|
||||
precedence, which lets a caller request a specific version; a canonical URI
|
||||
resolves to the latest version available.
|
||||
"""
|
||||
if frappe.db.exists("Code List", code_list):
|
||||
return code_list
|
||||
|
||||
candidates = frappe.get_all(
|
||||
"Code List",
|
||||
filters={"canonical_uri": code_list},
|
||||
fields=["name", "version"],
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# ponytail: assumes one publisher sticks to one version format. An integer and an
|
||||
# ISO date under the same canonical URI compare numerically (3 < 2020), so the date
|
||||
# would win; import the genericode ValidityDate and sort on that if it ever happens.
|
||||
return max(candidates, key=lambda cl: _version_key(cl.version)).name
|
||||
|
||||
|
||||
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
"""Return the common code for a given record"""
|
||||
"""Return the common code for a given record.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
"""Return the record name for a given common code"""
|
||||
"""Return the record name for a given common code.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_default_code(code_list: str) -> str | None:
|
||||
"""Return the default common code for a given code list"""
|
||||
"""Return the default common code for a given code list.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return None
|
||||
|
||||
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
|
||||
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None
|
||||
|
||||
@@ -1,9 +1,83 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
|
||||
from erpnext.edi.doctype.code_list.code_list import (
|
||||
_version_key,
|
||||
get_codes_for,
|
||||
get_default_code,
|
||||
get_docnames_for,
|
||||
resolve_code_list,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CANONICAL_URI = "urn:test:erpnext:codeliste:resolve"
|
||||
OLD_VERSION = f"{CANONICAL_URI}:3"
|
||||
NEW_VERSION = f"{CANONICAL_URI}:10"
|
||||
UNKNOWN_URI = "urn:test:erpnext:codeliste:missing"
|
||||
|
||||
|
||||
class TestCodeList(ERPNextTestSuite):
|
||||
pass
|
||||
def setUp(self):
|
||||
"""Create two versions of one code list. Test records are rolled back per test."""
|
||||
for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")):
|
||||
if not frappe.db.exists("Code List", name):
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=name,
|
||||
title=name,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version=version,
|
||||
).insert()
|
||||
|
||||
default_code = frappe.get_doc(
|
||||
doctype="Common Code",
|
||||
title="Test Default",
|
||||
common_code="XYZ",
|
||||
code_list=NEW_VERSION,
|
||||
).insert()
|
||||
frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name)
|
||||
|
||||
# resolution is request-cached, so fixtures must not be masked by earlier lookups
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
def test_version_key_orders_integers_and_iso_dates(self):
|
||||
"""Integer and ISO date versions must both order correctly, unlike a lexical sort."""
|
||||
self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"])
|
||||
self.assertEqual(
|
||||
sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key),
|
||||
["2019-12-31", "2020-01-01", "2020-11-05"],
|
||||
)
|
||||
|
||||
def test_canonical_uri_resolves_to_latest_version(self):
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION)
|
||||
|
||||
def test_name_resolves_to_itself(self):
|
||||
"""Passing a version-specific name must return that version, not the latest one."""
|
||||
self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION)
|
||||
|
||||
def test_name_takes_precedence_over_canonical_uri(self):
|
||||
"""A document named like a canonical URI must not redirect to another version."""
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=CANONICAL_URI,
|
||||
title=CANONICAL_URI,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version="1",
|
||||
).insert()
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI)
|
||||
|
||||
def test_unknown_uri_resolves_to_none(self):
|
||||
self.assertIsNone(resolve_code_list(UNKNOWN_URI))
|
||||
|
||||
def test_lookups_are_empty_for_unknown_code_list(self):
|
||||
"""An unresolved code list must not fall through to an unfiltered query."""
|
||||
self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ())
|
||||
self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ())
|
||||
self.assertIsNone(get_default_code(UNKNOWN_URI))
|
||||
|
||||
def test_default_code_follows_latest_version(self):
|
||||
self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "file-code",
|
||||
"idx": 0,
|
||||
"items": [
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Code List",
|
||||
"link_to": "Code List",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Common Code",
|
||||
"link_to": "Common Code",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "EDI",
|
||||
"name": "EDI",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "EDI"
|
||||
}
|
||||
@@ -21,9 +21,101 @@
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "phone",
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Telephony",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Incoming Call Settings",
|
||||
"link_to": "Incoming Call Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Voice Call Settings",
|
||||
"link_to": "Voice Call Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Call Log",
|
||||
"link_to": "Call Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Telephony Call Type",
|
||||
"link_to": "Telephony Call Type",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Communication Medium",
|
||||
"link_to": "Communication Medium",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified": "2026-09-02 14:58:17.082794",
|
||||
"modified_by": "Administrator",
|
||||
"module": "ERPNext Integrations",
|
||||
"name": "ERPNext Integrations",
|
||||
|
||||
@@ -21,6 +21,23 @@ add_to_apps_screen = [
|
||||
}
|
||||
]
|
||||
|
||||
# Modules that are a folder of code and nothing else. Their doctypes, reports and controllers stay
|
||||
# where they are; what they no longer own is navigation, which now sits in the sidebar named beside
|
||||
# each. Left in the dock, each would carry an entry of its own for two to four records. See
|
||||
# `frappe.utils.modules.get_code_only_modules`.
|
||||
#
|
||||
# The value names the modules that inherited that navigation, so a Call Log or a Code List resolves
|
||||
# to a sidebar the user can actually navigate to instead of dead-ending in a module the dock never
|
||||
# shows.
|
||||
code_only_modules = {
|
||||
"Telephony": ["ERPNext Integrations"],
|
||||
# Its one doctype, Communication Medium, describes how a call reaches someone, so it sits in
|
||||
# the Telephony section beside the call settings rather than in a shell of its own.
|
||||
"Communication": ["ERPNext Integrations"],
|
||||
"EDI": ["Utilities"],
|
||||
"Bulk Transaction": ["Utilities"],
|
||||
}
|
||||
|
||||
develop_version = "17.x.x-develop"
|
||||
|
||||
app_include_js = "erpnext.bundle.js"
|
||||
@@ -313,12 +330,14 @@ permission_query_conditions = {
|
||||
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
|
||||
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
|
||||
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
|
||||
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
|
||||
}
|
||||
|
||||
has_permission = {
|
||||
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
|
||||
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
|
||||
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
|
||||
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
|
||||
}
|
||||
|
||||
has_website_permission = {
|
||||
|
||||
6524
erpnext/locale/ar.po
6524
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
6516
erpnext/locale/bg.po
6516
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user