mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
Merge pull request #58868 from frappe/version-16-hotfix
chore: release v16
This commit is contained in:
30
.github/helper/install.sh
vendored
30
.github/helper/install.sh
vendored
@@ -4,6 +4,36 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
|
||||
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
|
||||
# below, or payments under `bench get-app`. See the PR description.
|
||||
#
|
||||
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
|
||||
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
|
||||
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
|
||||
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
|
||||
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
|
||||
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
|
||||
if [ -n "$ci_github_token" ]; then
|
||||
export CI_GITHUB_TOKEN="$ci_github_token"
|
||||
export GIT_CONFIG_COUNT=3
|
||||
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
|
||||
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
|
||||
# token to ~/.git-credentials. An empty value clears the list before ours is added.
|
||||
export GIT_CONFIG_KEY_0="credential.helper"
|
||||
export GIT_CONFIG_VALUE_0=""
|
||||
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
|
||||
export GIT_CONFIG_VALUE_1="x-access-token"
|
||||
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
|
||||
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
|
||||
# token is read from the environment at call time and never stored anywhere. Answering only
|
||||
# `get` makes the helper inert for git's `store`/`erase` calls.
|
||||
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
|
||||
fi
|
||||
|
||||
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -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: |
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,6 +129,8 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/server-tests-mariadb.yml
vendored
2
.github/workflows/server-tests-mariadb.yml
vendored
@@ -102,6 +102,8 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
|
||||
@@ -9,6 +9,7 @@ import Fuse from "fuse.js"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import { FormControl } from "../ui/form"
|
||||
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch"
|
||||
|
||||
|
||||
export interface AccountsDropdownProps {
|
||||
@@ -104,6 +105,10 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Searching replaces the grouped list with a short result list, so pin the scroll back to
|
||||
// the top - otherwise the auto-selected first result can be out of view.
|
||||
const listRef = useResetScrollOnSearch(search)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -153,7 +158,7 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
|
||||
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
|
||||
<CommandList>
|
||||
<CommandList ref={listRef}>
|
||||
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
|
||||
|
||||
{recommendedAccounts.length > 0 && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ChevronDownIcon, ExternalLink } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
|
||||
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch";
|
||||
import _ from "@/lib/translate";
|
||||
import ErrorBanner from "../ui/error-banner";
|
||||
import MarkdownRenderer from "../ui/markdown";
|
||||
@@ -149,6 +150,10 @@ const LinkFieldCombobox = ({
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Results change as the search runs, so pin the scroll back to the top to keep the
|
||||
// auto-selected first result in view.
|
||||
const listRef = useResetScrollOnSearch(searchInput)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -264,7 +269,7 @@ const LinkFieldCombobox = ({
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
|
||||
<CommandList>
|
||||
<CommandList ref={listRef}>
|
||||
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items?.map((result) => (
|
||||
@@ -272,7 +277,7 @@ const LinkFieldCombobox = ({
|
||||
<span className="font-medium">
|
||||
{result.label || result.value}
|
||||
</span>
|
||||
{result.description && <span className="text-xs text-ink-gray-5">
|
||||
{result.description && <span className="text-p-xs text-ink-gray-5">
|
||||
<MarkdownRenderer content={result.description} />
|
||||
</span>}
|
||||
</CommandItem>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Progress } from "@/components/ui/progress"
|
||||
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
||||
import { Edit, Info, Trash2 } from "lucide-react"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import _ from "@/lib/translate"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Form } from "@/components/ui/form"
|
||||
@@ -26,50 +26,109 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const BankBalance = () => {
|
||||
const useBankCurrency = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
return bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of the balance summary - label on the left, figure right-aligned.
|
||||
*
|
||||
* `items-baseline` keeps the figure on the label's FIRST line, so a row carrying a `subLabel`
|
||||
* (the statement row's "As of <date>" note) doesn't centre its value against both lines.
|
||||
*/
|
||||
const BalanceRow = ({ label, info, subLabel, emphasis, children }: {
|
||||
label: React.ReactNode
|
||||
info?: React.ReactNode
|
||||
subLabel?: React.ReactNode
|
||||
emphasis?: boolean
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className={cn("flex items-center gap-1 whitespace-nowrap text-xs text-ink-gray-6",
|
||||
emphasis && "font-medium text-ink-gray-7")}>
|
||||
{label}
|
||||
{info}
|
||||
</span>
|
||||
{subLabel}
|
||||
</span>
|
||||
<div className="flex flex-col items-end">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Type styles for a figure. Shared so an interactive figure can put them on the <button>
|
||||
* ITSELF rather than on a nested span: Tailwind's preflight sets `font: inherit` on buttons,
|
||||
* which resets line-height too, so a button wrapping a `text-sm` span gets a taller strut than
|
||||
* the span and the row grows - visible as extra space above a baseline-aligned row.
|
||||
*/
|
||||
const BALANCE_VALUE_CLASSES = "font-numeric text-sm tabular-nums text-ink-gray-8"
|
||||
|
||||
const BalanceValue = ({ children, emphasis, tone, className }: { children: React.ReactNode, emphasis?: boolean, tone?: 'red', className?: string }) => (
|
||||
<span className={cn(BALANCE_VALUE_CLASSES,
|
||||
emphasis && "font-semibold",
|
||||
tone === 'red' && "text-ink-red-3",
|
||||
className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
const BalanceSkeleton = () => <Skeleton className="h-4 w-24 rounded-sm" />
|
||||
|
||||
/**
|
||||
* Balances and progress for the selected bank account, laid out like the totals block of an
|
||||
* invoice. This sits beside the bank picker rather than in a row of its own (saves vertical
|
||||
* space) and outside the picker's horizontal scroll area, so the figures being reconciled
|
||||
* against can never scroll out of view.
|
||||
*/
|
||||
const BankAccountBalancePanel = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
|
||||
<OpeningBalance />
|
||||
<ClosingBalance />
|
||||
<ClosingBalanceAsPerStatement />
|
||||
<Difference />
|
||||
</div>
|
||||
|
||||
<ReconcileProgress />
|
||||
return (
|
||||
<div className="flex w-72 shrink-0 flex-col justify-center gap-2.5 border-s border-outline-gray-2 ps-4">
|
||||
{/* Names the account these figures belong to - the picker scrolls, so the
|
||||
highlighted card can't be relied on as the referent. */}
|
||||
<span
|
||||
className="truncate text-xs font-medium text-ink-gray-7"
|
||||
title={bankAccount.account_name}>
|
||||
{bankAccount.account_name}
|
||||
</span>
|
||||
<OpeningBalanceRow />
|
||||
<SystemClosingBalanceRow />
|
||||
<StatementClosingBalanceRow />
|
||||
<Separator />
|
||||
<DifferenceRow />
|
||||
<ReconciledRow />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OpeningBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const OpeningBalanceRow = () => {
|
||||
const currency = useBankCurrency()
|
||||
const { data, isLoading } = useGetAccountOpeningBalance()
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Opening Balance")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
return <BalanceRow label={_("Opening Balance")}>
|
||||
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
}
|
||||
|
||||
const ClosingBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const SystemClosingBalanceRow = () => {
|
||||
const currency = useBankCurrency()
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
return (
|
||||
<StatContainer className="min-w-48">
|
||||
<div className="flex items-start gap-1">
|
||||
<StatLabel>
|
||||
{_("Closing Balance as per system")}
|
||||
</StatLabel>
|
||||
<BalanceRow
|
||||
label={_("Closing (system)")}
|
||||
info={
|
||||
<HoverCard openDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
|
||||
<Info className="size-3.5 text-ink-gray-6" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96" align="start" side="right">
|
||||
<H4 className="text-base">{_("Closing balance as per system")}</H4>
|
||||
@@ -84,15 +143,111 @@ const ClosingBalance = () => {
|
||||
</Paragraph>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
>
|
||||
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
)
|
||||
}
|
||||
|
||||
const Difference = () => {
|
||||
const StatementClosingBalanceRow = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const currency = useBankCurrency()
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
||||
onSuccess: (data) => {
|
||||
if (data?.message && data?.message?.balance) {
|
||||
setValue({
|
||||
value: data?.message?.balance,
|
||||
stringValue: data?.message?.balance.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isDateSame = data?.message?.date === dates.toDate
|
||||
|
||||
// The server uses the returned date to distinguish an unset balance from a saved zero.
|
||||
const hasBalance = Boolean(data?.message?.date)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const tooltip = hasBalance
|
||||
? _("Click to change the closing balance as per statement")
|
||||
: _("Click to set the closing balance as per statement")
|
||||
|
||||
return (
|
||||
<BalanceRow
|
||||
label={_("Closing (statement)")}
|
||||
// The pencil sits beside the label, mirroring the info icon on the row above, so
|
||||
// the figure stays a plain right-aligned number in line with every other row.
|
||||
info={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* `p-0`: Tailwind's preflight gives buttons `appearance: button` but
|
||||
doesn't reset padding, so a bare button picks up the UA's ~1px 6px
|
||||
and knocks this row out of step with its neighbours. */}
|
||||
<button
|
||||
type='button'
|
||||
aria-label={tooltip}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer p-0 text-ink-gray-5 transition-colors hover:text-ink-gray-7">
|
||||
<Edit className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
subLabel={!isDateSame && data?.message.date
|
||||
? <span className="whitespace-nowrap text-2xs font-medium text-ink-red-3">
|
||||
{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}
|
||||
</span>
|
||||
: undefined}
|
||||
>
|
||||
{/* Deliberately NOT a flex container: a flex box's baseline doesn't resolve to its
|
||||
text, so the row's `items-baseline` couldn't line this up with the label. As a
|
||||
plain inline button its baseline is the figure's own, like every other row.
|
||||
"Set" gets the same treatment as a figure - it stands in for one. */}
|
||||
{isLoading
|
||||
? <BalanceSkeleton />
|
||||
: <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* The figure styles live on the button itself - see
|
||||
BALANCE_VALUE_CLASSES. `p-0` because preflight leaves the UA's
|
||||
button padding in place. */}
|
||||
<button
|
||||
type='button'
|
||||
aria-label={tooltip}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(BALANCE_VALUE_CLASSES,
|
||||
"cursor-pointer p-0 underline decoration-outline-gray-5 decoration-dashed underline-offset-4",
|
||||
"transition-colors hover:decoration-ink-gray-8")}>
|
||||
{hasBalance ? formatCurrency(flt(data?.message?.balance, 2), currency) : _("Set")}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>}
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<ClosingBalanceForm
|
||||
defaultBalance={data?.message?.balance ?? 0}
|
||||
date={dates.toDate}
|
||||
bankAccount={bankAccount}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</BalanceRow>
|
||||
)
|
||||
}
|
||||
|
||||
const DifferenceRow = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const currency = useBankCurrency()
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
@@ -102,16 +257,15 @@ const Difference = () => {
|
||||
|
||||
const isError = difference !== 0
|
||||
|
||||
return <StatContainer className="w-fit text-end sm:min-w-56">
|
||||
<StatLabel className="text-end">{_("Difference")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
|
||||
{formatCurrency(difference,
|
||||
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
|
||||
}</StatValue>}
|
||||
</StatContainer>
|
||||
return <BalanceRow label={_("Difference")} emphasis>
|
||||
{isLoading
|
||||
? <BalanceSkeleton />
|
||||
: <BalanceValue emphasis tone={isError ? 'red' : undefined}>{formatCurrency(difference, currency)}</BalanceValue>}
|
||||
</BalanceRow>
|
||||
}
|
||||
|
||||
const ReconcileProgress = () => {
|
||||
/** Reconciliation progress through the selected date range: a count plus a slim bar. */
|
||||
const ReconciledRow = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
@@ -132,75 +286,14 @@ const ReconcileProgress = () => {
|
||||
|
||||
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
|
||||
|
||||
return <div className="w-[18%] flex flex-col gap-1 items-end">
|
||||
<div className="w-full">
|
||||
<Progress
|
||||
value={progress}
|
||||
max={100}
|
||||
size="md"
|
||||
label="Progress"
|
||||
hint
|
||||
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
|
||||
</div>
|
||||
return <div className="flex flex-col gap-1.5">
|
||||
<BalanceRow label={_("Reconciled")}>
|
||||
<BalanceValue>{reconciledCount} / {totalCount ?? 0}</BalanceValue>
|
||||
</BalanceRow>
|
||||
<Progress value={progress} max={100} size="sm" />
|
||||
</div>
|
||||
}
|
||||
|
||||
const ClosingBalanceAsPerStatement = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
||||
onSuccess: (data) => {
|
||||
if (data?.message && data?.message?.balance) {
|
||||
setValue({
|
||||
value: data?.message?.balance,
|
||||
stringValue: data?.message?.balance.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isDateSame = data?.message?.date === dates.toDate
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
<Edit className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Click to set the closing balance as per statement")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-xl">
|
||||
<ClosingBalanceForm
|
||||
defaultBalance={data?.message?.balance ?? 0}
|
||||
date={dates.toDate}
|
||||
bankAccount={bankAccount}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
|
||||
</div>
|
||||
</StatContainer>
|
||||
|
||||
}
|
||||
|
||||
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
@@ -302,7 +395,7 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
|
||||
|
||||
return <div>
|
||||
<Separator className="my-8" />
|
||||
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<p className="text-p-sm text-center pb-2">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -331,4 +424,4 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
|
||||
|
||||
}
|
||||
|
||||
export default BankBalance
|
||||
export default BankAccountBalancePanel
|
||||
|
||||
@@ -205,9 +205,9 @@ const BankClearanceSummaryView = () => {
|
||||
|
||||
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -220,8 +220,9 @@ const BankClearanceSummaryView = () => {
|
||||
data={data.message.result}
|
||||
columns={clearanceColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -74,7 +74,10 @@ const BankPicker = ({ className }: { className?: string }) => {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
|
||||
// No trailing padding: it would sit inside the fade region, so the mask would
|
||||
// spend itself on empty space and the last card would stop short of the balance
|
||||
// panel instead of fading towards it. The column gap provides the separation.
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto scroll-fade-x",
|
||||
banks?.length > 4 ? 'pb-2' : '', className,
|
||||
)}
|
||||
style={{
|
||||
@@ -108,12 +111,12 @@ const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
|
||||
role="button"
|
||||
title={`Select ${bank.account_name}`}
|
||||
onClick={onSelect}
|
||||
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
|
||||
// `shrink-0`: this is a horizontally scrolling row, so cards keep their own width
|
||||
// instead of being compressed to fit the container.
|
||||
className={cn('w-60 shrink-0 rounded-md border border-outline-gray-1 p-2 overflow-hidden cursor-pointer transition-colors',
|
||||
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
|
||||
)}
|
||||
>
|
||||
|
||||
|
||||
<BankLogo bank={bank} className="mb-2" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -5,107 +5,179 @@ import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod }
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
|
||||
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { parse } from "chrono-node"
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import useFiscalYear from '@/hooks/useFiscalYear'
|
||||
import dayjs from 'dayjs'
|
||||
import _ from '@/lib/translate'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import useResetScrollOnSearch from '@/hooks/useResetScrollOnSearch'
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
/** Current fiscal year plus this many previous ones, for quarter/year options. */
|
||||
const PREVIOUS_FISCAL_YEARS = 2
|
||||
|
||||
type DateOption = {
|
||||
/** Stable id - used as the cmdk value and the React key. */
|
||||
key: string
|
||||
label: string
|
||||
translatedLabel: string
|
||||
fromDate: string
|
||||
toDate: string
|
||||
format: string
|
||||
/** Extra terms to match against, beyond the labels and dates. */
|
||||
keywords?: string[]
|
||||
/** Whether to show this option when the search box is empty. */
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiscal years keep the same month/day boundaries year on year, so previous years can be
|
||||
* derived by subtracting whole years instead of fetching them. Works for both Jan-Dec and
|
||||
* Apr-Mar style fiscal years.
|
||||
*/
|
||||
const fiscalYearLabel = (start: dayjs.Dayjs, end: dayjs.Dayjs) =>
|
||||
start.year() === end.year() ? `${start.year()}` : `${start.year()}-${end.year()}`
|
||||
|
||||
const BankRecDateFilter = () => {
|
||||
|
||||
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
|
||||
|
||||
const { data: fiscalYear } = useFiscalYear()
|
||||
const { fiscalYear } = useFiscalYear()
|
||||
|
||||
const timePeriodOptions = useMemo(() => {
|
||||
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const today = useMemo(() => dayjs().format(DATE_FORMAT), [])
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
const standardOptions: DateOption[] = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const dates = getDatesForTimePeriod(period)
|
||||
return {
|
||||
key: period,
|
||||
label: period,
|
||||
translatedLabel: dates.translatedLabel ?? _(period),
|
||||
fromDate: dates.fromDate,
|
||||
toDate: dates.toDate,
|
||||
format: dates.format,
|
||||
translatedLabel: dates.translatedLabel
|
||||
isDefault: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (fiscalYear?.message) {
|
||||
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters
|
||||
const fiscalYearStart = fiscalYear.message.year_start_date
|
||||
const fiscalYearEnd = fiscalYear.message.year_end_date
|
||||
|
||||
const q1 = {
|
||||
label: `Q1: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q2 = {
|
||||
label: `Q2: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q3 = {
|
||||
label: `Q3: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q4 = {
|
||||
label: `Q4: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const thisYear = {
|
||||
label: `This Fiscal Year`,
|
||||
translatedLabel: `${_("This Fiscal Year")}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const lastYear = {
|
||||
label: `Last Fiscal Year`,
|
||||
translatedLabel: `${_("Last Fiscal Year")}`,
|
||||
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
|
||||
|
||||
const topRankedItems = standardOptions.filter((option) => {
|
||||
return option.label === "This Month" || option.label === "Last Month"
|
||||
})
|
||||
|
||||
const bottomRankedItems = standardOptions.filter((option) => {
|
||||
return option.label !== "This Month" && option.label !== "Last Month"
|
||||
})
|
||||
|
||||
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
|
||||
if (!fiscalYear) {
|
||||
return standardOptions
|
||||
}
|
||||
|
||||
return standardOptions
|
||||
const currentStart = dayjs(fiscalYear.year_start_date)
|
||||
const currentEnd = dayjs(fiscalYear.year_end_date)
|
||||
|
||||
const quarterOptions: DateOption[] = []
|
||||
const fiscalYearOptions: DateOption[] = []
|
||||
|
||||
// Static literals so the translation extractor can find them.
|
||||
const quarterLabels = [_("Q1"), _("Q2"), _("Q3"), _("Q4")]
|
||||
|
||||
for (let yearsAgo = 0; yearsAgo <= PREVIOUS_FISCAL_YEARS; yearsAgo++) {
|
||||
const start = currentStart.subtract(yearsAgo, 'year')
|
||||
const end = currentEnd.subtract(yearsAgo, 'year')
|
||||
// Keep the real name for the current year; derive it for the earlier ones.
|
||||
const yearLabel = yearsAgo === 0 ? fiscalYear.name : fiscalYearLabel(start, end)
|
||||
|
||||
for (let quarter = 0; quarter < 4; quarter++) {
|
||||
const quarterStart = start.add(quarter * 3, 'month')
|
||||
// End the day before the next quarter starts, clamped to the fiscal year end
|
||||
// so a short fiscal year can't spill over.
|
||||
const nextQuarterStart = start.add((quarter + 1) * 3, 'month')
|
||||
const quarterEnd = nextQuarterStart.subtract(1, 'day').isAfter(end)
|
||||
? end
|
||||
: nextQuarterStart.subtract(1, 'day')
|
||||
|
||||
if (quarterStart.isAfter(end)) continue
|
||||
|
||||
quarterOptions.push({
|
||||
key: `Q${quarter + 1}-${yearLabel}`,
|
||||
label: `Q${quarter + 1}: ${yearLabel}`,
|
||||
translatedLabel: `${quarterLabels[quarter]}: ${yearLabel}`,
|
||||
fromDate: quarterStart.format(DATE_FORMAT),
|
||||
toDate: quarterEnd.format(DATE_FORMAT),
|
||||
format: 'MMM YYYY',
|
||||
keywords: ['quarter', `q${quarter + 1}`, yearLabel],
|
||||
// Only the current fiscal year's quarters clutter the default list;
|
||||
// older ones stay searchable.
|
||||
isDefault: yearsAgo === 0,
|
||||
})
|
||||
}
|
||||
|
||||
const label = yearsAgo === 0
|
||||
? 'This Fiscal Year'
|
||||
: yearsAgo === 1
|
||||
? 'Last Fiscal Year'
|
||||
: `FY ${yearLabel}`
|
||||
|
||||
fiscalYearOptions.push({
|
||||
key: `fiscal-year-${yearLabel}`,
|
||||
label,
|
||||
translatedLabel: yearsAgo <= 1 ? _(label) : `${_("FY")} ${yearLabel}`,
|
||||
fromDate: start.format(DATE_FORMAT),
|
||||
toDate: end.format(DATE_FORMAT),
|
||||
format: 'MMM YYYY',
|
||||
keywords: ['fiscal year', yearLabel],
|
||||
isDefault: yearsAgo <= 1,
|
||||
})
|
||||
}
|
||||
|
||||
// "This Month"/"Last Month" first, then quarters and fiscal years, then the rest.
|
||||
const topRanked = standardOptions.filter((o) => o.label === 'This Month' || o.label === 'Last Month')
|
||||
const bottomRanked = standardOptions.filter((o) => o.label !== 'This Month' && o.label !== 'Last Month')
|
||||
|
||||
return [...topRanked, ...quarterOptions, ...fiscalYearOptions, ...bottomRanked]
|
||||
}, [fiscalYear])
|
||||
|
||||
// Reconciliation only looks backwards, so a period that hasn't started is never useful.
|
||||
const selectableOptions = useMemo(
|
||||
() => allOptions.filter((option) => option.fromDate <= today),
|
||||
[allOptions, today],
|
||||
)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
// We filter ourselves (`shouldFilter={false}`) so that the parsed-date suggestion can be a
|
||||
// real CommandItem alongside the predefined options, and keyboard navigation covers both.
|
||||
const filteredOptions = useMemo(() => {
|
||||
const query = value.trim().toLowerCase()
|
||||
|
||||
if (!query) {
|
||||
return selectableOptions.filter((option) => option.isDefault)
|
||||
}
|
||||
|
||||
const tokens = query.split(/\s+/)
|
||||
|
||||
return selectableOptions.filter((option) => {
|
||||
const haystack = [
|
||||
option.label,
|
||||
option.translatedLabel,
|
||||
...(option.keywords ?? []),
|
||||
option.fromDate,
|
||||
option.toDate,
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return tokens.every((token) => haystack.includes(token))
|
||||
})
|
||||
}, [selectableOptions, value])
|
||||
|
||||
const parsedOption = useMemo(() => parseDateRange(value), [value])
|
||||
|
||||
// Filtering shortens the list, so pin the scroll back to the top to keep the
|
||||
// auto-selected first option in view.
|
||||
const listRef = useResetScrollOnSearch(value)
|
||||
|
||||
// Don't show a parsed suggestion that duplicates an option already in the list.
|
||||
const showParsedOption = parsedOption
|
||||
&& !filteredOptions.some((o) => o.fromDate === parsedOption.fromDate && o.toDate === parsedOption.toDate)
|
||||
|
||||
const timePeriod: TimePeriod | string = useMemo(() => {
|
||||
if (bankRecDate.fromDate && bankRecDate.toDate) {
|
||||
// Check if the from and to dates match any predefined time period
|
||||
for (const period of timePeriodOptions) {
|
||||
for (const period of allOptions) {
|
||||
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
|
||||
return period.label;
|
||||
}
|
||||
@@ -114,10 +186,11 @@ const BankRecDateFilter = () => {
|
||||
} else {
|
||||
return "Date Range";
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, allOptions]);
|
||||
|
||||
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
|
||||
setBankRecDate({ fromDate, toDate })
|
||||
setValue("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -130,7 +203,9 @@ const BankRecDateFilter = () => {
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
|
||||
const RangeArrow = direction === 'ltr'
|
||||
? <ChevronRight className='text-[12px] text-ink-gray-5/70' />
|
||||
: <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />
|
||||
|
||||
return <div className='flex items-center'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -141,30 +216,57 @@ const BankRecDateFilter = () => {
|
||||
size='md'
|
||||
className='rounded-e-none border-e-0'
|
||||
role="combobox">
|
||||
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
{allOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-84 p-1" align='start'>
|
||||
<Command>
|
||||
<Command shouldFilter={false}>
|
||||
|
||||
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} />
|
||||
<CommandList className='max-h-fit'>
|
||||
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'>
|
||||
<EmptyState onSelect={handleTimePeriodChange} value={value} />
|
||||
</CommandEmpty>
|
||||
{timePeriodOptions.map((period) => (
|
||||
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
|
||||
<span>
|
||||
{period.translatedLabel ?? _(period.label)}
|
||||
</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
<CommandInput placeholder={_("e.g. Last 3 weeks, Q1, May 2025")} onValueChange={setValue} value={value} />
|
||||
<CommandList ref={listRef} className='max-h-80'>
|
||||
{showParsedOption && parsedOption && (
|
||||
<CommandGroup heading={_("Matched date")}>
|
||||
<CommandItem
|
||||
value='parsed-date-range'
|
||||
className='flex justify-between'
|
||||
onSelect={() => handleTimePeriodChange(parsedOption.fromDate, parsedOption.toDate)}>
|
||||
<span className='max-w-[45%] truncate'>{value}</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{parsedOption.fromDate === parsedOption.toDate
|
||||
? formatDate(parsedOption.fromDate, 'Do MMM YYYY')
|
||||
: <>{formatDate(parsedOption.fromDate, 'Do MMM YY')} {RangeArrow} {formatDate(parsedOption.toDate, 'Do MMM YY')}</>}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredOptions.length > 0 && (
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((period) => (
|
||||
<CommandItem
|
||||
key={period.key}
|
||||
value={period.key}
|
||||
className='flex justify-between'
|
||||
onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
|
||||
<span>
|
||||
{period.translatedLabel}
|
||||
</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{formatDate(period.fromDate, period.format)} {RangeArrow} {formatDate(period.toDate, period.format)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!showParsedOption && filteredOptions.length === 0 && (
|
||||
<div className='p-2 text-sm text-ink-gray-5'>
|
||||
{_("No results found")}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -199,77 +301,97 @@ const BankRecDateFilter = () => {
|
||||
}
|
||||
|
||||
const referentialKeywords = ["last", "this", "next", "previous"]
|
||||
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
|
||||
|
||||
const dates = useMemo(() => {
|
||||
if (value) {
|
||||
// Try parsing the value
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
/** chrono exposes `knownValues` on ParsingComponents but doesn't type it publicly. */
|
||||
const knownValuesOf = (components: unknown): Record<string, number> =>
|
||||
(components as { knownValues?: Record<string, number> })?.knownValues ?? {}
|
||||
|
||||
if (parsedDate && parsedDate.length > 0) {
|
||||
const startDate = parsedDate[0].start.date()
|
||||
const endDate = parsedDate[0].end?.date()
|
||||
/**
|
||||
* How far back a parsed date must move to land in the past. Reconciliation only ever looks
|
||||
* backwards, so an ambiguous input that chrono resolves into the future - "December" typed in
|
||||
* September, or a bare weekday like "Friday" - is pulled to its most recent past occurrence.
|
||||
* An explicitly stated year is respected; a range that is still future gets discarded later.
|
||||
*
|
||||
* This returns a shift rather than a date so that a range can be moved as a single unit -
|
||||
* shifting its start and end independently would distort or invert it.
|
||||
*/
|
||||
const pastShift = (date: Date, knownValues: Record<string, number>) => {
|
||||
const today = dayjs()
|
||||
let candidate = dayjs(date)
|
||||
|
||||
if (!endDate) {
|
||||
const today = new Date()
|
||||
// If today is greater than the start date, use today as the end date
|
||||
if (startDate.getTime() > today.getTime()) {
|
||||
return { fromDate: today, toDate: startDate }
|
||||
} else {
|
||||
// Check if the user only wants a specific month like "May 2025"
|
||||
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: dayjs(startDate).endOf('month').toDate()
|
||||
}
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
|
||||
// If month and day is known, then we should not assume that the user wants to get everything until today
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: startDate,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: today
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { fromDate: startDate, toDate: endDate }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const onClick = (fromDate: Date, toDate: Date) => {
|
||||
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
|
||||
if (!candidate.isAfter(today, 'date') || knownValues.year !== undefined) {
|
||||
return { amount: 0, unit: 'year' as const }
|
||||
}
|
||||
|
||||
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
|
||||
// A bare weekday repeats weekly, everything else (month/day) repeats yearly.
|
||||
const unit = knownValues.weekday !== undefined && knownValues.day === undefined
|
||||
? 'day' as const
|
||||
: 'year' as const
|
||||
const step = unit === 'day' ? 7 : 1
|
||||
let amount = 0
|
||||
|
||||
return <div>
|
||||
{dates ?
|
||||
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}>
|
||||
<span className='text-sm text-ink-gray-5 max-w-[30%]'>
|
||||
{value}
|
||||
</span>
|
||||
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YYYY')}
|
||||
</span> :
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
|
||||
</span>}
|
||||
</div> :
|
||||
<span className='text-sm text-ink-gray-5'>
|
||||
No results found
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
for (let i = 0; i < 200 && candidate.isAfter(today, 'date'); i++) {
|
||||
candidate = candidate.subtract(step, unit)
|
||||
amount += step
|
||||
}
|
||||
|
||||
return { amount, unit }
|
||||
}
|
||||
|
||||
export default BankRecDateFilter
|
||||
/**
|
||||
* Parse free text into a past date range, or return undefined when it can't be parsed or
|
||||
* resolves entirely into the future.
|
||||
*/
|
||||
const parseDateRange = (value: string): { fromDate: string, toDate: string } | undefined => {
|
||||
if (!value.trim()) return undefined
|
||||
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
|
||||
if (!parsedDate || parsedDate.length === 0) return undefined
|
||||
|
||||
const result = parsedDate[0]
|
||||
const startKnownValues = knownValuesOf(result.start)
|
||||
|
||||
// Anchor the shift on the start and apply it to both ends, so an explicit range like
|
||||
// "1st Sept to 30th Sept" keeps its shape instead of having only its end rolled back.
|
||||
const shift = pastShift(result.start.date(), startKnownValues)
|
||||
const startDate = dayjs(result.start.date()).subtract(shift.amount, shift.unit).toDate()
|
||||
const endDate = result.end
|
||||
? dayjs(result.end.date()).subtract(shift.amount, shift.unit).toDate()
|
||||
: undefined
|
||||
|
||||
const today = new Date()
|
||||
let range: { fromDate: Date, toDate: Date }
|
||||
|
||||
if (endDate) {
|
||||
const endKnownValues = knownValuesOf(result.end)
|
||||
// chrono ends "Apr 2025 to Jun 2025" on the 1st of June, but the user means all of it.
|
||||
const rangeEnd = endKnownValues.month && !endKnownValues.day
|
||||
? dayjs(endDate).endOf('month').toDate()
|
||||
: endDate
|
||||
range = { fromDate: startDate, toDate: rangeEnd }
|
||||
} else if (startKnownValues.month && !startKnownValues.day) {
|
||||
// The user only wants a specific month like "May 2025" - span the whole month
|
||||
range = { fromDate: dayjs(startDate).startOf('month').toDate(), toDate: dayjs(startDate).endOf('month').toDate() }
|
||||
} else if (startKnownValues.month && startKnownValues.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
|
||||
// If month and day is known, then we should not assume that the user wants to get everything until today
|
||||
range = { fromDate: startDate, toDate: startDate }
|
||||
} else {
|
||||
range = { fromDate: startDate, toDate: today }
|
||||
}
|
||||
|
||||
// A range that hasn't started yet is never useful for reconciliation. A range that merely
|
||||
// ends in the future is kept as typed, the same way "This Month" spans the whole month.
|
||||
if (dayjs(range.fromDate).isAfter(today, 'date')) return undefined
|
||||
|
||||
if (dayjs(range.toDate).isBefore(range.fromDate, 'date')) {
|
||||
range = { fromDate: range.toDate, toDate: range.fromDate }
|
||||
}
|
||||
|
||||
return {
|
||||
fromDate: dayjs(range.fromDate).format(DATE_FORMAT),
|
||||
toDate: dayjs(range.toDate).format(DATE_FORMAT),
|
||||
}
|
||||
}
|
||||
|
||||
export default BankRecDateFilter
|
||||
|
||||
@@ -191,9 +191,9 @@ const BankReconciliationStatementView = () => {
|
||||
|
||||
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -201,16 +201,18 @@ const BankReconciliationStatementView = () => {
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && <SummarySection data={data} />}
|
||||
{data && <div className="shrink-0"><SummarySection data={data} /></div>}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<p className="shrink-0 text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<ListView
|
||||
data={statementRows}
|
||||
columns={statementColumns}
|
||||
getRowId={(row) => row.payment_entry}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
emptyState={_("No entries with a payment document in this list.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -245,9 +245,9 @@ const BankTransactionListView = () => {
|
||||
|
||||
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<div className="flex shrink-0 gap-2 justify-between items-center">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
@@ -278,8 +278,9 @@ const BankTransactionListView = () => {
|
||||
data={filteredResults}
|
||||
columns={transactionColumns}
|
||||
getRowId={(row) => row.name}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
emptyState={<Empty>
|
||||
<EmptyMedia>
|
||||
<ListIcon />
|
||||
|
||||
@@ -181,9 +181,9 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
|
||||
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
@@ -198,13 +198,15 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<p className="shrink-0 text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={incorrectlyClearedColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
className="min-h-0 flex-1"
|
||||
maxHeight="none"
|
||||
scrollAreaClassName="flex-1"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Link } from "react-router"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
||||
|
||||
const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const MatchAndReconcile = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!selectedBank) {
|
||||
@@ -52,15 +52,15 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className={`flex items-start space-x-2`} >
|
||||
<div className="flex-1">
|
||||
<H4 className="text-sm font-medium">{_("Unreconciled Transactions")}</H4>
|
||||
<UnreconciledTransactions contentHeight={contentHeight} />
|
||||
<div className="flex min-h-0 flex-1 items-stretch space-x-2" >
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<H4 className="shrink-0 text-sm font-medium">{_("Unreconciled Transactions")}</H4>
|
||||
<UnreconciledTransactions />
|
||||
</div>
|
||||
<Separator orientation="vertical" style={{ minHeight: `${contentHeight}px` }} />
|
||||
<div className="flex-1 px-1">
|
||||
<H4 className="text-sm font-medium">{_("Match or Create")}</H4>
|
||||
<VouchersSection contentHeight={contentHeight} />
|
||||
<Separator orientation="vertical" className="self-stretch" />
|
||||
<div className="flex min-h-0 flex-1 flex-col px-1">
|
||||
<H4 className="shrink-0 text-sm font-medium">{_("Match or Create")}</H4>
|
||||
<VouchersSection />
|
||||
</div>
|
||||
</div>
|
||||
<TransferModal />
|
||||
@@ -69,16 +69,19 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
</>
|
||||
}
|
||||
|
||||
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
|
||||
/**
|
||||
* TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets
|
||||
* the real height. The scroll container fills its flex parent rather than taking a pixel
|
||||
* height - the virtualizer observes its own rect, so it stays correct across resizes and any
|
||||
* layout change above it.
|
||||
*/
|
||||
function VirtualizedListBody<T>({
|
||||
items,
|
||||
height,
|
||||
getItemKey,
|
||||
children,
|
||||
estimateSize = 74,
|
||||
}: {
|
||||
items: T[]
|
||||
height: number
|
||||
getItemKey: (item: T, index: number) => string | number
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
estimateSize?: number
|
||||
@@ -100,8 +103,7 @@ function VirtualizedListBody<T>({
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto contain-strict"
|
||||
style={{ height }}
|
||||
className="min-h-0 flex-1 overflow-auto contain-strict"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
@@ -123,7 +125,7 @@ function VirtualizedListBody<T>({
|
||||
)
|
||||
}
|
||||
|
||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const UnreconciledTransactions = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
@@ -187,14 +189,13 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
const listHeight = contentHeight - 72
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
}
|
||||
|
||||
return <div className="space-y-1">
|
||||
<div className="flex py-2 w-full gap-2">
|
||||
return <div className="flex min-h-0 flex-1 flex-col space-y-1">
|
||||
<div className="flex py-2 w-full gap-2 shrink-0">
|
||||
|
||||
<InputGroup variant='outline'>
|
||||
<label className="sr-only">{_("Search transactions")}</label>
|
||||
@@ -278,7 +279,6 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
|
||||
<VirtualizedListBody
|
||||
items={results}
|
||||
height={listHeight}
|
||||
estimateSize={74}
|
||||
getItemKey={(transaction) => transaction.name}
|
||||
>
|
||||
@@ -381,7 +381,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
|
||||
}
|
||||
|
||||
|
||||
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const VouchersSection = () => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
@@ -402,8 +402,8 @@ const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
|
||||
return <OptionsForMultipleTransactions transactions={selectedTransactions} />
|
||||
}
|
||||
|
||||
return <div style={{ minHeight: contentHeight }} className="mt-2">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} />
|
||||
return <div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<OptionsForSingleTransaction transaction={selectedTransactions[0]} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -535,11 +535,11 @@ const OptionsForMultipleTransactions = ({ transactions }: { transactions: Unreco
|
||||
}
|
||||
|
||||
|
||||
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
const OptionsForSingleTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
|
||||
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
|
||||
|
||||
return <div className="flex flex-col gap-3">
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-4 justify-center">
|
||||
@@ -602,7 +602,7 @@ const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transacti
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
|
||||
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} />
|
||||
<VouchersForTransaction transaction={transaction} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -774,12 +774,11 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
)
|
||||
}
|
||||
|
||||
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
|
||||
const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
const voucherList = vouchers?.message ?? []
|
||||
const listHeight = contentHeight - 120
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
@@ -801,8 +800,8 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="relative space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
|
||||
return <div className="relative flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm text-ink-gray-5">
|
||||
<Separator className="flex-1" />
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
@@ -818,7 +817,6 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
</Empty>}
|
||||
<VirtualizedListBody
|
||||
items={voucherList}
|
||||
height={listHeight}
|
||||
estimateSize={121}
|
||||
getItemKey={(voucher) => voucher.name}
|
||||
>
|
||||
|
||||
@@ -59,8 +59,8 @@ const SelectedTransactionDetails = ({ transaction, showAccount = false, account
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
<span className='text-p-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-p-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transact
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
<span className='text-p-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,7 @@ export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[],
|
||||
{
|
||||
keywords: ['Federal Bank'],
|
||||
logo: 'Federal_Bank.png',
|
||||
logoDark: 'Federal_Bank-dark.png',
|
||||
logoDark: 'Federal_Bank-Dark.png',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
|
||||
@@ -83,10 +83,13 @@ const StatementDetails = ({ data }: Props) => {
|
||||
|
||||
}
|
||||
|
||||
// `progress` is a percentage (drives the bar); `current`/`total` are actual counts.
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [imported, setImported] = useState({ current: 0, total: 0 })
|
||||
|
||||
useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
|
||||
setProgress(event.progress)
|
||||
setImported({ current: event.current ?? 0, total: event.total ?? 0 })
|
||||
})
|
||||
|
||||
const file_name = data.doc.file.split("/").pop() ?? ""
|
||||
@@ -112,7 +115,9 @@ const StatementDetails = ({ data }: Props) => {
|
||||
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
|
||||
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
|
||||
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
|
||||
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
{loading ? _("Importing...") : data.final_transactions?.length === 1
|
||||
? _("Import 1 transaction")
|
||||
: _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-start gap-4'>
|
||||
@@ -129,7 +134,9 @@ const StatementDetails = ({ data }: Props) => {
|
||||
</div>
|
||||
|
||||
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
|
||||
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])}
|
||||
<span className='text-sm'>{imported.total === 1
|
||||
? _("Importing 1 transaction")
|
||||
: _("Importing {0} of {1} transactions", [imported.current.toString(), imported.total.toString()])}
|
||||
</span>
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ function ListViewInner<TData>({
|
||||
)}
|
||||
role="columnheader"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<div className="min-w-0 flex-1 truncate leading-snug">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useMemo } from "react"
|
||||
import dayjs from "dayjs"
|
||||
import { useCurrentCompany } from "./useCurrentCompany"
|
||||
|
||||
const useFiscalYear = () => {
|
||||
|
||||
return useFrappeGetCall("erpnext.accounts.utils.get_fiscal_year", undefined, 'fiscal_year', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false
|
||||
})
|
||||
|
||||
export type FiscalYear = {
|
||||
name: string
|
||||
year_start_date: string
|
||||
year_end_date: string
|
||||
}
|
||||
|
||||
export default useFiscalYear
|
||||
/**
|
||||
* The fiscal year containing today, for the currently selected company.
|
||||
*
|
||||
* `company` matters in multi-company setups, where fiscal years can be restricted to
|
||||
* specific companies. `date` matters because without it `get_fiscal_year` returns the newest
|
||||
* fiscal year in the system (they're ordered by start date, descending) - which may be one
|
||||
* created in advance for a year that hasn't started.
|
||||
*/
|
||||
const useFiscalYear = () => {
|
||||
const company = useCurrentCompany()
|
||||
|
||||
const { data, ...rest } = useFrappeGetCall<{ message: FiscalYear | [string, string, string] | false }>(
|
||||
"erpnext.accounts.utils.get_fiscal_year",
|
||||
{
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
company,
|
||||
as_dict: 1,
|
||||
// Return nothing instead of throwing/msgprinting when no fiscal year covers today.
|
||||
raise_on_missing: 0,
|
||||
verbose: 0,
|
||||
},
|
||||
company ? `fiscal_year_${company}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false
|
||||
}
|
||||
)
|
||||
|
||||
// get_fiscal_year returns a dict with as_dict, a (name, start, end) tuple without it, and
|
||||
// false when there's no match - normalise all three.
|
||||
const fiscalYear = useMemo<FiscalYear | undefined>(() => {
|
||||
const message = data?.message
|
||||
if (!message) return undefined
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
const [name, year_start_date, year_end_date] = message
|
||||
return { name, year_start_date, year_end_date }
|
||||
}
|
||||
|
||||
return message
|
||||
}, [data])
|
||||
|
||||
return { fiscalYear, ...rest }
|
||||
}
|
||||
|
||||
export default useFiscalYear
|
||||
|
||||
23
banking/src/hooks/useResetScrollOnSearch.ts
Normal file
23
banking/src/hooks/useResetScrollOnSearch.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useLayoutEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* Pins a scrollable list back to the top whenever the search term changes.
|
||||
*
|
||||
* Dropdowns that do their own filtering (`shouldFilter={false}`) swap a long list for a much
|
||||
* shorter one while the scroll container keeps its previous offset - which can leave the
|
||||
* auto-selected first item scrolled out of view.
|
||||
*
|
||||
* Returns a ref to attach to the scroll container (e.g. `CommandList`).
|
||||
*/
|
||||
const useResetScrollOnSearch = (search: string) => {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Layout effect so the reset lands before paint, avoiding a visible jump.
|
||||
useLayoutEffect(() => {
|
||||
listRef.current?.scrollTo({ top: 0 })
|
||||
}, [search])
|
||||
|
||||
return listRef
|
||||
}
|
||||
|
||||
export default useResetScrollOnSearch
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./styles/scroll-fade.css";
|
||||
|
||||
@font-face {
|
||||
font-family: InterVariable;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankAccountBalancePanel from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
||||
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
||||
@@ -9,10 +9,9 @@ import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAtomValue } from "jotai"
|
||||
@@ -25,23 +24,13 @@ const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankR
|
||||
|
||||
const BankReconciliation = () => {
|
||||
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
setHeaderHeight(ref.current.clientHeight)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 flex-col gap-4 md:flex hidden">
|
||||
<div ref={ref} className="flex flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
{/* The page owns the viewport height and the tabs/lists below fill what's left, so
|
||||
the virtualizers size themselves from layout instead of a measured pixel value. */}
|
||||
<div className="px-2 pt-1 flex-col gap-4 md:flex hidden h-dvh">
|
||||
<div className="flex flex-col gap-4 shrink-0">
|
||||
<div className="flex justify-between shrink-0">
|
||||
<div className="flex items-center gap-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -54,7 +43,7 @@ const BankReconciliation = () => {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
<div className="flex gap-1 items-center">
|
||||
{_("Banking")} <Badge theme="violet" variant="subtle">{_("Beta")}</Badge>
|
||||
{_("Banking")}
|
||||
</div>
|
||||
|
||||
</BreadcrumbPage>
|
||||
@@ -71,10 +60,8 @@ const BankReconciliation = () => {
|
||||
<BankRecDateFilter />
|
||||
</div>
|
||||
</div>
|
||||
<BankPicker />
|
||||
<BankBalance />
|
||||
</div>
|
||||
<BankRecTabs remainingHeightAfterTabs={remainingHeightAfterTabs} />
|
||||
<BankRecWorkspace />
|
||||
<BankTransactionUnreconcileModal />
|
||||
</div>
|
||||
<div className="md:hidden flex h-screen items-center justify-between">
|
||||
@@ -104,42 +91,53 @@ const BankReconciliation = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: number }) => {
|
||||
const BankRecWorkspace = () => {
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!selectedBankAccount) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <Tabs defaultValue="Match and Reconcile">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Bank Reconciliation Statement")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Bank Transactions")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Bank Clearance Summary")}</TabsTrigger>
|
||||
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared Entries")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="Match and Reconcile">
|
||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||
</TabsContent>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
return <Tabs defaultValue="Match and Reconcile" className="min-h-0 flex-1 gap-4">
|
||||
{/* Picker + tab strip stack on the left, balance panel beside them - the tab strip
|
||||
fills height the panel needs anyway, so it costs no row of its own. The picker
|
||||
scrolls horizontally (`min-w-0` lets it shrink so its overflow-x engages) while
|
||||
the panel stays put, so the figures never scroll away. */}
|
||||
{/* No gap here: the panel's own `border-s ps-4` supplies the separation, and a gap
|
||||
would leave dead space the picker's edge fade can't reach. */}
|
||||
<div className="flex shrink-0 items-stretch">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3">
|
||||
<BankPicker />
|
||||
{selectedBankAccount && <TabsList>
|
||||
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Reconciliation Statement")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Transactions")}</TabsTrigger>
|
||||
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Clearance Summary")}</TabsTrigger>
|
||||
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared")}</TabsTrigger>
|
||||
</TabsList>}
|
||||
</div>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
{selectedBankAccount && <BankAccountBalancePanel />}
|
||||
</div>
|
||||
|
||||
{selectedBankAccount && <>
|
||||
<TabsContent value="Match and Reconcile" className="flex min-h-0 flex-col">
|
||||
<MatchAndReconcile />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement" className="flex min-h-0 flex-col">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions" className="flex min-h-0 flex-col">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary" className="flex min-h-0 flex-col">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries" className="flex min-h-0 flex-col">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
</>}
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ const StatementImportLog = () => {
|
||||
field: "creation",
|
||||
order: "desc"
|
||||
},
|
||||
limit: 10
|
||||
limit: 20
|
||||
}, bankAccount ? undefined : null, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
94
banking/src/styles/scroll-fade.css
Normal file
94
banking/src/styles/scroll-fade.css
Normal file
@@ -0,0 +1,94 @@
|
||||
/* Scroll-edge fade mask for horizontal scroll containers (the bank picker strip).
|
||||
Ported from Raven's `scroll-fade-x`; imported by index.css, since Tailwind processes
|
||||
`@utility` in imported files the same as in the entry file.
|
||||
|
||||
The scroll-timeline keyframes reveal each edge's fade only when there IS content to scroll
|
||||
in that direction - no fade on the left edge when scrolled fully left, none on the right at
|
||||
the end. `@property` makes the fade animate smoothly rather than jumping.
|
||||
|
||||
Without scroll-timeline support (Firefox) there is deliberately NO fade at all: the fade
|
||||
vars stay at their 0px initial value and the gradient stops collapse to the edges. A static
|
||||
both-edges fallback was tried in Raven and removed - on a container with nothing to scroll
|
||||
it dimmed the edges anyway, promising content that didn't exist. */
|
||||
|
||||
@property --scroll-fade-l {
|
||||
/* length-percentage, NOT length: the fade size is min(12%, …) - a percentage. A <length>
|
||||
property rejects that value and reverts to initial-value (0px), zeroing the fade. */
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --scroll-fade-r {
|
||||
syntax: "<length-percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@keyframes scroll-fade-reveal-l {
|
||||
from {
|
||||
--scroll-fade-l: 0px;
|
||||
}
|
||||
|
||||
to {
|
||||
--scroll-fade-l: var(--_scroll-fade-size-l);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-fade-reveal-r {
|
||||
from {
|
||||
--scroll-fade-r: var(--_scroll-fade-size-r);
|
||||
}
|
||||
|
||||
to {
|
||||
--scroll-fade-r: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-x {
|
||||
--_scroll-fade-size-l: var(--scroll-fade-l-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
|
||||
--_scroll-fade-size-r: var(--scroll-fade-r-size,
|
||||
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
|
||||
/* Eased (smoothstep) alpha ramp, sampled finely so it reads as a smooth curve, NOT fading
|
||||
all the way to transparent: the edge floors at 0.25 (content dims, never vanishes), ramping
|
||||
up to a full 1 for the body. The opaque end MUST be 1 or everything would be permanently
|
||||
dimmed. Stops collapse to the edge when the size animates to 0, so the true first/last card
|
||||
is never dimmed at rest. Tune the floor - higher (~0.4) = subtler, lower (~0.1) = stronger. */
|
||||
--scroll-fade-inline: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0.25) 0,
|
||||
rgba(0, 0, 0, 0.282) calc(var(--scroll-fade-l, 0px) * 0.125),
|
||||
rgba(0, 0, 0, 0.367) calc(var(--scroll-fade-l, 0px) * 0.25),
|
||||
rgba(0, 0, 0, 0.487) calc(var(--scroll-fade-l, 0px) * 0.375),
|
||||
rgba(0, 0, 0, 0.625) calc(var(--scroll-fade-l, 0px) * 0.5),
|
||||
rgba(0, 0, 0, 0.763) calc(var(--scroll-fade-l, 0px) * 0.625),
|
||||
rgba(0, 0, 0, 0.883) calc(var(--scroll-fade-l, 0px) * 0.75),
|
||||
rgba(0, 0, 0, 0.968) calc(var(--scroll-fade-l, 0px) * 0.875),
|
||||
rgba(0, 0, 0, 1) var(--scroll-fade-l, 0px),
|
||||
rgba(0, 0, 0, 1) calc(100% - var(--scroll-fade-r, 0px)),
|
||||
rgba(0, 0, 0, 0.968) calc(100% - var(--scroll-fade-r, 0px) * 0.875),
|
||||
rgba(0, 0, 0, 0.883) calc(100% - var(--scroll-fade-r, 0px) * 0.75),
|
||||
rgba(0, 0, 0, 0.763) calc(100% - var(--scroll-fade-r, 0px) * 0.625),
|
||||
rgba(0, 0, 0, 0.625) calc(100% - var(--scroll-fade-r, 0px) * 0.5),
|
||||
rgba(0, 0, 0, 0.487) calc(100% - var(--scroll-fade-r, 0px) * 0.375),
|
||||
rgba(0, 0, 0, 0.367) calc(100% - var(--scroll-fade-r, 0px) * 0.25),
|
||||
rgba(0, 0, 0, 0.282) calc(100% - var(--scroll-fade-r, 0px) * 0.125),
|
||||
rgba(0, 0, 0, 0.25) 100%);
|
||||
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation:
|
||||
scroll-fade-reveal-l 1ms ease-in-out,
|
||||
scroll-fade-reveal-r 1ms ease-in-out;
|
||||
animation-timeline: scroll(self x), scroll(self x);
|
||||
animation-range:
|
||||
0 var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24)),
|
||||
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24))) 100%;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -375,8 +375,7 @@ class BankStatementImportLog(Document):
|
||||
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||
|
||||
final_transactions, table["date_format"], table["amount_format"] = build_table_transactions(table)
|
||||
# Tables with no detectable transactions (ads, summaries, headers) start excluded.
|
||||
table["included"] = bool(final_transactions)
|
||||
table["included"] = should_include_table(table, final_transactions)
|
||||
|
||||
self.pdf_tables = json.dumps(tables)
|
||||
return tables
|
||||
@@ -542,6 +541,8 @@ class BankStatementImportLog(Document):
|
||||
"bank-rec-statement-import-progress",
|
||||
{
|
||||
"progress": round(progress / total_transactions * 100),
|
||||
"current": progress,
|
||||
"total": total_transactions,
|
||||
},
|
||||
doctype="Bank Statement Import Log",
|
||||
docname=self.name,
|
||||
@@ -551,6 +552,7 @@ class BankStatementImportLog(Document):
|
||||
"bank-rec-statement-import-progress",
|
||||
{
|
||||
"progress": 100,
|
||||
"current": total_transactions,
|
||||
"total": total_transactions,
|
||||
},
|
||||
doctype="Bank Statement Import Log",
|
||||
@@ -821,6 +823,15 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
"""Pure version of the final-transaction builder (date normalized, amount split)."""
|
||||
final_transactions = []
|
||||
|
||||
# Which marker does this statement actually write? A statement that only ever says "Cr"
|
||||
# is marking the credits as its exceptions, so an unmarked row is a withdrawal; one that
|
||||
# only ever says "Dr" means the opposite. With both markers present an unmarked row is
|
||||
# genuinely undetermined, so it stays a withdrawal.
|
||||
unmarked_is_deposit = False
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
markers = {get_amount_cr_dr_marker(row.get("amount")) for row in transaction_rows}
|
||||
unmarked_is_deposit = markers - {None} == {"dr"}
|
||||
|
||||
def parse_amount(transaction_row: dict):
|
||||
if amount_format == "Separate columns for withdrawal and deposit":
|
||||
return get_float_amount(transaction_row.get("withdrawal")), get_float_amount(
|
||||
@@ -829,44 +840,43 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
amount = transaction_row.get("amount")
|
||||
marker = get_amount_cr_dr_marker(amount)
|
||||
# The marker carries the direction, so the amount's own sign is ignored.
|
||||
signed_amount = get_float_amount(amount) or 0
|
||||
|
||||
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
|
||||
float_amount = abs(get_float_amount(amount) or 0)
|
||||
if "cr" in amount.lower():
|
||||
return 0, float_amount
|
||||
else:
|
||||
return float_amount, 0
|
||||
if marker:
|
||||
return (0, abs(signed_amount)) if marker == "cr" else (abs(signed_amount), 0)
|
||||
|
||||
# An unmarked row takes the opposite direction to the marker this statement
|
||||
# uses. A negative amount reverses that again (a refund).
|
||||
is_deposit = unmarked_is_deposit
|
||||
if signed_amount < 0:
|
||||
is_deposit = not is_deposit
|
||||
|
||||
return (0, abs(signed_amount)) if is_deposit else (abs(signed_amount), 0)
|
||||
|
||||
# `or 0` below: get_float_amount returns None for an unparseable cell, and a blank
|
||||
# transaction-type cell comes through as None. Both used to raise.
|
||||
if amount_format == "Amount column has positive/negative values":
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
amount = get_float_amount(transaction_row.get("amount", "0")) or 0
|
||||
if amount > 0:
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
|
||||
transaction_type = str(transaction_row.get("debit_credit") or "").strip().lower()
|
||||
amount = abs(get_float_amount(transaction_row.get("amount", "0")) or 0)
|
||||
|
||||
if amount_format == 'Transaction type column has "CR"/"DR" values':
|
||||
transaction_type = transaction_row.get("debit_credit")
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if "cr" in transaction_type.lower():
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
# "credit" contains "cr". "debit" does not contain "dr", so it correctly falls
|
||||
# through to the withdrawal side.
|
||||
return (0, amount) if "cr" in transaction_type else (amount, 0)
|
||||
|
||||
if amount_format == 'Transaction type column has "C"/"D" values':
|
||||
transaction_type = transaction_row.get("debit_credit")
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if transaction_type.lower().strip() == "c":
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
return (0, amount) if transaction_type == "c" else (amount, 0)
|
||||
|
||||
if amount_format == 'Transaction type column has "Deposit"/"Withdrawal" values':
|
||||
transaction_type = transaction_row.get("debit_credit")
|
||||
amount = get_float_amount(transaction_row.get("amount", "0"))
|
||||
if "deposit" in transaction_type.lower():
|
||||
return 0, abs(amount)
|
||||
else:
|
||||
return abs(amount), 0
|
||||
return (0, amount) if "deposit" in transaction_type else (amount, 0)
|
||||
|
||||
return 0, 0
|
||||
|
||||
@@ -910,6 +920,26 @@ def build_table_transactions(table: dict):
|
||||
return final_transactions, date_format, amount_format
|
||||
|
||||
|
||||
def should_include_table(table: dict, final_transactions: list) -> bool:
|
||||
"""
|
||||
Whether a freshly extracted PDF table should START as included - only the default state
|
||||
of the checkbox, which the user can change afterwards.
|
||||
|
||||
It must have yielded transactions, and it must have a Description column mapped. A
|
||||
transaction table always carries a narration; the summary boxes printed around it -
|
||||
payment due, credit limit, reward points - are dates and figures only. Otherwise the
|
||||
HDFC credit-card "Payment Due Date / Total Dues / Minimum Amount Due" box parses as one
|
||||
transaction and imports a phantom row.
|
||||
|
||||
A description is NOT needed to import (it is not mandatory on Bank Transaction), so a
|
||||
bank that omits narration still works - its table just starts unticked.
|
||||
"""
|
||||
if not final_transactions:
|
||||
return False
|
||||
|
||||
return any(column.get("maps_to") == "Description" for column in table.get("column_mapping", []))
|
||||
|
||||
|
||||
def _clean_cell(cell) -> str:
|
||||
"""Normalize a pdfplumber cell: None -> '', collapse wrapped newlines, strip."""
|
||||
if cell is None:
|
||||
@@ -1055,6 +1085,43 @@ def get_float_amount(amount):
|
||||
return amount
|
||||
|
||||
|
||||
# A "CR"/"DR" marker on the amount itself, at either end: "2,378.00Cr", "Cr 100",
|
||||
# "INR 50.90 Cr.", "DR 1,234.50".
|
||||
# `(?![a-zA-Z])` rather than `\b` on the leading form: there is no word boundary between
|
||||
# the "r" of "Cr100" and the digit, but there IS one inside "CREDIT" and "DRAFT".
|
||||
AMOUNT_CR_DR_PATTERN = re.compile(r"^\s*(cr|dr)(?![a-zA-Z])\.?|(?:^|[\s\d.)])(cr|dr)\b\.?\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def get_amount_cr_dr_marker(amount) -> str | None:
|
||||
"""
|
||||
Return "cr" or "dr" if the amount cell carries a direction marker of its own, else None.
|
||||
|
||||
What is left after removing the marker has to look like an amount - it must hold a digit
|
||||
and at most a short currency token - so that text which merely starts or ends with the
|
||||
letters is not read as a marker. That guard is what separates "Cr 100" from a
|
||||
description that bled into the amount column, like "Dr Smith Clinic 500".
|
||||
"""
|
||||
if not isinstance(amount, str):
|
||||
return None
|
||||
|
||||
match = AMOUNT_CR_DR_PATTERN.search(amount)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Only the marker itself is removed - the surrounding character the pattern needed to
|
||||
# anchor on (a digit, say) stays part of the remainder.
|
||||
group = 1 if match.group(1) else 2
|
||||
start, end = match.span(group)
|
||||
remainder = amount[:start] + amount[end:]
|
||||
|
||||
if not any(char.isdigit() for char in remainder):
|
||||
return None
|
||||
if sum(char.isalpha() for char in remainder) > 3:
|
||||
return None
|
||||
|
||||
return match.group(group).lower()
|
||||
|
||||
|
||||
def get_file_properties(transactions: list):
|
||||
"""
|
||||
From the transaction rows, try to figure out the following:
|
||||
@@ -1075,6 +1142,8 @@ def get_file_properties(transactions: list):
|
||||
'Transaction type column has "C"/"D" values': 0,
|
||||
}
|
||||
|
||||
amount_column_has_cr_dr = False
|
||||
|
||||
for transaction in transactions:
|
||||
date_format = transaction.get("date_format")
|
||||
|
||||
@@ -1092,33 +1161,40 @@ def get_file_properties(transactions: list):
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
if isinstance(amount, str) and ("cr" in amount.lower() or "dr" in amount.lower()):
|
||||
debit_credit = str(transaction.get("debit_credit") or "").strip().lower()
|
||||
|
||||
# One vote per row, most specific signal first. Order matters: "withdrawal" contains
|
||||
# "dr", so it must be matched before the loose cr/dr check or a Deposit/Withdrawal
|
||||
# column reads as CR/DR. "debit" needs listing because, unlike "credit", it does not
|
||||
# contain "dr". The final else means every row votes, even an unrecognised type.
|
||||
if get_amount_cr_dr_marker(amount):
|
||||
amount_column_has_cr_dr = True
|
||||
amount_format_frequency['Amount column has "CR"/"DR" values'] += 1
|
||||
|
||||
# Check if there's a debit_credit column containing "cr"/"dr"
|
||||
if transaction.get("debit_credit", None):
|
||||
if (
|
||||
"cr" in transaction.get("debit_credit", "").lower()
|
||||
or "dr" in transaction.get("debit_credit", "").lower()
|
||||
):
|
||||
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
|
||||
elif (
|
||||
"deposit" in transaction.get("debit_credit", "").lower()
|
||||
or "withdrawal" in transaction.get("debit_credit", "").lower()
|
||||
):
|
||||
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
|
||||
elif (transaction.get("debit_credit", "").lower().strip() == "c") or (
|
||||
transaction.get("debit_credit", "").lower().strip() == "d"
|
||||
):
|
||||
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
|
||||
|
||||
# Else assume that the amount is expressed as positive/negative value
|
||||
elif "deposit" in debit_credit or "withdrawal" in debit_credit:
|
||||
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
|
||||
elif debit_credit in ("c", "d"):
|
||||
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
|
||||
elif any(token in debit_credit for token in ("cr", "dr", "debit")):
|
||||
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
|
||||
else:
|
||||
# Nothing said which direction this is, so assume the amount carries the sign.
|
||||
amount_format_frequency["Amount column has positive/negative values"] += 1
|
||||
|
||||
most_common_date_format = max(date_format_frequency, key=date_format_frequency.get)
|
||||
most_common_amount_format = max(amount_format_frequency, key=amount_format_frequency.get)
|
||||
|
||||
# With no votes at all (no rows, or every amount blank) max() would return whichever key
|
||||
# happens to be first in the dict. Say what we mean instead.
|
||||
if not amount_format_frequency[most_common_amount_format]:
|
||||
most_common_amount_format = "Amount column has positive/negative values"
|
||||
|
||||
# A CR/DR amount column is proved by a single marker, not by a majority: both formats
|
||||
# describe the same column, and an unmarked row is only the default direction, not
|
||||
# evidence against the notation. Statements mark just the exceptions - one HDFC
|
||||
# credit-card page has 18 rows and a single "50.90Cr".
|
||||
if amount_column_has_cr_dr and most_common_amount_format == "Amount column has positive/negative values":
|
||||
most_common_amount_format = 'Amount column has "CR"/"DR" values'
|
||||
|
||||
return most_common_date_format, most_common_amount_format
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_lo
|
||||
detect_column_mapping,
|
||||
detect_header_row,
|
||||
extract_pdf_tables,
|
||||
get_amount_cr_dr_marker,
|
||||
get_float_amount,
|
||||
get_statement_details,
|
||||
guess_column_mapping_by_content,
|
||||
reextract_pdf_table,
|
||||
set_header_index,
|
||||
set_pdf_table_header,
|
||||
should_include_table,
|
||||
update_column_mapping,
|
||||
update_pdf_tables,
|
||||
)
|
||||
@@ -124,6 +126,184 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIsNone(get_float_amount("ABCD"))
|
||||
self.assertIsNone(get_float_amount("****"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Amount format detection
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_amount_cr_dr_marker(self):
|
||||
"""The marker is read at either end of the cell, but only next to the amount."""
|
||||
for amount in ("2,378.00Cr", "50.90 CR", "INR 50.90 Cr.", "1000cr", "5cr", "(100) Cr"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
|
||||
|
||||
for amount in ("2,378.00Dr", "50.90 DR", "1000dr", "-100 Dr"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
|
||||
|
||||
# Some banks put the marker in front of the digits instead.
|
||||
for amount in ("Cr 100", "Cr100", "CR INR 100", "cr 0.00"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
|
||||
|
||||
for amount in ("Dr 100", "Dr100", "Dr. 1,234.50"):
|
||||
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
|
||||
|
||||
for amount in ("100.00", "-2,000.00", "INR 25,236.00", "", None, 100.0):
|
||||
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
|
||||
|
||||
# Text that merely starts or ends with the letters must not be read as a marker, or
|
||||
# a description that bled into the amount column would reclassify the statement.
|
||||
for amount in (
|
||||
"CREDIT CARD PAYMENT 500",
|
||||
"DRAFT 100",
|
||||
"Dr Smith Clinic 500",
|
||||
"DR AMBEDKAR ROAD BRANCH 500",
|
||||
"500 CRC",
|
||||
"Cheque Dr",
|
||||
"Cr",
|
||||
):
|
||||
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
|
||||
|
||||
def test_sparsely_marked_cr_dr_amount_column(self):
|
||||
"""One marker is enough to prove a CR/DR amount column - it is not a majority vote.
|
||||
|
||||
A real HDFC credit-card page carries 18 rows and a single "50.90Cr": the unmarked
|
||||
rows are ordinary purchases, and only the exceptions are marked. A frequency vote
|
||||
therefore picked "positive/negative" 17-1 and imported that lone credit as a debit.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Transaction Description", "Amount (in Rs.)"],
|
||||
["21/07/2026", "ITC MAURYA NEW DELHI", "2,495.00"],
|
||||
["22/07/2026", "ZOMATO LIMITED Gurugram", "1,288.68"],
|
||||
["23/07/2026", "SWIGGY Bangalore", "532.00"],
|
||||
["26/07/2026", "SWIGGY Bangalore", "1,043.00"],
|
||||
["27/07/2026", "PETRO SURCHARGE WAIVER", "50.90Cr"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
# Only "Cr" appears, so it is the marked exception and unmarked rows are debits.
|
||||
self.assertEqual(doc.total_credits, 50.90)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
self.assertEqual(doc.total_debits, 5358.68)
|
||||
self.assertEqual(doc.total_debit_transactions, 4)
|
||||
|
||||
def test_dr_only_statement_treats_unmarked_rows_as_deposits(self):
|
||||
"""The mirror image of a Cr-only statement: only withdrawals are marked.
|
||||
|
||||
The unmarked default cannot be hardcoded to the debit, because which side gets
|
||||
marked varies by bank. It is derived from the markers the statement actually uses -
|
||||
here only "Dr" appears, so "Dr" is the exception and everything unmarked is a
|
||||
deposit.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Amount"],
|
||||
["01/04/2026", "ATM WITHDRAWAL", "2,000.00Dr"],
|
||||
["03/04/2026", "SALARY", "20,000.00"],
|
||||
["05/04/2026", "INTEREST", "150.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
self.assertEqual(doc.total_debits, 2000.0)
|
||||
self.assertEqual(doc.total_debit_transactions, 1)
|
||||
self.assertEqual(doc.total_credits, 20150.0)
|
||||
self.assertEqual(doc.total_credit_transactions, 2)
|
||||
|
||||
def test_leading_cr_dr_markers(self):
|
||||
"""Some banks print the marker in front of the amount."""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Amount"],
|
||||
["01/04/2026", "ATM WITHDRAWAL", "Dr 2,000.00"],
|
||||
["03/04/2026", "SALARY", "Cr 20,000.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
self.assertEqual(doc.total_debits, 2000.0)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
|
||||
def test_partially_marked_cr_dr_amount_column(self):
|
||||
"""A CR/DR amount column stays CR/DR even when some rows carry no marker.
|
||||
|
||||
Every unmarked row used to also vote for "positive/negative", so an ordinary
|
||||
statement with a few unmarked rows was detected as positive/negative and a
|
||||
"2000.00Dr" was then imported as a deposit.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Amount", "Balance"],
|
||||
["01/04/2026", "OPENING FEE", "100.00", "9,900.00"],
|
||||
["03/04/2026", "SALARY", "20000.00Cr", "29,900.00"],
|
||||
["05/04/2026", "ATM WDL", "2000.00Dr", "27,900.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
|
||||
# Both markers appear, so an unmarked row is undetermined and stays a debit.
|
||||
self.assertEqual(doc.total_debits, 2100.0)
|
||||
self.assertEqual(doc.total_debit_transactions, 2)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
|
||||
def test_deposit_withdrawal_type_column(self):
|
||||
"""The word Withdrawal contains "dr", so a loose CR/DR check claims this column first.
|
||||
|
||||
It then reads "Deposit" (which has no "cr" in it) as a withdrawal, flipping the
|
||||
direction of every credit in the statement.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Transaction Type", "Amount"],
|
||||
["01/04/2026", "ATM WDL", "Withdrawal", "2,000.00"],
|
||||
["03/04/2026", "SALARY", "Deposit", "20,000.00"],
|
||||
["05/04/2026", "ATM WDL", "Withdrawal", "500.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
doc.detected_amount_format, 'Transaction type column has "Deposit"/"Withdrawal" values'
|
||||
)
|
||||
self.assertEqual(doc.total_debits, 2500.0)
|
||||
self.assertEqual(doc.total_debit_transactions, 2)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
|
||||
def test_unrecognised_type_column_falls_back_to_signed_amount(self):
|
||||
"""An unrecognised transaction type must not stop the amount being read.
|
||||
|
||||
No tally was incremented for these rows, so max() returned the first key -
|
||||
"Separate columns for withdrawal and deposit" - and, with no such columns in the
|
||||
file, every amount came through as None.
|
||||
"""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Transaction Type", "Amount"],
|
||||
["01/04/2026", "ATM WDL", "NEFT", "-2,000.00"],
|
||||
["03/04/2026", "SALARY", "IMPS", "20,000.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, "Amount column has positive/negative values")
|
||||
self.assertEqual(doc.total_debits, 2000.0)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
|
||||
def test_blank_transaction_type_cell(self):
|
||||
"""A blank type cell used to raise - `None.lower()` - instead of parsing the row."""
|
||||
doc = self._create_bank_statement_import_log(
|
||||
[
|
||||
["Date", "Narration", "Transaction Type", "Amount"],
|
||||
["01/04/2026", "ATM WDL", "Dr", "2,000.00"],
|
||||
["03/04/2026", "SALARY", "Cr", "20,000.00"],
|
||||
["05/04/2026", "UNKNOWN", None, "500.00"],
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(doc.detected_amount_format, 'Transaction type column has "CR"/"DR" values')
|
||||
# The unmarked row has no direction of its own, so it counts as a withdrawal.
|
||||
self.assertEqual(doc.total_debits, 2500.0)
|
||||
self.assertEqual(doc.total_credits, 20000.0)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PDF statement import
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -159,7 +339,8 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
else:
|
||||
table["header_index"] = None
|
||||
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||
table["included"] = True
|
||||
final_transactions, _df, _af = build_table_transactions(table)
|
||||
table["included"] = should_include_table(table, final_transactions)
|
||||
return table
|
||||
|
||||
def test_pdf_multi_page_kept_separate_and_unioned(self):
|
||||
@@ -197,6 +378,74 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
final, _df, _af = build_table_transactions(ad_table)
|
||||
self.assertEqual(final, [])
|
||||
|
||||
def test_pdf_summary_box_not_auto_included(self):
|
||||
"""A summary box that happens to parse as one transaction must not start included.
|
||||
|
||||
The "Payment Due Date / Total Dues / Minimum Amount Due" block on an HDFC
|
||||
credit-card statement has a date column and a figures column, so it yields a single
|
||||
transaction - the due date and the minimum amount - and used to import as a phantom
|
||||
row. What it does not have, and a real transaction table always does, is a narration.
|
||||
"""
|
||||
summary_box = {
|
||||
"header_index": 1,
|
||||
"rows": [
|
||||
["Statement Date:17/08/2025", "Card No: 4341 55XX XXXX 2754", ""],
|
||||
["Payment Due Date", "Total Dues", "Minimum Amount Due"],
|
||||
["06/09/2025", "73,200.00", "3,660.00"],
|
||||
["Credit Limit", "Available Credit Limit", "Available Cash Limit"],
|
||||
["", "32,800", ""],
|
||||
],
|
||||
"column_mapping": [
|
||||
{"index": 0, "header_text": "Payment Due Date", "variable": "a", "maps_to": "Date"},
|
||||
{"index": 1, "header_text": "Total Dues", "variable": "b", "maps_to": "Do not import"},
|
||||
{"index": 2, "header_text": "Minimum Amount Due", "variable": "c", "maps_to": "Amount"},
|
||||
],
|
||||
}
|
||||
|
||||
final, _df, _af = build_table_transactions(summary_box)
|
||||
# It really does parse as a transaction - that is why the previous check missed it.
|
||||
self.assertEqual(len(final), 1)
|
||||
self.assertFalse(should_include_table(summary_box, final))
|
||||
|
||||
# The transaction table beside it, which does carry a narration, still starts included.
|
||||
transactions = self._auto_map(
|
||||
{
|
||||
"rows": [
|
||||
["Date", "Transaction Description", "Amount (in Rs.)"],
|
||||
["21/07/2025", "ITC MAURYA NEW DELHI", "2,495.00"],
|
||||
["27/07/2025", "PETRO SURCHARGE WAIVER", "50.90Cr"],
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertTrue(transactions["included"])
|
||||
|
||||
def test_pdf_table_without_description_still_importable(self):
|
||||
"""No narration column means "starts unticked", NOT "cannot be imported".
|
||||
|
||||
`description` is not mandatory on Bank Transaction, so a bank that omits narration
|
||||
must still import once the user ticks the table.
|
||||
"""
|
||||
table = {
|
||||
"header_index": 0,
|
||||
"rows": [
|
||||
["Date", "Amount", "Balance"],
|
||||
["01/04/2025", "500.00", "9,500.00"],
|
||||
["03/04/2025", "20000.00", "29,500.00"],
|
||||
],
|
||||
"column_mapping": [
|
||||
{"index": 0, "header_text": "Date", "variable": "a", "maps_to": "Date"},
|
||||
{"index": 1, "header_text": "Amount", "variable": "b", "maps_to": "Amount"},
|
||||
{"index": 2, "header_text": "Balance", "variable": "c", "maps_to": "Balance"},
|
||||
],
|
||||
}
|
||||
|
||||
final, _df, _af = build_table_transactions(table)
|
||||
self.assertFalse(should_include_table(table, final))
|
||||
|
||||
# The transactions themselves are intact and importable.
|
||||
self.assertEqual(len(final), 2)
|
||||
self.assertEqual([t["date"] for t in final], ["2025-04-01", "2025-04-03"])
|
||||
|
||||
def test_headerless_content_mapping(self):
|
||||
"""Without a header row, columns are guessed from their contents."""
|
||||
rows = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("<script>", message)
|
||||
self.assertNotIn("<script>", message)
|
||||
|
||||
def test_build_conditions_raises_on_invalid_field_when_opted_in(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FilterExpressionParser,
|
||||
)
|
||||
|
||||
account = frappe.qb.DocType("Account")
|
||||
rows = [self._row('["bad_field", "=", "x"]')]
|
||||
parser = FilterExpressionParser()
|
||||
|
||||
# default: invalid rows are skipped, not raised
|
||||
self.assertIsNone(parser.build_conditions(rows, account))
|
||||
|
||||
# opted in (the get_filtered_accounts path): invalid rows raise
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, parser.build_conditions, rows, account, raise_on_invalid=True
|
||||
)
|
||||
|
||||
def test_build_conditions_empty_returns_none(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FilterExpressionParser,
|
||||
)
|
||||
|
||||
account = frappe.qb.DocType("Account")
|
||||
self.assertIsNone(FilterExpressionParser().build_conditions([], account))
|
||||
|
||||
def test_endpoint_requires_company(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
get_filtered_accounts,
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, get_filtered_accounts, "", "[]")
|
||||
|
||||
def test_endpoint_rejects_invalid_field(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
get_filtered_accounts,
|
||||
)
|
||||
|
||||
company = frappe.get_all("Company", limit=1, pluck="name")[0]
|
||||
rows = frappe.as_json([{"calculation_formula": '["bad_field", "=", "x"]'}])
|
||||
self.assertRaises(frappe.ValidationError, get_filtered_accounts, company, rows)
|
||||
|
||||
def test_endpoint_empty_rows_returns_all_company_accounts(self):
|
||||
# filters are optional: no filter returns every enabled, non-group account of the company
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
get_filtered_accounts,
|
||||
)
|
||||
|
||||
company = frappe.get_all("Company", limit=1, pluck="name")[0]
|
||||
expected = frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": company, "disabled": 0, "is_group": 0},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
{% } %}
|
||||
{% } %}
|
||||
|
||||
@@ -106,6 +106,7 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
columns[0]["fieldname"] = "sales_invoice"
|
||||
columns[0]["options"] = "Item"
|
||||
columns[0]["width"] = 300
|
||||
# removing Item Code and Item Name columns
|
||||
# removing the duplicate Item Code column and moving Item Name before Customer
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
|
||||
del columns[4:6]
|
||||
del columns[4]
|
||||
columns.insert(1, columns.pop(4))
|
||||
else:
|
||||
del columns[5:7]
|
||||
del columns[5]
|
||||
columns.insert(1, columns.pop(5))
|
||||
|
||||
total_base_amount = 0
|
||||
total_buying_amount = 0
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
@@ -78,8 +79,48 @@ class CodeList(Document):
|
||||
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
|
||||
|
||||
|
||||
def _version_key(version: str | None) -> list:
|
||||
"""Natural sort key for the version formats publishers use: integers and ISO dates.
|
||||
|
||||
Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05.
|
||||
"""
|
||||
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")]
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def resolve_code_list(code_list: str) -> str | None:
|
||||
"""Return the Code List for a document name or a canonical URI.
|
||||
|
||||
Code Lists are named after their CanonicalVersionUri, so one canonical URI can
|
||||
map to several documents, one per version. An exact document name takes
|
||||
precedence, which lets a caller request a specific version; a canonical URI
|
||||
resolves to the latest version available.
|
||||
"""
|
||||
if frappe.db.exists("Code List", code_list):
|
||||
return code_list
|
||||
|
||||
candidates = frappe.get_all(
|
||||
"Code List",
|
||||
filters={"canonical_uri": code_list},
|
||||
fields=["name", "version"],
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# ponytail: assumes one publisher sticks to one version format. An integer and an
|
||||
# ISO date under the same canonical URI compare numerically (3 < 2020), so the date
|
||||
# would win; import the genericode ValidityDate and sort on that if it ever happens.
|
||||
return max(candidates, key=lambda cl: _version_key(cl.version)).name
|
||||
|
||||
|
||||
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
"""Return the common code for a given record"""
|
||||
"""Return the common code for a given record.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
"""Return the record name for a given common code"""
|
||||
"""Return the record name for a given common code.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_default_code(code_list: str) -> str | None:
|
||||
"""Return the default common code for a given code list"""
|
||||
"""Return the default common code for a given code list.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return None
|
||||
|
||||
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
|
||||
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None
|
||||
|
||||
@@ -1,9 +1,83 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
|
||||
from erpnext.edi.doctype.code_list.code_list import (
|
||||
_version_key,
|
||||
get_codes_for,
|
||||
get_default_code,
|
||||
get_docnames_for,
|
||||
resolve_code_list,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CANONICAL_URI = "urn:test:erpnext:codeliste:resolve"
|
||||
OLD_VERSION = f"{CANONICAL_URI}:3"
|
||||
NEW_VERSION = f"{CANONICAL_URI}:10"
|
||||
UNKNOWN_URI = "urn:test:erpnext:codeliste:missing"
|
||||
|
||||
|
||||
class TestCodeList(ERPNextTestSuite):
|
||||
pass
|
||||
def setUp(self):
|
||||
"""Create two versions of one code list. Test records are rolled back per test."""
|
||||
for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")):
|
||||
if not frappe.db.exists("Code List", name):
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=name,
|
||||
title=name,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version=version,
|
||||
).insert()
|
||||
|
||||
default_code = frappe.get_doc(
|
||||
doctype="Common Code",
|
||||
title="Test Default",
|
||||
common_code="XYZ",
|
||||
code_list=NEW_VERSION,
|
||||
).insert()
|
||||
frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name)
|
||||
|
||||
# resolution is request-cached, so fixtures must not be masked by earlier lookups
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
def test_version_key_orders_integers_and_iso_dates(self):
|
||||
"""Integer and ISO date versions must both order correctly, unlike a lexical sort."""
|
||||
self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"])
|
||||
self.assertEqual(
|
||||
sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key),
|
||||
["2019-12-31", "2020-01-01", "2020-11-05"],
|
||||
)
|
||||
|
||||
def test_canonical_uri_resolves_to_latest_version(self):
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION)
|
||||
|
||||
def test_name_resolves_to_itself(self):
|
||||
"""Passing a version-specific name must return that version, not the latest one."""
|
||||
self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION)
|
||||
|
||||
def test_name_takes_precedence_over_canonical_uri(self):
|
||||
"""A document named like a canonical URI must not redirect to another version."""
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=CANONICAL_URI,
|
||||
title=CANONICAL_URI,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version="1",
|
||||
).insert()
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI)
|
||||
|
||||
def test_unknown_uri_resolves_to_none(self):
|
||||
self.assertIsNone(resolve_code_list(UNKNOWN_URI))
|
||||
|
||||
def test_lookups_are_empty_for_unknown_code_list(self):
|
||||
"""An unresolved code list must not fall through to an unfiltered query."""
|
||||
self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ())
|
||||
self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ())
|
||||
self.assertIsNone(get_default_code(UNKNOWN_URI))
|
||||
|
||||
def test_default_code_follows_latest_version(self):
|
||||
self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")
|
||||
|
||||
@@ -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",
|
||||
|
||||
3761
erpnext/locale/ar.po
3761
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/bg.po
3753
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3772
erpnext/locale/bs.po
3772
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3755
erpnext/locale/cs.po
3755
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3765
erpnext/locale/da.po
3765
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3801
erpnext/locale/de.po
3801
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3765
erpnext/locale/eo.po
3765
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3761
erpnext/locale/es.po
3761
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3889
erpnext/locale/fa.po
3889
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3755
erpnext/locale/fr.po
3755
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3757
erpnext/locale/hi.po
3757
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3766
erpnext/locale/hr.po
3766
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3763
erpnext/locale/hu.po
3763
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3761
erpnext/locale/id.po
3761
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/it.po
3753
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/km.po
3753
erpnext/locale/km.po
File diff suppressed because it is too large
Load Diff
3757
erpnext/locale/ko.po
3757
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3811
erpnext/locale/mn.po
3811
erpnext/locale/mn.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/my.po
3753
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/nb.po
3753
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3761
erpnext/locale/nl.po
3761
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/pl.po
3753
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/pt.po
3753
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3753
erpnext/locale/ro.po
3753
erpnext/locale/ro.po
File diff suppressed because it is too large
Load Diff
3765
erpnext/locale/ru.po
3765
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3755
erpnext/locale/sl.po
3755
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3761
erpnext/locale/sr.po
3761
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3904
erpnext/locale/sv.po
3904
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3763
erpnext/locale/th.po
3763
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3761
erpnext/locale/tr.po
3761
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
3765
erpnext/locale/uz.po
3765
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3761
erpnext/locale/vi.po
3761
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
3763
erpnext/locale/zh.po
3763
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
Reference in New Issue
Block a user