Merge pull request #58868 from frappe/version-16-hotfix

chore: release v16
This commit is contained in:
Diptanil Saha
2026-09-08 18:50:53 +05:30
committed by GitHub
139 changed files with 79768 additions and 64297 deletions

View File

@@ -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:-$githubbranch}

View File

@@ -105,6 +105,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: |

View File

@@ -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: |

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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

View File

@@ -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}

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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']
},
{

View File

@@ -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>}

View File

@@ -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())}

View File

@@ -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

View 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

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/scroll-fade.css";
@font-face {
font-family: InterVariable;

View File

@@ -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>
}

View File

@@ -226,7 +226,7 @@ const StatementImportLog = () => {
field: "creation",
order: "desc"
},
limit: 10
limit: 20
}, bankAccount ? undefined : null, {
revalidateOnFocus: false
})

View 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;
}
}

View File

@@ -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,

View File

@@ -224,7 +224,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",
@@ -797,7 +798,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",

View File

@@ -916,7 +916,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
if not days:
if days is None:
days = 3
min_date = frappe.utils.add_days(date, -days)
@@ -1340,9 +1340,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()
@@ -1359,7 +1361,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,

View File

@@ -8,7 +8,9 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
get_linked_payments,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -97,3 +99,103 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100, withdrawal=0):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"withdrawal": withdrawal,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.save()
.submit()
)
def get_matching_payment_entries(self, bank_transaction, exact_match=False):
document_types = ["payment_entry", "exact_match"] if exact_match else ["payment_entry"]
vouchers = get_linked_payments(
bank_transaction,
document_types,
from_date=add_days(today(), -1),
to_date=today(),
)
return [v for v in vouchers if v.get("doctype") == "Payment Entry"]
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_deposit_matches_amount_received_in_bank_account(self):
# money leaves another bank account and lands here minus a charge, so the two sides differ
payment = frappe.get_doc(
{
"doctype": "Payment Entry",
"payment_type": "Internal Transfer",
"company": self.company,
"posting_date": today(),
"paid_from": "_Test Bank - _TC",
"paid_to": self.bank,
"paid_amount": 3537.64,
"received_amount": 3460.52,
"reference_no": "TRF-001",
"reference_date": today(),
}
)
payment.set_missing_values()
payment.set_exchange_rate()
payment.set_amounts()
payment.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
payment.deductions[-1].cost_center = "_Test Cost Center - _TC"
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=3460.52)
# the received side is what reached this bank account, so that is what is shown
matches = self.get_matching_payment_entries(transaction.name)
self.assertEqual([m["name"] for m in matches], [payment.name])
self.assertEqual(matches[0]["paid_amount"], 3460.52)
# and what the exact match compares against
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
def test_withdrawal_matches_amount_paid_from_bank_account(self):
payment = create_payment_entry(
company=self.company,
payment_type="Pay",
party_type="Supplier",
party="_Test Supplier",
paid_from=self.bank,
paid_to="Creditors - _TC",
paid_amount=1250,
)
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=0, withdrawal=1250)
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
self.assertEqual(exact_matches[0]["paid_amount"], 1250)
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")
self.assertIn("No matches", message)
def test_auto_reconcile_message_counts_and_pluralizes(self):
# reconciled count is reported and the indicator turns green
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
self.assertEqual(indicator, "green")
self.assertIn("2 Transaction(s) Reconciled", message)
# partially-reconciled label is singular for one, plural for many
singular, _ = get_auto_reconcile_message(["p1"], [])
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)

View File

@@ -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

View File

@@ -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 = [

View File

@@ -479,7 +479,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)
@@ -791,17 +794,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.
@@ -831,9 +837,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
@@ -1023,7 +1031,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)
if isinstance(account_rows, str):
account_rows = json.loads(account_rows, object_hook=frappe._dict)

View File

@@ -193,8 +193,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,
)
@@ -222,7 +224,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
@@ -284,7 +293,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,
)
)
@@ -311,17 +322,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
@@ -346,16 +346,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:
@@ -413,21 +403,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)
@@ -440,7 +428,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,
)
)
@@ -448,8 +438,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,
)
@@ -473,10 +464,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_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)
@@ -555,8 +545,9 @@ class FormulaValidator(Validator):
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
get_formula_field_label(row.data_source), api_path
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(

View File

@@ -5,6 +5,7 @@ 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,
)
@@ -164,3 +165,86 @@ class TestCustomAPIValidation(FinancialReportTemplateTestCase):
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("&lt;script&gt;", 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))

View File

@@ -472,8 +472,8 @@ cur_frm.cscript.update_totals = function (doc) {
tc += flt(accounts[i].credit, precision("credit", accounts[i]));
}
doc = locals[doc.doctype][doc.name];
doc.total_debit = td;
doc.total_credit = tc;
doc.total_debit = flt(td, precision("total_debit"));
doc.total_credit = flt(tc, precision("total_credit"));
doc.difference = flt(td - tc, precision("difference"));
refresh_many(["total_debit", "total_credit", "difference"]);
};
@@ -623,7 +623,7 @@ $.extend(erpnext.journal_entry, {
} else {
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn);
}
refresh_field("exchange_rate", cdn, "accounts");
frm.get_field("accounts").grid.refresh_row(cdn);
},
quick_entry: function (frm) {

View File

@@ -960,12 +960,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 = []

View File

@@ -409,6 +409,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

View File

@@ -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,
};
});

View File

@@ -175,6 +175,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()
@@ -623,6 +624,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay and 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))

View File

@@ -726,6 +726,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_payment_against_negative_sales_invoice(self):
si1 = create_sales_invoice()

View File

@@ -1024,6 +1024,10 @@ class PurchaseInvoice(BuyingController):
gl_entries.append(self.get_gl_dict(gl, self.party_account_currency, item=self))
def make_item_gl_entries(self, gl_entries):
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
get_custom_dimension_overrides,
)
# item gl entries
stock_items = self.get_stock_items()
if self.update_stock and self.auto_accounting_for_stock:
@@ -1165,25 +1169,34 @@ class PurchaseInvoice(BuyingController):
# Amount added through landed-cost-voucher
if landed_cost_entries:
if (item.item_code, item.name) in landed_cost_entries:
for account, base_amount in landed_cost_entries[
(item.item_code, item.name)
].items():
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": item.expense_account,
"cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(base_amount["base_amount"]),
"credit_in_account_currency": flt(base_amount["amount"]),
"credit_in_transaction_currency": item.net_amount,
"project": item.project or self.project,
},
item=item,
)
for entry in landed_cost_entries.get((item.item_code, item.name), []):
if not (entry.amount or entry.base_amount):
continue
lcv_account_currency = get_account_currency(entry.expense_account)
credit_in_transaction_currency = (
flt(entry.amount)
if lcv_account_currency == self.currency
else flt(
entry.base_amount / self.conversion_rate, item.precision("net_amount")
)
)
gl_dict = self.get_gl_dict(
{
"account": entry.expense_account,
"against": item.expense_account,
"cost_center": entry.dimensions.cost_center or item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(entry.base_amount),
"credit_in_account_currency": flt(entry.amount),
"credit_in_transaction_currency": credit_in_transaction_currency,
"project": entry.dimensions.project or item.project or self.project,
},
item=item,
)
gl_dict.update(get_custom_dimension_overrides(entry))
gl_entries.append(gl_dict)
# sub-contracting warehouse
if flt(item.rm_supp_cost):

View File

@@ -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"],

View File

@@ -1877,7 +1877,7 @@ class SalesInvoice(SellingController):
for payment_mode in self.payments:
if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount:
payment_mode.base_amount -= flt(self.change_amount)
payment_mode.base_amount -= flt(self.base_change_amount)
against_voucher = self.name
if self.is_return and self.return_against and not self.update_outstanding_for_self:

View File

@@ -1484,6 +1484,33 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_pos_change_amount_multi_currency_gl_entry(self):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 0)
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.currency = "USD"
si.conversion_rate = 50
si.party_account_currency = "USD"
si.account_for_change_amount = "Cash - _TC"
si.change_amount = 50
si.base_change_amount = 2500
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
)
gl_entries = []
si.make_pos_gl_entries(gl_entries)
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
cash_amount -= pos.change_amount

View File

@@ -172,6 +172,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,

View File

@@ -1173,6 +1173,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

View File

@@ -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>
{% } %}
{% } %}

View File

@@ -106,6 +106,7 @@ def execute(filters=None):
filters={
"account_type": row["account_type"],
"is_group": 0,
"company": filters.company,
},
pluck="name",
)

View File

@@ -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

View File

@@ -230,6 +230,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):
"""
@@ -331,6 +332,43 @@ class PurchaseOrder(BuyingController):
).format(item_code, qty, itemwise_min_order_qty.get(item_code))
)
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
if not self.is_new():
return
precision = self.items[0].precision("stock_qty")
itemwise_steps = {}
itemwise_stock_uom = frappe._dict()
for d in self.get("items"):
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
itemwise_steps.setdefault(d.item_code, set()).add(step)
itemwise_stock_uom[d.item_code] = d.stock_uom
for item_code, qty in itemwise_qty.items():
steps = itemwise_steps[item_code]
if len(steps) != 1:
continue
step = next(iter(steps))
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
overage = flt(qty) - min_order_qty
if min_order_qty and flt(overage, precision) > 0 and overage < step:
frappe.toast(
_(
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
).format(
item_code,
flt(qty, precision),
itemwise_stock_uom[item_code],
min_order_qty,
flt(overage, precision),
),
indicator="orange",
)
def validate_bom_for_subcontracting_items(self):
for item in self.items:
if not item.bom:

View File

@@ -724,6 +724,72 @@ class TestPurchaseOrder(ERPNextTestSuite):
po = create_purchase_order(company="_Test Company 1", do_not_save=True)
self.assertRaises(InvalidWarehouseCompany, po.insert)
def test_marginal_min_order_qty_overage_toast(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
if not frappe.db.exists("UOM", "Gram"):
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
item_doc.save()
item = item_doc.name
def insert_po(qty):
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
po.items[0].uom = "Pound"
po.items[0].conversion_factor = 453.592292197
frappe.clear_messages()
po.insert()
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
self.assertTrue(insert_po(110.232))
self.assertFalse(insert_po(150))
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 1})
def test_marginal_min_order_qty_toast_with_duplicate_rows(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
item = make_item(
properties={"min_order_qty": 1000.5, "stock_uom": "_Test UOM 1"},
uoms=[{"uom": "Pound", "conversion_factor": 1000}],
)
conversion_factors = {"_Test UOM 1": 1, "Pound": 1000}
cases = [
([("Pound", 1), ("_Test UOM 1", 0.6)], False),
([("_Test UOM 1", 0.6), ("Pound", 1)], False),
([("Pound", 0.5), ("_Test UOM 1", 0.6), ("Pound", 0.5)], False),
([("Pound", 0.5), ("Pound", 0.501)], True),
]
for rows, expect_toast in cases:
with self.subTest(rows=rows):
po = create_purchase_order(
do_not_save=1,
rm_items=[
{
"item_code": item.name,
"uom": uom,
"conversion_factor": conversion_factors[uom],
"qty": qty,
"rate": 1,
"warehouse": "_Test Warehouse - _TC",
"schedule_date": add_days(nowdate(), 1),
}
for uom, qty in rows
],
)
frappe.clear_messages()
po.insert()
has_toast = any(
"due to purchase UOM rounding" in message.get("message", "")
for message in frappe.get_message_log()
)
self.assertEqual(has_toast, expect_toast)
def test_uom_integer_validation(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError

View File

@@ -1246,7 +1246,13 @@ class StockController(AccountsController):
if not landed_cost_vouchers:
return
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
get_lcv_dimension_fields,
get_row_dimensions,
)
item_account_wise_cost = {}
dimension_fields = get_lcv_dimension_fields()
row_fieldname = "purchase_receipt_item"
if self.doctype == "Stock Entry":
@@ -1268,28 +1274,36 @@ class StockController(AccountsController):
for item in landed_cost_voucher_doc.items:
if item.receipt_document == self.name:
charges = item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {})
for account in landed_cost_voucher_doc.taxes:
exchange_rate = account.exchange_rate or 1
item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {})
item_account_wise_cost[(item.item_code, item.get(row_fieldname))].setdefault(
account.expense_account, {"amount": 0.0, "base_amount": 0.0}
dimensions = get_row_dimensions(account, item, dimension_fields)
group_key = (
account.expense_account,
tuple(dimensions.get(field) for field in dimension_fields),
)
item_row = item_account_wise_cost[(item.item_code, item.get(row_fieldname))][
account.expense_account
]
item_row = charges.get(group_key)
if item_row is None:
item_row = charges[group_key] = frappe._dict(
expense_account=account.expense_account,
amount=0.0,
base_amount=0.0,
dimensions=dimensions,
)
if total_item_cost > 0:
item_row["amount"] += account.amount * item.get(based_on_field) / total_item_cost
item_row.amount += account.amount * item.get(based_on_field) / total_item_cost
item_row["base_amount"] += (
item_row.base_amount += (
account.base_amount * item.get(based_on_field) / total_item_cost
)
else:
item_row["amount"] += item.applicable_charges / exchange_rate
item_row["base_amount"] += item.applicable_charges
item_row.amount += item.applicable_charges / exchange_rate
item_row.base_amount += item.applicable_charges
return item_account_wise_cost
return {key: list(charges.values()) for key, charges in item_account_wise_cost.items()}
def validate_inventory_dimension_mandatory(self):
# Mandatory inventory dimensions are enforced here (instead of via field-level `reqd`)
@@ -1976,6 +1990,7 @@ class StockController(AccountsController):
voucher_detail_no=None,
item=None,
posting_date=None,
dimensions=None,
):
gl_entry = {
"account": account,
@@ -2001,6 +2016,9 @@ class StockController(AccountsController):
if posting_date:
gl_entry.update({"posting_date": posting_date})
if dimensions:
gl_entry.update(dimensions)
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
def update_stock_reservation_entries(self):

View File

@@ -237,7 +237,9 @@ class Lead(SellingController, CRMNote):
return frappe.db.get_value("Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"})
@frappe.whitelist()
def create_prospect_and_contact(self, data):
def create_prospect_and_contact(self, data: dict):
self.check_permission("write")
data = frappe._dict(data)
if data.create_contact:
self.create_contact()
@@ -526,8 +528,11 @@ def get_lead_with_phone_number(number):
return lead
@frappe.whitelist()
def add_lead_to_prospect(lead, prospect):
@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()

View File

@@ -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

View File

@@ -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")

View File

@@ -559,6 +559,7 @@ accounting_dimension_doctypes = [
"Purchase Taxes and Charges",
"Shipping Rule",
"Landed Cost Item",
"Landed Cost Taxes and Charges",
"Asset Value Adjustment",
"Asset Repair",
"Asset Capitalization",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,9 @@ from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, get_bom_i
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
)
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
get_subcontracting_boms_for_finished_goods,
@@ -990,6 +993,10 @@ class JobCard(Document):
if not self.operation_id:
return
work_order = frappe.get_doc("Work Order", self.work_order)
if work_order.production_plan:
ProductionPlanWorkOrderQuantities(work_order.production_plan).lock_plan_row(work_order)
job_cards = frappe.get_all(
"Job Card",
filters={
@@ -1004,14 +1011,13 @@ class JobCard(Document):
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
if (
self.finished_good
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
== self.finished_good
):
_wo_doc = frappe.get_doc("Work Order", self.work_order)
_wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
_wo_doc.db_set("status", _wo_doc.get_status())
if self.finished_good and work_order.production_item == self.finished_good:
work_order.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
if work_order.production_plan:
ProductionPlanWorkOrderQuantities(work_order.production_plan).validate_work_order(
work_order, process_loss_qty=work_order.process_loss_qty
)
work_order.db_set("status", work_order.get_status())
def update_corrective_in_work_order(self, wo):
wo.corrective_operation_cost = 0.0

View File

@@ -318,43 +318,6 @@ class TestJobCard(ERPNextTestSuite):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
def test_material_request_stock_entry_uses_job_card_coverage(self):
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
mr = make_material_request(job_card.name)
mr.schedule_date = today()
for row in mr.items:
row.qty = flt(row.qty) / 2
row.stock_qty = flt(row.stock_qty) / 2
mr.submit()
stock_entry = make_stock_entry(mr.name)
self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
selected_row = mr.items[0]
try:
frappe.flags.selected_children = {"items": [selected_row.name]}
selected_stock_entry = make_stock_entry(mr.name)
finally:
frappe.flags.selected_children = None
self.assertEqual(
[row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
)
self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
for row in mr.items:
transferred_qty = flt(row.stock_qty) / 2
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
mr.reload()
repeated_stock_entry = make_stock_entry(mr.name)
self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
@@ -768,7 +731,6 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
def test_job_card_material_transfer_via_pick_list(self):
from erpnext.stock.doctype.material_request.material_request import create_pick_list

View File

@@ -230,6 +230,14 @@ frappe.ui.form.on("Production Plan", {
let has_items =
items.filter((item) => {
const reference_field =
item.doctype === "Production Plan Item"
? "production_plan_item"
: "production_plan_sub_assembly_item";
const pending_qty = frm.doc.__onload?.pending_work_order_qty?.[reference_field]?.[item.name];
if (pending_qty !== undefined) {
return pending_qty > 0;
}
if (item.planned_qty) {
return item.planned_qty > item.ordered_qty;
} else {

View File

@@ -5,6 +5,7 @@
import copy
import json
from collections import defaultdict
from decimal import ROUND_CEILING, Decimal
import frappe
from frappe import _, msgprint
@@ -30,6 +31,9 @@ from pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_uom_conv_factor
@@ -121,6 +125,12 @@ class ProductionPlan(Document):
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"),
)
if self.docstatus == 1:
self.set_onload(
"pending_work_order_qty",
ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self),
)
def on_discard(self):
self.db_set("status", "Cancelled")
@@ -687,7 +697,9 @@ class ProductionPlan(Document):
frappe.delete_doc("Work Order", d.name)
@frappe.whitelist()
def set_status(self, close=None, update_bin=False):
def set_status(self, close: bool | None = None, update_bin: bool = False):
self.check_permission("write")
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
if close:
@@ -725,6 +737,7 @@ class ProductionPlan(Document):
def get_production_items(self):
item_dict = {}
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
for d in self.po_items:
item_details = {
@@ -747,29 +760,12 @@ class ProductionPlan(Document):
"project": self.project,
}
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
if self.combine_items:
key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
if not d.sales_order:
key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
if not item_details["project"] and d.sales_order:
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request":
item_details.update({"qty": d.planned_qty})
item_dict[
(d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
] = item_details
else:
item_details.update(
{
"qty": flt(item_dict.get(key, {}).get("qty"))
+ (flt(d.planned_qty) - flt(d.ordered_qty))
}
)
item_dict[key] = item_details
item_details["qty"] = pending["production_plan_item"][d.name]
# A Work Order can reference only one Production Plan row.
item_dict[d.name] = item_details
return item_dict
@@ -777,6 +773,7 @@ class ProductionPlan(Document):
def make_work_order(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
self.reload()
wo_list, po_list = [], []
subcontracted_po = {}
default_warehouses = get_default_warehouse(self.company)
@@ -806,6 +803,7 @@ class ProductionPlan(Document):
wo_list.append(work_order)
def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
pending = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)
for row in self.sub_assembly_items:
if row.type_of_manufacturing == "Subcontract":
subcontracted_po.setdefault(row.supplier, []).append(row)
@@ -822,10 +820,9 @@ class ProductionPlan(Document):
"company": self.get("company"),
}
if flt(row.qty) <= flt(row.ordered_qty):
continue
self.prepare_data_for_sub_assembly_items(row, work_order_data)
self.prepare_data_for_sub_assembly_items(
row, work_order_data, pending["production_plan_sub_assembly_item"][row.name]
)
if work_order_data.get("qty") <= 0:
continue
@@ -834,7 +831,7 @@ class ProductionPlan(Document):
if work_order:
wo_list.append(work_order)
def prepare_data_for_sub_assembly_items(self, row, wo_data):
def prepare_data_for_sub_assembly_items(self, row, wo_data, pending_qty=None):
for field in [
"production_item",
"item_name",
@@ -850,7 +847,11 @@ class ProductionPlan(Document):
if row.get(field):
wo_data[field] = row.get(field)
wo_data["qty"] = flt(row.get("qty")) - flt(row.get("ordered_qty"))
if pending_qty is None:
pending_qty = ProductionPlanWorkOrderQuantities(self.name).get_pending_quantities(self)[
"production_plan_sub_assembly_item"
][row.name]
wo_data["qty"] = pending_qty
wo_data.update(
{
@@ -965,12 +966,9 @@ class ProductionPlan(Document):
material_request_list = []
material_request_map = {}
if all([item.requested_qty == item.quantity for item in self.mr_items]):
msgprint(_("All items are already requested"))
return
for item in self.mr_items:
if item.quantity == item.requested_qty:
qty_to_request = flt(flt(item.quantity) - flt(item.requested_qty), item.precision("quantity"))
if qty_to_request <= 0:
continue
item_doc = frappe.get_cached_doc("Item", item.item_code)
@@ -1005,7 +1003,7 @@ class ProductionPlan(Document):
"from_warehouse": item.from_warehouse
if material_request_type == "Material Transfer"
else None,
"qty": item.quantity - item.requested_qty,
"qty": qty_to_request,
"uom": item.uom,
"schedule_date": schedule_date,
"warehouse": item.warehouse,
@@ -1018,6 +1016,10 @@ class ProductionPlan(Document):
},
)
if not material_request_list:
msgprint(_("All items are already requested"))
return
for material_request in material_request_list:
# submit
material_request.flags.ignore_permissions = 1
@@ -1171,6 +1173,7 @@ class ProductionPlan(Document):
if existing_row:
# if row with same (item, wh, bom no, man.g type) key, merge
existing_row.qty += flt(row.qty)
existing_row.required_qty += flt(row.required_qty)
existing_row.stock_qty += flt(row.stock_qty)
existing_row.bom_level = max(existing_row.bom_level, row.bom_level)
continue
@@ -1446,42 +1449,15 @@ def get_material_request_items(
bin_dict,
consumed_qty,
):
required_qty = 0
item_code = row.get("item_code")
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
required_qty = flt(row.get("qty"))
else:
key = (item_code, warehouse)
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
if available_qty > 0:
required_qty = max(0, flt(row.get("qty")) - available_qty)
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
else:
required_qty = flt(row.get("qty"))
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
required_qty = row["min_order_qty"]
required_qty = _required_qty_for_mr(
row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
)
item_group_defaults = get_item_group_defaults(row.item_code, company)
if not row["purchase_uom"]:
row["purchase_uom"] = row["stock_uom"]
if row["purchase_uom"] != row["stock_uom"]:
if not (row["conversion_factor"] or frappe.flags.show_qty_in_stock_uom):
frappe.throw(
_("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format(
row["purchase_uom"], row["stock_uom"], row.item_code
)
)
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
required_qty = ceil(required_qty)
if include_safety_stock:
required_qty += flt(row["safety_stock"])
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
conversion_factor = 1.0
@@ -1494,11 +1470,11 @@ def get_material_request_items(
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
return {
"item_code": row.item_code,
"item_name": row.item_name,
"quantity": flt(required_qty / conversion_factor, precision),
"quantity": _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty),
"conversion_factor": conversion_factor,
"required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"),
@@ -1521,6 +1497,92 @@ def get_material_request_items(
}
def _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty=0):
"""Convert to purchase UOM; a binding minimum order qty takes the smallest
representable quantity whose stock equivalent still meets it. The minimum is
capped at the requirement so a small shortage never rounds down to zero."""
min_order_qty = min(min_order_qty, required_qty)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
quantity = flt(required_qty / conversion_factor, precision)
if min_order_qty and quantity * conversion_factor < min_order_qty <= required_qty:
grid = Decimal(10) ** -precision
exact = Decimal(str(min_order_qty)) / Decimal(str(conversion_factor))
quantity = flt(exact.quantize(grid, rounding=ROUND_CEILING))
return quantity
def _apply_minimum_order_qty(mr_items):
for rows in _purchase_rows_by_item(mr_items).values():
surplus_qty = 0.0
for order_rows in _rows_by_sales_order(rows):
surplus_qty = _apply_minimum_order_qty_to_order(order_rows, surplus_qty)
def _purchase_rows_by_item(mr_items):
rows_by_item = defaultdict(list)
for row in mr_items:
if row.get("material_request_type") not in ("Purchase", "Subcontracting"):
continue
if flt(row.get("quantity")) <= 0:
continue
key = (
row.get("item_code"),
row.get("warehouse"),
row.get("material_request_type"),
row.get("supplier"),
)
rows_by_item[key].append(row)
return rows_by_item
def _rows_by_sales_order(rows):
rows_by_order = defaultdict(list)
for row in rows:
rows_by_order[row.get("sales_order") or ""].append(row)
# Keep surplus allocation stable when upstream queries return orders in a different order.
return [rows_by_order[sales_order] for sales_order in sorted(rows_by_order)]
def _apply_minimum_order_qty_to_order(rows, surplus_qty):
"""Cover the order from an earlier order's surplus, then raise the rest to the minimum.
Material Requests and Purchase Orders are raised per Sales Order and a Purchase
Order rejects an item below its minimum, so each order either buys at least the
minimum or is covered by what an earlier order over-purchased."""
demand_qty = sum(_stock_quantity(row) for row in rows)
_cover_from_surplus(rows, surplus_qty)
min_order_qty = max(flt(row.get("min_order_qty")) for row in rows)
total_qty = sum(_stock_quantity(row) for row in rows)
if 0 < total_qty < min_order_qty:
row = next(row for row in rows if _stock_quantity(row) > 0)
_set_stock_quantity(row, _stock_quantity(row) + min_order_qty - total_qty)
purchased_qty = sum(_stock_quantity(row) for row in rows)
return surplus_qty + purchased_qty - demand_qty
def _cover_from_surplus(rows, surplus_qty):
for row in rows:
covered_qty = min(surplus_qty, _stock_quantity(row))
if covered_qty <= 0:
break
_set_stock_quantity(row, _stock_quantity(row) - covered_qty)
surplus_qty -= covered_qty
def _stock_quantity(row):
return flt(row.get("quantity")) * (flt(row.get("conversion_factor")) or 1)
def _set_stock_quantity(row, stock_qty):
conversion_factor = flt(row.get("conversion_factor")) or 1
quantity = _quantity_in_purchase_uom(stock_qty, conversion_factor, stock_qty)
if frappe.get_cached_value("UOM", row.get("uom"), "must_be_whole_number"):
quantity = ceil(quantity)
row["quantity"] = quantity
def get_sales_orders(self):
bom = frappe.qb.DocType("BOM")
pi = frappe.qb.DocType("Packed Item")
@@ -1590,6 +1652,37 @@ def get_sales_orders(self):
return open_so
def _required_qty_for_mr(
row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
):
safety_stock = flt(row["safety_stock"]) if include_safety_stock else 0
qty = flt(row.get("qty"))
projected_qty = max(0, flt(bin_dict.get("projected_qty"))) if ignore_existing_ordered_qty else 0
key = (row.get("item_code"), warehouse)
available_qty = projected_qty - consumed_qty[key]
required_qty = max(0, qty - (available_qty - safety_stock))
consumed_qty[key] += qty - required_qty
return _adjust_required_qty_for_uom(row, required_qty)
def _adjust_required_qty_for_uom(row, required_qty):
if not row["purchase_uom"]:
row["purchase_uom"] = row["stock_uom"]
if row["purchase_uom"] != row["stock_uom"]:
if not (row["conversion_factor"] or frappe.flags.show_qty_in_stock_uom):
frappe.throw(
_("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format(
row["purchase_uom"], row["stock_uom"], row.item_code
)
)
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
required_qty = ceil(required_qty)
return required_qty
@frappe.whitelist()
def get_bin_details(row, company, for_warehouse=None, all_warehouse=False):
if isinstance(row, str):
@@ -1649,9 +1742,10 @@ def get_warehouse_list(warehouses):
@frappe.whitelist()
def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_data=None):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
def get_items_for_material_requests(
doc: str | dict, warehouses: str | list[dict] | None = None, get_parent_warehouse_data: bool | None = None
):
doc = frappe._dict(json.loads(doc) if isinstance(doc, str) else doc)
if warehouses:
warehouses = list(set(get_warehouse_list(warehouses)))
@@ -1827,6 +1921,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
new_mr_items = []
locations_by_item = _get_transfer_locations(mr_items, warehouses, company)
for item in mr_items:
get_materials_from_other_locations(
item,
@@ -1834,10 +1929,14 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
new_mr_items,
company,
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
locations=locations_by_item[item.get("item_code")],
)
mr_items = new_mr_items
if doc.get("consider_minimum_order_qty"):
_apply_minimum_order_qty(mr_items)
if not mr_items:
to_enable = frappe.bold(
frappe.get_meta("Production Plan").get_field("ignore_existing_ordered_qty").label
@@ -1857,61 +1956,88 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
def get_materials_from_other_locations(
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False, locations=None
):
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
locations = get_available_item_locations(
item.get("item_code"),
warehouses,
item.get("quantity") * item.get("conversion_factor"),
company,
ignore_validation=True,
)
if locations is None:
locations = _get_transfer_locations([item], warehouses, company)[item.get("item_code")]
required_qty = item.get("quantity")
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
# Convert qty to stock UOM
required_qty = required_qty * item.get("conversion_factor")
# get available material by transferring to production warehouse
for d in locations:
if required_qty <= 0:
return
new_dict = copy.deepcopy(item)
quantity = required_qty if d.get("qty") > required_qty else d.get("qty")
new_dict.update(
{
"quantity": quantity,
"material_request_type": "Material Transfer",
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
"from_warehouse": d.get("warehouse"),
"conversion_factor": 1.0,
}
)
required_qty -= quantity
new_mr_items.append(new_dict)
required_qty = _transfer_from_locations(item, locations, new_mr_items, required_qty)
# raise purchase request for remaining qty
precision = frappe.get_precision("Material Request Plan Item", "quantity")
if flt(required_qty, precision) > 0:
if consider_minimum_order_qty:
required_qty = max(required_qty, flt(item.get("min_order_qty")))
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
min_order_qty = flt(item.get("min_order_qty")) if consider_minimum_order_qty else 0
item["quantity"] = _quantity_in_purchase_uom(
required_qty, item.get("conversion_factor"), min_order_qty
)
new_mr_items.append(item)
def _get_transfer_locations(mr_items, warehouses, company):
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
required_qty_by_item = defaultdict(float)
for item in mr_items:
required_qty_by_item[item.get("item_code")] += max(
0, flt(item.get("quantity")) * flt(item.get("conversion_factor"))
)
return {
item_code: get_available_item_locations(
item_code, warehouses, required_qty, company, ignore_validation=True
)
if required_qty > 0
else []
for item_code, required_qty in required_qty_by_item.items()
}
def _transfer_from_locations(item, locations, new_mr_items, required_qty):
precision = frappe.get_precision("Material Request Plan Item", "quantity")
transfers_by_warehouse = {}
for d in locations:
if flt(required_qty, precision) <= 0:
return required_qty
quantity = flt(min(required_qty, d.get("qty")), precision)
if quantity <= 0:
continue
d["qty"] -= quantity
required_qty -= quantity
warehouse = d.get("warehouse")
if warehouse in transfers_by_warehouse:
transfer = transfers_by_warehouse[warehouse]
transfer["quantity"] = flt(transfer["quantity"] + quantity, precision)
continue
new_dict = copy.deepcopy(item)
new_dict.update(
{
"quantity": quantity,
"material_request_type": "Material Transfer",
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
"from_warehouse": warehouse,
"conversion_factor": 1.0,
}
)
transfers_by_warehouse[warehouse] = new_dict
new_mr_items.append(new_dict)
return required_qty
@frappe.whitelist()
def get_item_data(item_code):
item_details = get_item_details(item_code)

View File

@@ -56,6 +56,237 @@ class TestProductionPlan(ERPNextTestSuite):
if not frappe.db.get_value("BOM", {"item": item}):
make_bom(item=item, raw_materials=raw_materials)
def _plan_with_shared_raw_material(self, rm_item, qty_per_order):
fg_item = make_item(properties={"is_stock_item": 1}).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item,
ignore_existing_ordered_qty=1,
do_not_save=1,
skip_getting_mr_items=1,
)
pln.get_items_from = "Sales Order"
for _ in range(2):
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
pln.append(
"sales_orders",
{
"sales_order": so.name,
"sales_order_date": so.transaction_date,
"customer": so.customer,
"grand_total": so.grand_total,
},
)
pln.get_items()
return pln
def test_minimum_order_qty_surplus_covers_later_rows(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 100, "valuation_rate": 100}).name
make_stock_entry(item_code=rm_item, qty=40, rate=100, target="_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
self.assertEqual(quantities, [0, 100])
def test_minimum_order_qty_surplus_carries_across_sales_orders(self):
from erpnext.stock.utils import get_or_make_bin
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.consider_minimum_order_qty = 1
for projected_qty in (0, -5):
frappe.db.set_value("Bin", bin_name, "projected_qty", projected_qty)
for consider_projected_qty in (0, 1):
pln.ignore_existing_ordered_qty = consider_projected_qty
for second_qty, expected_qty in ((500, [1234, 0]), (984, [1234, 0]), (1000, [1234, 1234])):
with self.subTest(
projected_qty=projected_qty,
consider_projected_qty=consider_projected_qty,
second_qty=second_qty,
):
pln.po_items[1].planned_qty = second_qty
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], expected_qty)
self.assertEqual([row["required_bom_qty"] for row in items], [250, second_qty])
self.assertEqual(
[row["sales_order"] for row in items], [row.sales_order for row in pln.po_items]
)
def test_minimum_order_qty_allocation_uses_sales_order_name(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 500
pln.consider_minimum_order_qty = 1
sales_orders = sorted(row.sales_order for row in pln.po_items)
for reverse in (False, True):
with self.subTest(reverse=reverse):
pln.set("po_items", sorted(pln.po_items, key=lambda row: row.sales_order, reverse=reverse))
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual(
{row["sales_order"]: row["quantity"] for row in items},
{sales_orders[0]: 1234, sales_orders[1]: 0},
)
self.assertEqual(
[row["sales_order"] for row in items], [row.sales_order for row in pln.po_items]
)
self.assertEqual(
[row["required_bom_qty"] for row in items], [row.planned_qty for row in pln.po_items]
)
def test_minimum_order_qty_groups_rows_without_sales_order(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import _apply_minimum_order_qty
rows = [
{
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"material_request_type": "Purchase",
"uom": "Nos",
"min_order_qty": 1234,
"quantity": 250,
"sales_order": sales_order,
}
for sales_order in ("SO-2", None, "SO-1", "")
]
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [0, 984, 0, 250])
self.assertEqual([row["sales_order"] for row in rows], ["SO-2", None, "SO-1", ""])
def test_minimum_order_qty_does_not_purchase_when_stock_covers_demand(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
make_stock_entry(item_code=rm_item, qty=1000, rate=100, target="_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [0, 0])
def test_minimum_order_qty_disabled_for_repeated_raw_material(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 500
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [250, 500])
def test_minimum_order_qty_does_not_transfer_surplus_stock(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
source_warehouse = create_warehouse("MOQ Sufficient Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=1500, rate=100, target=source_warehouse)
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.consider_minimum_order_qty = 1
pln.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
self.assertEqual([row["material_request_type"] for row in items], ["Material Transfer"] * 2)
self.assertEqual([row["quantity"] for row in items], [250, 250])
def test_minimum_order_qty_respects_purchase_groups_and_sales_orders(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
_apply_minimum_order_qty,
)
base_row = {
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"supplier": "_Test Supplier",
"sales_order": "SO-1",
"material_request_type": "Purchase",
"uom": "Nos",
"conversion_factor": 1,
"min_order_qty": 1234,
"quantity": 250,
}
rows = [
base_row.copy(),
base_row | {"quantity": 500},
base_row | {"warehouse": "_Test Warehouse 1 - _TC"},
base_row | {"supplier": "_Test Supplier 1"},
base_row | {"item_code": "Raw Material Item 2"},
base_row | {"material_request_type": "Subcontracting"},
base_row | {"material_request_type": "Material Transfer", "quantity": 1000},
base_row | {"material_request_type": "Manufacture"},
base_row | {"sales_order": "SO-2", "quantity": 400},
base_row | {"sales_order": "SO-3", "quantity": 100},
]
expected_rows = [row.copy() for row in rows]
quantities = [734, 500, 1234, 1234, 1234, 1234, 1000, 250, 0, 1234]
for row, quantity in zip(expected_rows, quantities, strict=True):
row["quantity"] = quantity
_apply_minimum_order_qty(rows)
self.assertEqual(rows, expected_rows)
def test_minimum_order_qty_shortfall_uses_stock_uom(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
_apply_minimum_order_qty,
)
frappe.db.set_default("float_precision", "3")
rows = [
{
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"material_request_type": "Purchase",
"uom": uom,
"conversion_factor": conversion_factor,
"min_order_qty": 1234,
"quantity": quantity,
}
for uom, conversion_factor, quantity in (("_Test UOM 1", 7, 10), ("Nos", 1, 500))
]
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [104.858, 500])
self.assertGreaterEqual(sum(row["quantity"] * row["conversion_factor"] for row in rows), 1234)
rows[0].update(uom="Nos", quantity=10)
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [105, 500])
def test_minimum_order_qty_surplus_includes_rounding(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
_apply_minimum_order_qty,
)
rows = [
{
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"sales_order": sales_order,
"material_request_type": "Purchase",
"uom": "Nos",
"conversion_factor": 2,
"min_order_qty": 5,
"quantity": 1.5,
}
for sales_order in ("SO-1", "SO-2")
]
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [3, 0])
def test_min_order_qty_keeps_small_shortages_in_purchase_uom(self):
frappe.db.set_default("float_precision", "3")
conversion_factor = 10000
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=1)
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [5, 0])
self.assertEqual(sum(row["quantity"] * row["conversion_factor"] for row in items), 50000)
def test_production_plan_mr_creation(self):
"Test if MRs are created for unavailable raw materials."
pln = create_production_plan(item_code="Test Production Item 1")
@@ -110,6 +341,186 @@ class TestProductionPlan(ERPNextTestSuite):
pln = frappe.get_doc("Production Plan", pln.name)
pln.cancel()
def test_production_plan_material_request_skips_zero_qty_items(self):
pln = create_production_plan(item_code="Test Production Item 1")
zero_qty_item, requested_item = pln.mr_items
zero_qty_item.quantity = "0"
pln.make_material_request()
material_request_items = frappe.get_all(
"Material Request Item",
filters={"production_plan": pln.name},
fields=["item_code", "qty"],
)
self.assertEqual(
material_request_items,
[{"item_code": requested_item.item_code, "qty": requested_item.quantity}],
)
def _plan_for_safety_stock(self, rm_item, qty_per_order, bom_quantity=1):
fg_item = make_item(properties={"is_stock_item": 1}).name
make_bom(
item=fg_item,
raw_materials=[rm_item],
source_warehouse="_Test Warehouse - _TC",
quantity=bom_quantity,
)
pln = create_production_plan(
item_code=fg_item,
ignore_existing_ordered_qty=1,
do_not_save=1,
skip_getting_mr_items=1,
)
pln.get_items_from = "Sales Order"
for _ in range(2):
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
pln.append(
"sales_orders",
{
"sales_order": so.name,
"sales_order_date": so.transaction_date,
"customer": so.customer,
"grand_total": so.grand_total,
},
)
pln.get_items()
return pln
def test_safety_stock_added_once_for_repeated_raw_material(self):
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 10, "valuation_rate": 100}).name
make_stock_entry(item_code=rm_item, qty=100, rate=100, target="_Test Warehouse - _TC")
pln = self._plan_for_safety_stock(rm_item, qty_per_order=50)
pln.include_safety_stock = 1
items = get_items_for_material_requests(pln.as_dict())
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
self.assertEqual(quantities, [0, 10])
def test_safety_stock_added_once_with_negative_or_ignored_projected_qty(self):
from erpnext.stock.utils import get_or_make_bin
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 100}).name
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
pln = self._plan_for_safety_stock(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 1000
pln.include_safety_stock = 1
for projected_qty in (-5, 0, 200, 1500):
frappe.db.set_value("Bin", bin_name, "projected_qty", projected_qty)
for consider_projected_qty in (0, 1):
with self.subTest(projected_qty=projected_qty, consider_projected_qty=consider_projected_qty):
pln.ignore_existing_ordered_qty = consider_projected_qty
items = get_items_for_material_requests(pln.as_dict())
expected_qty = [350, 1000]
if consider_projected_qty and projected_qty > 0:
expected_qty = [150, 1000] if projected_qty == 200 else [0, 0]
self.assertEqual([row["quantity"] for row in items], expected_qty)
self.assertEqual([row["required_bom_qty"] for row in items], [250, 1000])
self.assertEqual([row["safety_stock"] for row in items], [100, 100])
self.assertEqual(
[row["sales_order"] for row in items], [row.sales_order for row in pln.po_items]
)
def test_safety_stock_disabled_with_negative_projected_qty(self):
from erpnext.stock.utils import get_or_make_bin
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 100}).name
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
frappe.db.set_value("Bin", bin_name, "projected_qty", -5)
pln = self._plan_for_safety_stock(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 1000
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [250, 1000])
def test_safety_stock_is_separate_for_each_item_and_warehouse(self):
from collections import defaultdict
from erpnext.manufacturing.doctype.production_plan.production_plan import (
_required_qty_for_mr,
)
row = frappe._dict(qty=250, safety_stock=100, purchase_uom="Nos", stock_uom="Nos")
items_and_warehouses = [
("Raw Material Item 1", "_Test Warehouse - _TC"),
("Raw Material Item 1", "_Test Warehouse 1 - _TC"),
("Raw Material Item 2", "_Test Warehouse - _TC"),
]
for consider_projected_qty in (0, 1):
with self.subTest(consider_projected_qty=consider_projected_qty):
consumed_qty = defaultdict(float)
quantities = []
for item_code, warehouse in items_and_warehouses * 2:
row.item_code = item_code
quantities.append(
_required_qty_for_mr(
row, consider_projected_qty, warehouse, {"projected_qty": -5}, consumed_qty, True
)
)
self.assertEqual(quantities, [350, 350, 350, 250, 250, 250])
def test_safety_stock_added_once_before_transferring_materials(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_or_make_bin
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 100, "min_order_qty": 1234}).name
source_warehouse = create_warehouse("Safety Stock Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=2000, rate=100, target=source_warehouse)
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
frappe.db.set_value("Bin", bin_name, "projected_qty", -5)
pln = self._plan_for_safety_stock(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 1000
pln.for_warehouse = "_Test Warehouse - _TC"
pln.include_safety_stock = 1
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
self.assertEqual([row["material_request_type"] for row in items], ["Material Transfer"] * 2)
self.assertEqual([row["quantity"] for row in items], [350, 1000])
def test_safety_stock_does_not_share_purchase_rounding_between_rows(self):
from erpnext.stock.utils import get_or_make_bin
rm_item = make_item(properties={"is_stock_item": 1, "stock_uom": "Nos", "safety_stock": 1}).name
pln = self._plan_for_safety_stock(rm_item, qty_per_order=1, bom_quantity=2)
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
for projected_qty in (0, 0.25, 0.75):
frappe.db.set_value("Bin", bin_name, "projected_qty", projected_qty)
for include_safety_stock in (0, 1):
for consider_projected_qty in (0, 1):
with self.subTest(
projected_qty=projected_qty,
include_safety_stock=include_safety_stock,
consider_projected_qty=consider_projected_qty,
):
pln.include_safety_stock = include_safety_stock
pln.ignore_existing_ordered_qty = consider_projected_qty
items = get_items_for_material_requests(pln.as_dict())
expected_qty = [2, 1] if include_safety_stock else [1, 1]
if consider_projected_qty and projected_qty == 0.75:
expected_qty = [1, 1] if include_safety_stock else [0, 1]
self.assertEqual([row["quantity"] for row in items], expected_qty)
self.assertEqual([row["required_bom_qty"] for row in items], [0.5, 0.5])
self.assertEqual(
[row["sales_order"] for row in items], [row.sales_order for row in pln.po_items]
)
def test_safety_stock_with_fractional_minimum_uses_whole_purchase_uom(self):
rm_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "Nos", "safety_stock": 0.5, "min_order_qty": 2.5}
).name
pln = self._plan_for_safety_stock(rm_item, qty_per_order=1)
pln.set("po_items", [pln.po_items[0]])
pln.include_safety_stock = 1
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["quantity"], 3)
def test_production_plan_start_date(self):
"Test if Work Order has same Planned Start Date as Prod Plan."
planned_date = add_to_date(date=None, days=3)
@@ -869,12 +1280,59 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertTrue(len(plan.sub_assembly_items), 1) # check if sub-assembly items merged
self.assertEqual(plan.sub_assembly_items[0].qty, 2.0)
self.assertEqual(plan.sub_assembly_items[0].stock_qty, 2.0)
self.assertEqual(plan.sub_assembly_items[0].required_qty, 2.0)
# change warehouse in one row, sub-assemblies should not merge
plan.po_items[0].warehouse = "Finished Goods - _TC"
plan.get_sub_assembly_items()
self.assertTrue(len(plan.sub_assembly_items), 2)
def test_consolidated_subassembly_required_qty_with_projected_stock(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
rm_item = make_item(properties={"is_stock_item": 1}).name
subassembly = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name
make_bom(item=subassembly, raw_materials=[rm_item])
warehouse = create_warehouse("Consolidated Sub Assembly Warehouse", company="_Test Company")
make_stock_entry(item_code=subassembly, qty=750, rate=100, target=warehouse)
finished_item = make_item(properties={"is_stock_item": 1}).name
make_bom(item=finished_item, raw_materials=[subassembly])
plan = create_production_plan(
item_code=finished_item, planned_qty=1000, do_not_save=1, skip_getting_mr_items=1
)
plan.append(
"po_items",
{
"item_code": finished_item,
"bom_no": plan.po_items[0].bom_no,
"planned_qty": 1000,
"use_multi_level_bom": 1,
"planned_start_date": now_datetime(),
},
)
plan.sub_assembly_warehouse = warehouse
for consider_projected_qty in (0, 1):
with self.subTest(consider_projected_qty=consider_projected_qty):
plan.skip_available_sub_assembly_item = consider_projected_qty
plan.combine_sub_items = 0
plan.get_sub_assembly_items()
self.assertEqual([row.required_qty for row in plan.sub_assembly_items], [1000, 1000])
self.assertEqual(
[row.qty for row in plan.sub_assembly_items],
[250, 1000] if consider_projected_qty else [1000, 1000],
)
plan.combine_sub_items = 1
plan.get_sub_assembly_items()
self.assertEqual(len(plan.sub_assembly_items), 1)
row = plan.sub_assembly_items[0]
self.assertEqual(row.required_qty, 2000)
self.assertEqual(row.projected_qty, 750)
self.assertEqual(row.actual_qty, 750)
self.assertEqual(row.qty, 1250 if consider_projected_qty else 2000)
self.assertEqual(row.stock_qty, row.qty)
def test_pp_to_mr_customer_provided(self):
"Test Material Request from Production Plan for Customer Provided Item."
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
@@ -1126,12 +1584,12 @@ class TestProductionPlan(ERPNextTestSuite):
def test_multiple_work_order_for_production_plan_item(self):
"Test producing Prod Plan (making WO) in parts."
def create_work_order(item, pln, qty):
def create_work_order(pln, qty):
# Get Production Items
items_data = pln.get_production_items()
# Update qty
items_data[(pln.po_items[0].name, item, None, pln.po_items[0].planned_start_date)]["qty"] = qty
items_data[pln.po_items[0].name]["qty"] = qty
# Create and Submit Work Order for each item in items_data
for _key, item in items_data.items():
@@ -1159,17 +1617,17 @@ class TestProductionPlan(ERPNextTestSuite):
wo_list = []
# Create and Submit 1st Work Order for 3 qty
create_work_order(item, pln, 3)
create_work_order(pln, 3)
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 3)
# Create and Submit 2nd Work Order for 2 qty
create_work_order(item, pln, 2)
create_work_order(pln, 2)
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 5)
# Overproduction
self.assertRaises(OverProductionError, create_work_order, item=item, pln=pln, qty=2)
self.assertRaises(OverProductionError, create_work_order, pln=pln, qty=2)
# Cancel 1st Work Order
wo1 = frappe.get_doc("Work Order", wo_list[0])
@@ -1350,8 +1808,11 @@ class TestProductionPlan(ERPNextTestSuite):
make_bom(item=fg_item, raw_materials=[sub_assembly_item], rm_qty=4)
# Step - 1: Create Production Plan
pln = create_production_plan(item_code=fg_item, planned_qty=5, skip_getting_mr_items=1)
pln = create_production_plan(
item_code=fg_item, planned_qty=5, skip_getting_mr_items=1, do_not_submit=1
)
pln.get_sub_assembly_items()
pln.submit()
# Step - 2: Create Work Orders
pln.make_work_order()
@@ -1743,6 +2204,125 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertFalse(items)
def _plan_for_transfer_allocation(self, rm_item, qty_per_order):
fg_item = make_item(properties={"is_stock_item": 1}).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item,
ignore_existing_ordered_qty=1,
do_not_save=1,
skip_getting_mr_items=1,
)
pln.get_items_from = "Sales Order"
for _ in range(2):
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
pln.append(
"sales_orders",
{
"sales_order": so.name,
"sales_order_date": so.transaction_date,
"customer": so.customer,
"grand_total": so.grand_total,
},
)
pln.get_items()
return pln
def test_transfer_batches_share_stock_across_requirements(self):
rm_item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1, "create_new_batch": 1}).name
source_warehouse = "_Test Warehouse 1 - _TC"
for qty in (1, 1, 5, 3, 3, 4, 100):
make_stock_entry(item_code=rm_item, qty=qty, rate=100, target=source_warehouse)
pln = self._plan_for_transfer_allocation(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 1000
pln.for_warehouse = "_Test Warehouse - _TC"
warehouses = [{"warehouse": source_warehouse}]
items = get_items_for_material_requests(pln.as_dict(), warehouses=warehouses)
self.assertEqual(
[row["material_request_type"] for row in items], ["Material Transfer", "Purchase", "Purchase"]
)
self.assertEqual([row["quantity"] for row in items], [117, 133, 1000])
self.assertEqual(items[0]["from_warehouse"], source_warehouse)
self.assertEqual([row["warehouse"] for row in items], [pln.for_warehouse] * 3)
self.assertEqual([row["required_bom_qty"] for row in items], [250, 250, 1000])
self.assertEqual(
[row["sales_order"] for row in items],
[pln.po_items[0].sales_order] * 2 + [pln.po_items[1].sales_order],
)
self.assertEqual(items, get_items_for_material_requests(pln.as_dict(), warehouses=warehouses))
def test_transfer_batches_keep_source_warehouses_and_requirements_separate(self):
rm_item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1, "create_new_batch": 1}).name
source_warehouses = ["_Test Warehouse 1 - _TC", "_Test Warehouse 2 - _TC"]
for warehouse, quantities in zip(source_warehouses, ((10, 20), (50, 60)), strict=True):
for qty in quantities:
make_stock_entry(item_code=rm_item, qty=qty, rate=100, target=warehouse)
pln = self._plan_for_transfer_allocation(rm_item, qty_per_order=50)
pln.po_items[1].planned_qty = 100
pln.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(
pln.as_dict(), warehouses=[{"warehouse": warehouse} for warehouse in source_warehouses]
)
transfers = [row for row in items if row["material_request_type"] == "Material Transfer"]
self.assertEqual(len(transfers), 3)
self.assertEqual(
{(row["sales_order"], row["from_warehouse"]): row["quantity"] for row in transfers},
{
(pln.po_items[0].sales_order, source_warehouses[0]): 30,
(pln.po_items[0].sales_order, source_warehouses[1]): 20,
(pln.po_items[1].sales_order, source_warehouses[1]): 90,
},
)
purchases = [row for row in items if row["material_request_type"] == "Purchase"]
self.assertEqual(len(purchases), 1)
self.assertEqual(purchases[0]["quantity"], 10)
self.assertEqual(purchases[0]["sales_order"], pln.po_items[1].sales_order)
def test_transfer_shared_stock_uses_stock_uom(self):
rm_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "Nos", "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": 10}],
).name
source_warehouse = "_Test Warehouse 1 - _TC"
make_stock_entry(item_code=rm_item, qty=60, rate=100, target=source_warehouse)
pln = self._plan_for_transfer_allocation(rm_item, qty_per_order=50)
pln.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
self.assertEqual(
[row["material_request_type"] for row in items],
["Material Transfer", "Material Transfer", "Purchase"],
)
self.assertEqual([row["quantity"] for row in items], [50, 10, 4])
self.assertEqual([row["uom"] for row in items], ["Nos", "Nos", "_Test UOM 1"])
self.assertEqual([row["conversion_factor"] for row in items], [1, 1, 10])
self.assertEqual(
[row["sales_order"] for row in items],
[pln.po_items[0].sales_order] + [pln.po_items[1].sales_order] * 2,
)
def test_transfer_shared_stock_rounds_away_float_residue(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
_transfer_from_locations,
)
locations = [
frappe._dict(qty=0.7, warehouse="_Test Warehouse 1 - _TC"),
frappe._dict(qty=1, warehouse="_Test Warehouse 2 - _TC"),
]
transfers = []
for quantity in (0.1, 0.2, 0.4):
item = {"item_code": "Raw Material Item 1", "quantity": quantity, "conversion_factor": 1}
self.assertEqual(_transfer_from_locations(item, locations, transfers, quantity), 0)
self.assertEqual(
[(row["from_warehouse"], row["quantity"]) for row in transfers],
[("_Test Warehouse 1 - _TC", quantity) for quantity in (0.1, 0.2, 0.4)],
)
def test_transfer_and_purchase_mrp_for_purchase_uom(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@@ -2262,6 +2842,125 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
def test_min_order_qty_conversion_takes_grid_ceiling(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
_quantity_in_purchase_uom,
)
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197, 50000), 110.232)
self.assertEqual(_quantity_in_purchase_uom(2000, 0.453592, 2000), 4409.249)
self.assertEqual(_quantity_in_purchase_uom(10, 0.5, 10), 20.0)
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197), 110.231)
def test_min_order_qty_grid_ceiling_in_plan_items(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
conversion_factor = 453.592292197
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(item_code=fg_item, planned_qty=1, do_not_submit=1)
pln.consider_minimum_order_qty = 1
mr_items = get_items_for_material_requests(pln.as_dict())
self.assertEqual(mr_items[0].get("quantity"), 110.232)
self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000)
def test_min_order_qty_grid_ceiling_from_other_locations(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
conversion_factor = 453.592292197
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
rm_warehouse = create_warehouse("MOQ Ceiling RM Warehouse", company="_Test Company")
source_warehouse = create_warehouse("MOQ Ceiling Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=4, rate=100, target=source_warehouse)
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(item_code=fg_item, planned_qty=10, do_not_submit=1)
pln.for_warehouse = rm_warehouse
pln.consider_minimum_order_qty = 1
pln.ignore_existing_ordered_qty = 1
mr_items = get_items_for_material_requests(
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
)
rows_by_type = {d.get("material_request_type"): d for d in mr_items}
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
self.assertEqual(rows_by_type["Purchase"].get("quantity"), 110.232)
def test_min_order_qty_round_trip_to_purchase_order(self):
from erpnext.stock.doctype.material_request.material_request import (
get_item_default_suppliers,
make_purchase_orders_by_supplier,
)
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={
"is_stock_item": 1,
"stock_uom": "_Test UOM 1",
"purchase_uom": "Pound",
"min_order_qty": 50000,
},
uoms=[{"uom": "Pound", "conversion_factor": 453.592292197}],
).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item, planned_qty=1, skip_getting_mr_items=1, do_not_submit=1
)
pln.consider_minimum_order_qty = 1
pln.set("mr_items", get_items_for_material_requests(pln.as_dict()))
pln.submit_material_request = 1
pln.save()
pln.submit()
pln.make_material_request()
mr_name = frappe.db.get_value(
"Material Request Item", {"production_plan": pln.name, "item_code": rm_item}, "parent"
)
self.assertTrue(mr_name)
pending_items = get_item_default_suppliers(mr_name)
self.assertEqual(len(pending_items), 1)
self.assertEqual(flt(pending_items[0]["pending_qty"], 3), 110.232)
purchase_orders = make_purchase_orders_by_supplier(
mr_name,
[
row | {"qty": flt(row["pending_qty"], 3), "supplier": "_Test Supplier"}
for row in pending_items
],
)
self.assertEqual(len(purchase_orders), 1)
po = frappe.get_doc("Purchase Order", purchase_orders[0])
self.assertEqual(po.items[0].qty, 110.232)
self.assertGreaterEqual(po.items[0].stock_qty, 50000)
def test_fg_item_quantity(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
@@ -3167,6 +3866,46 @@ class TestProductionPlan(ERPNextTestSuite):
"The phantom BOM was not re-exploded for the second po_item.",
)
def test_set_status_requires_write_permission(self):
pln = create_production_plan(item_code="Test Production Item 1")
with self.set_user(create_user_without_production_plan_access()):
doc = frappe.get_doc("Production Plan", pln.name)
self.assertRaises(frappe.PermissionError, doc.set_status)
def test_work_order_status_rollup_without_production_plan_permission(self):
pln = create_production_plan(item_code="Test Production Item 1")
pln.make_work_order()
wo_name = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name")
frappe.db.set_value("Production Plan Item", pln.po_items[0].name, "ordered_qty", 99)
with self.set_user(create_user_without_production_plan_access()):
frappe.get_doc("Work Order", wo_name).update_ordered_qty()
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 0.0)
self.assertEqual(pln.status, "Submitted")
def test_material_request_status_rollup_without_production_plan_permission(self):
pln = create_production_plan(item_code="Test Production Item 1")
pln.make_material_request()
plan_item = pln.mr_items[0].name
mr_name = frappe.db.get_value(
"Material Request Item", {"material_request_plan_item": plan_item}, "parent"
)
frappe.get_doc("Material Request", mr_name).submit()
frappe.db.set_value("Material Request Plan Item", plan_item, "requested_qty", 0)
with self.set_user(create_user_without_production_plan_access()):
frappe.get_doc("Material Request", mr_name).update_requested_qty_in_production_plan()
pln.reload()
requested_qty = frappe.db.get_value("Material Request Plan Item", plan_item, "requested_qty")
self.assertGreater(requested_qty, 0)
self.assertEqual(pln.status, "Material Requested")
def create_production_plan(**args):
"""
@@ -3299,3 +4038,19 @@ def make_bom(**args):
frappe.set_value("Item", args.item, "default_bom", bom.name)
return bom
def create_user_without_production_plan_access():
user = "test_production_plan_no_access@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Production Plan No Access",
"send_welcome_email": 0,
"roles": [{"doctype": "Has Role", "role": "Stock User"}],
}
).insert(ignore_permissions=True)
return user

View File

@@ -0,0 +1,532 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
create_production_plan,
make_bom,
)
from erpnext.manufacturing.doctype.production_plan.work_order_quantities import (
ProductionPlanWorkOrderQuantities,
)
from erpnext.manufacturing.doctype.work_order.work_order import (
OverProductionError,
StockOverProductionError,
close_work_order,
stop_unstop,
)
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_se_from_wo
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
REFERENCE_FIELDS = ("production_plan_item", "production_plan_sub_assembly_item")
class TestProductionPlanWorkOrderQuantities(ERPNextTestSuite):
def setUp(self):
self.warehouse = "_Test Warehouse - _TC"
self.raw_material, self.sub_assembly, self.finished_good = (
make_item(properties={"is_stock_item": 1, "stock_uom": "Kg", "valuation_rate": 10}).name
for _ in range(3)
)
for item, material in (
(self.sub_assembly, self.raw_material),
(self.finished_good, self.sub_assembly),
):
bom = make_bom(item=item, raw_materials=[material], do_not_save=True)
bom.process_loss_percentage = 10
bom.insert().submit()
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 0)
def test_quantity_limit_on_submit(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.qty = 50
first.submit()
second = self.create_work_order(plan, field)
self.assertEqual(second.qty, 50)
self.assert_overproduction(second, 60)
second.qty = 50
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_recorded_loss_creates_replacement(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.assert_pending_qty(plan, field, 0)
manufacture = self.manufacture_with_loss(first)
first.reload()
self.assertEqual(first.process_loss_qty, 10)
self.assertEqual(first.produced_qty, 90)
self.assert_pending_qty(plan, field, 10)
replacement = self.create_work_order(plan, field)
self.assertEqual(replacement.qty, 10)
self.assert_overproduction(replacement, 11)
replacement.qty = 10
replacement.submit()
self.assert_pending_qty(plan, field, 0)
row = self.plan_row(plan, field)
self.assertEqual(row.ordered_qty, 110)
self.assert_loss_reversal_blocked(manufacture)
first.reload()
self.assertEqual(first.process_loss_qty, 10)
self.assertEqual(first.produced_qty, 90)
self.assert_pending_qty(plan, field, 0)
replacement.cancel()
manufacture.cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
first.reload().cancel()
self.assert_pending_qty(plan, field, 100)
def test_cumulative_manufacture_loss_exceeds_work_order(self):
plan = self.make_plan()
for field in (None, *REFERENCE_FIELDS):
with self.subTest(field=field):
first = self.create_work_order(plan, field or "production_plan_item")
if field is None:
first.production_plan = None
first.production_plan_item = None
first.qty = 100
first.submit()
self.manufacture_with_loss(first, loss_qty=99)
second_entry = self.manufacture_with_loss(first, loss_qty=99, submit=False)
self.assert_manufacture_rejected(second_entry, StockOverProductionError)
self.assertEqual(first.reload().produced_qty, 1)
self.assertEqual(first.process_loss_qty, 99)
if field:
self.assert_pending_qty(plan, field, 99)
replacement = self.create_work_order(plan, field)
replacement.submit()
self.assert_pending_qty(plan, field, 0)
def test_cumulative_manufacture_loss_respects_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.manufacture_with_loss(first, qty=50)
self.manufacture_with_loss(first, qty=50)
last_entry = self.manufacture_with_loss(first, qty=10)
self.assertEqual(first.reload().produced_qty, 99)
self.assertEqual(first.process_loss_qty, 11)
self.assert_pending_qty(plan, field, 1)
excess = self.manufacture_with_loss(first, qty=1, submit=False)
self.assert_manufacture_rejected(excess, StockOverProductionError)
excess.delete()
last_entry.cancel()
self.assertEqual(first.reload().produced_qty, 90)
self.assertEqual(first.process_loss_qty, 10)
self.manufacture_with_loss(first, qty=10)
@ERPNextTestSuite.change_settings("System Settings", {"float_precision": 6})
def test_cumulative_fractional_manufacture_loss(self):
plan = self.make_plan(qty=0.3)
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.manufacture_with_loss(first, qty=0.1, loss_qty=0.025)
self.manufacture_with_loss(first, qty=0.2, loss_qty=0.05)
self.assertAlmostEqual(first.reload().produced_qty, 0.225)
self.assertAlmostEqual(first.process_loss_qty, 0.075)
excess = self.manufacture_with_loss(first, qty=0.001, submit=False)
self.assert_manufacture_rejected(excess, StockOverProductionError)
def test_existing_excess_loss_preserves_produced_quantity(self):
for field in REFERENCE_FIELDS:
for loss_qty, produced_qty in ((198, 2), (100, 2), (20, 90), (198, 0)):
with self.subTest(field=field, loss_qty=loss_qty, produced_qty=produced_qty):
plan = self.make_plan()
first = self.create_work_order(plan, field)
first.submit()
# Reproduce records saved before cumulative manufacture validation existed.
first.db_set({"process_loss_qty": loss_qty, "produced_qty": produced_qty})
pending_qty = 100 - produced_qty
self.assert_pending_qty(plan, field, pending_qty)
replacement = self.create_work_order(plan, field)
self.assertEqual(replacement.qty, pending_qty)
self.assert_overproduction(replacement, pending_qty + 1)
replacement.qty = pending_qty
replacement.submit()
self.assert_pending_qty(plan, field, 0)
quantities = ProductionPlanWorkOrderQuantities(plan.name)
quantities.validate_work_order(first, process_loss_qty=loss_qty)
with self.assertRaises(OverProductionError):
quantities.validate_work_order(first, process_loss_qty=0)
def test_more_production_cannot_consume_replacement_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
self.manufacture_with_loss(first, loss_qty=99)
replacement = self.create_work_order(plan, field)
replacement.qty = 109
replacement.submit()
# This fits the first Work Order's allowance, but exceeds the plan's 110 units.
excess = self.manufacture_with_loss(first, qty=10, loss_qty=9, submit=False)
self.assert_manufacture_rejected(excess, OverProductionError)
self.assertEqual(first.reload().produced_qty, 1)
self.assertEqual(first.process_loss_qty, 99)
def test_loss_reversal_with_draft_replacement(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
manufacture = self.manufacture_with_loss(first)
replacement = self.create_work_order(plan, field)
manufacture.cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
self.assert_pending_qty(plan, field, 0)
self.assert_overproduction(replacement, 10)
def test_partial_loss_reversal_with_overproduction_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 5)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
manufactures = [self.manufacture_with_loss(first, qty=50) for _ in range(2)]
self.assertEqual(first.reload().process_loss_qty, 10)
replacement = self.create_work_order(plan, field)
replacement.submit()
# Retaining five units of loss keeps the net quantity at the allowed 105.
manufactures[1].cancel()
self.assertEqual(first.reload().process_loss_qty, 5)
self.assert_loss_reversal_blocked(manufactures[0])
self.assertEqual(first.reload().process_loss_qty, 5)
replacement.cancel()
manufactures[0].cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
def test_job_card_loss_reversal_with_replacement(self):
self.make_bom_with_operation(self.finished_good, self.raw_material)
plan = self.make_plan()
first = self.create_work_order(plan, "production_plan_item")
first.submit()
job_card = frappe.get_last_doc("Job Card", {"work_order": first.name})
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=90,
for_quantity=100,
pending_qty=0,
process_loss_qty=10,
end_time="2024-05-01 09:00:00",
)
job_card.reload().submit()
self.assertEqual(first.reload().process_loss_qty, 10)
make_stock_entry(item_code=self.raw_material, target=self.warehouse, qty=100, basic_rate=10)
manufacture = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
replacement = self.create_work_order(plan, "production_plan_item")
replacement.submit()
with self.assert_plan_locked_before_work_order_update(first):
manufacture.cancel()
job_card.reload()
self.assert_loss_reversal_blocked(job_card)
self.assertEqual(first.reload().process_loss_qty, 10)
self.assertEqual(first.operations[0].process_loss_qty, 10)
replacement.cancel()
job_card.cancel()
self.assertEqual(first.reload().process_loss_qty, 0)
def test_expected_loss_does_not_allow_extra_quantity(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
work_order = self.create_work_order(plan, field)
self.assert_overproduction(work_order, 110)
def test_overproduction_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
self.assertEqual(first.qty, 100)
first.submit()
self.assert_pending_qty(plan, field, 0)
second = self.copy_work_order(first)
self.assert_overproduction(second, 11)
second.qty = 10
second.submit()
def test_drafts_and_cancelled_orders_do_not_consume_quantity(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
second = self.create_work_order(plan, field)
self.assertEqual(first.qty, second.qty)
first.submit()
self.assert_overproduction(second, 100)
first.cancel()
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_process_loss_and_overproduction_allowance(self):
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 10)
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.qty = 50
first.submit()
self.manufacture_with_loss(first)
second = self.create_work_order(plan, field)
self.assertEqual(second.qty, 55)
self.assert_overproduction(second, 66)
second.qty = 65
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_stopped_and_closed_orders_consume_quantity(self):
plan = self.make_plan()
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.submit()
stop_unstop(first.name, "Stopped")
self.assert_pending_qty(plan, field, 0)
stop_unstop(first.name, "Not Started")
close_work_order(first.name, "Closed")
self.assert_pending_qty(plan, field, 0)
def test_fractional_quantities(self):
plan = self.make_plan(qty=0.3)
for field in REFERENCE_FIELDS:
with self.subTest(field=field):
first = self.create_work_order(plan, field)
first.qty = 0.1
first.submit()
second = self.create_work_order(plan, field)
self.assertEqual(second.qty, 0.2)
second.submit()
self.assert_pending_qty(plan, field, 0)
def test_rows_with_same_item_are_independent(self):
plan = self.make_plan(submit=False)
sales_order = make_sales_order(item_code=self.finished_good, qty=200, warehouse=self.warehouse)
plan.po_items[0].sales_order = sales_order.name
plan.po_items[0].sales_order_item = sales_order.items[0].name
row = plan.po_items[0].as_dict()
row.pop("name")
row["planned_qty"] = 50
plan.append("po_items", row)
plan.submit()
first = self.create_work_order(plan, "production_plan_item")
first.submit()
plan.onload()
pending = plan.get_onload()["pending_work_order_qty"]["production_plan_item"]
self.assertEqual(pending[plan.po_items[0].name], 0)
self.assertEqual(pending[plan.po_items[1].name], 50)
second_name = frappe.db.get_value(
"Work Order", {"production_plan_item": plan.po_items[1].name, "docstatus": 0}, "name"
)
second = frappe.get_doc("Work Order", second_name)
self.assertEqual(second.qty, 50)
self.assert_overproduction(second, 51)
def test_material_request_plan_uses_remaining_quantity(self):
plan = self.make_plan(submit=False)
plan.get_items_from = "Material Request"
plan.submit()
first = self.create_work_order(plan, "production_plan_item")
first.qty = 50
first.submit()
second = self.create_work_order(plan, "production_plan_item")
self.assertEqual(second.qty, 50)
def test_reference_must_belong_to_plan(self):
plan = self.make_plan()
other_plan = self.make_plan()
work_order = self.create_work_order(plan, "production_plan_item")
work_order.production_plan = other_plan.name
with self.assertRaisesRegex(frappe.ValidationError, "must reference a row"):
work_order.submit()
def test_missing_or_ambiguous_plan_reference(self):
plan = self.make_plan()
work_order = self.create_work_order(plan, "production_plan_item")
work_order.production_plan_sub_assembly_item = plan.sub_assembly_items[0].name
with self.assertRaisesRegex(frappe.ValidationError, "only one Production Plan row"):
work_order.submit()
work_order.reload()
work_order.production_plan_item = None
with self.assertRaisesRegex(frappe.ValidationError, "must reference a row"):
work_order.submit()
def make_plan(self, qty=100, submit=True):
plan = create_production_plan(
item_code=self.finished_good,
planned_qty=qty,
stock_uom="Kg",
warehouse=self.warehouse,
sub_assembly_warehouse=self.warehouse,
skip_getting_mr_items=True,
do_not_submit=True,
)
plan.get_sub_assembly_items()
if submit:
plan.submit()
return plan
def make_bom_with_operation(self, item, material):
bom = make_bom(item=item, raw_materials=[material], with_operations=1, do_not_save=True)
bom.track_semi_finished_goods = 1
bom.items[0].operation_row_id = 1
operation = {
"operation": f"_Test Loss Reversal {item}",
"workstation": "_Test Workstation A",
"finished_good": item,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": self.warehouse,
"fg_warehouse": self.warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
bom.append("operations", operation)
bom.insert().submit()
def create_work_order(self, plan, field):
plan.make_work_order()
name = frappe.db.get_value(
"Work Order",
{"production_plan": plan.name, field: self.plan_row(plan, field).name, "docstatus": 0},
"name",
order_by="creation desc",
)
work_order = frappe.get_doc("Work Order", name)
work_order.update(
{"skip_transfer": 1, "source_warehouse": self.warehouse, "fg_warehouse": self.warehouse}
)
return work_order
def copy_work_order(self, work_order):
copy = frappe.copy_doc(work_order)
copy.docstatus = 0
copy.production_plan = work_order.production_plan
for field in REFERENCE_FIELDS:
copy.set(field, work_order.get(field))
copy.insert()
return copy
def manufacture_with_loss(self, work_order, qty=None, *, loss_qty=None, submit=True):
for item in work_order.required_items:
make_stock_entry(
item_code=item.item_code, target=self.warehouse, qty=item.required_qty, basic_rate=10
)
entry = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", qty or work_order.qty))
if loss_qty is not None:
entry.process_loss_qty = loss_qty
entry.process_loss_percentage = loss_qty / entry.fg_completed_qty * 100
for item in entry.items:
if item.is_finished_item:
item.qty = entry.fg_completed_qty - loss_qty
if submit:
entry.submit()
else:
entry.save()
return entry
def assert_manufacture_rejected(self, entry, exception):
frappe.db.savepoint("excess_manufacture")
try:
with self.assertRaises(exception):
entry.submit()
finally:
frappe.db.rollback(save_point="excess_manufacture")
self.assertEqual(entry.reload().docstatus, 0)
def assert_loss_reversal_blocked(self, document):
work_order = frappe.get_doc("Work Order", document.work_order)
frappe.db.savepoint("loss_reversal")
try:
with (
self.assert_plan_locked_before_work_order_update(work_order),
self.assertRaises(OverProductionError),
):
document.cancel()
finally:
# Match the request rollback after an on_cancel validation fails.
frappe.db.rollback(save_point="loss_reversal")
self.assertEqual(document.reload().docstatus, 1)
@contextmanager
def assert_plan_locked_before_work_order_update(self, work_order):
get_value, set_value = frappe.db.get_value, frappe.db.set_value
plan_row_locked = False
row_doctype = (
"Production Plan Sub Assembly Item"
if work_order.production_plan_sub_assembly_item
else "Production Plan Item"
)
row_name = work_order.production_plan_sub_assembly_item or work_order.production_plan_item
def get_value_with_lock_check(doctype, filters=None, *args, **kwargs):
nonlocal plan_row_locked
if doctype == "Work Order" and filters == work_order.name and kwargs.get("for_update"):
self.assertTrue(plan_row_locked, "Work Order locked before its Production Plan row")
result = get_value(doctype, filters, *args, **kwargs)
if (
doctype == row_doctype
and filters == {"name": row_name, "parent": work_order.production_plan}
and kwargs.get("for_update")
):
plan_row_locked = True
return result
def set_value_with_lock_check(doctype, name, *args, **kwargs):
if doctype == "Work Order" and name == work_order.name:
self.assertTrue(plan_row_locked, "Work Order updated before locking its Production Plan row")
return set_value(doctype, name, *args, **kwargs)
with (
patch.object(frappe.db, "get_value", get_value_with_lock_check),
patch.object(frappe.db, "set_value", set_value_with_lock_check),
):
yield
def assert_overproduction(self, work_order, qty):
work_order.qty = qty
work_order.save()
with self.assertRaises(OverProductionError):
work_order.submit()
work_order.reload()
def assert_pending_qty(self, plan, field, expected):
plan.reload()
plan.onload()
self.assertEqual(
plan.get_onload()["pending_work_order_qty"][field][self.plan_row(plan, field).name], expected
)
def plan_row(self, plan, field):
return plan.po_items[0] if field == "production_plan_item" else plan.sub_assembly_items[0]

View File

@@ -0,0 +1,143 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import defaultdict
import frappe
from frappe import _
from frappe.utils import flt, get_link_to_form
class ProductionPlanWorkOrderQuantities:
"""Count submitted Work Orders after recorded process loss, independently for each plan row."""
def __init__(self, production_plan):
self.production_plan = production_plan
def validate_work_order(self, work_order, *, process_loss_qty=0):
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
row = self.lock_plan_row(work_order)
committed = self.get_committed_quantities(
exclude_work_order=work_order.name,
reference_field=row.reference_field,
reference_name=row.name,
for_update=True,
)[row.reference_field].get(row.name, 0)
allowance = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
)
precision = work_order.precision("qty")
maximum_qty = flt(flt(row.planned_qty) * (1 + allowance / 100) - committed, precision)
committed_qty = flt(self._get_committed_qty(work_order, process_loss_qty), precision)
if committed_qty > maximum_qty:
frappe.throw(
_(
"Row {0} in {1} {2}: Work Order quantity after process loss {3} exceeds the remaining allowed quantity {4}."
).format(
row.idx,
_(row.doctype),
get_link_to_form("Production Plan", self.production_plan),
committed_qty,
max(0, maximum_qty),
),
OverProductionError,
title=_("Production Plan Quantity Exceeded"),
)
def lock_plan_row(self, work_order):
if work_order.production_plan_item and work_order.production_plan_sub_assembly_item:
frappe.throw(_("Work Order must reference only one Production Plan row."))
if work_order.production_plan_sub_assembly_item:
reference_field = "production_plan_sub_assembly_item"
row_doctype, qty_field = "Production Plan Sub Assembly Item", "qty"
else:
reference_field = "production_plan_item"
row_doctype, qty_field = "Production Plan Item", "planned_qty"
reference_name = work_order.get(reference_field)
# Serialize submissions and loss reversals. The submit rollup updates this row.
row = (
frappe.db.get_value(
row_doctype,
{"name": reference_name, "parent": self.production_plan},
["name", "idx", f"{qty_field} as planned_qty"],
as_dict=True,
for_update=True,
)
if reference_name
else None
)
if not row:
frappe.throw(
_("Work Order must reference a row in Production Plan {0}.").format(
get_link_to_form("Production Plan", self.production_plan)
)
)
row.reference_field = reference_field
row.doctype = row_doctype
return row
def get_pending_quantities(self, plan):
committed = self.get_committed_quantities()
precision = frappe.get_precision("Work Order", "qty")
pending = {}
for table, reference_field, qty_field in (
("po_items", "production_plan_item", "planned_qty"),
("sub_assembly_items", "production_plan_sub_assembly_item", "qty"),
):
pending[reference_field] = {
row.name: max(
0, flt(flt(row.get(qty_field)) - committed[reference_field].get(row.name, 0), precision)
)
for row in plan.get(table)
if table == "po_items" or row.type_of_manufacturing == "In House"
}
return pending
def get_committed_quantities(
self, exclude_work_order=None, reference_field=None, reference_name=None, for_update=False
):
table = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(table)
.select(
table.production_plan_item,
table.production_plan_sub_assembly_item,
table.qty,
table.produced_qty,
table.process_loss_qty,
)
.where((table.production_plan == self.production_plan) & (table.docstatus == 1))
.orderby(table.name)
)
if exclude_work_order:
query = query.where(table.name != exclude_work_order)
if reference_field:
query = query.where(table[reference_field] == reference_name)
# Use a current locking read so concurrent submissions see committed quantities.
if for_update:
query = query.for_update()
work_orders = query.run(as_dict=True)
quantities = {
"production_plan_item": defaultdict(float),
"production_plan_sub_assembly_item": defaultdict(float),
}
for work_order in work_orders:
field = (
"production_plan_sub_assembly_item"
if work_order.production_plan_sub_assembly_item
else "production_plan_item"
)
if work_order.get(field):
quantities[field][work_order[field]] += self._get_committed_qty(
work_order, work_order.process_loss_qty
)
return quantities
def _get_committed_qty(self, work_order, process_loss_qty):
# Excess loss in existing records must not erase finished goods already produced.
return max(0, flt(work_order.produced_qty), flt(work_order.qty) - flt(process_loss_qty))

Some files were not shown because too many files have changed in this diff Show More