Compare commits

..

10 Commits

Author SHA1 Message Date
Frappe PR Bot
d1d3b241ae chore(release): Bumped to Version 16.26.2
## [16.26.2](https://github.com/frappe/erpnext/compare/v16.26.1...v16.26.2) (2026-07-03)

### Bug Fixes

* **item-attribute:** clear attribute values when marking numeric ([1b1ea7f](1b1ea7f2aa))
* **item:** error on uncommitted input and escape values in variant dialog ([56d065b](56d065b919))
* **item:** rework multiple variant dialog for large numeric ranges ([6691217](66912173bd))
2026-07-03 10:29:44 +00:00
Mihir Kandoi
9cdaa738f1 Merge pull request #56848 from frappe/mergify/bp/version-16/pr-56742
fix(item): rework multiple variant dialog for large numeric ranges (backport #56741) (backport #56742)
2026-07-03 15:58:17 +05:30
Mihir Kandoi
56d065b919 fix(item): error on uncommitted input and escape values in variant dialog
Address review feedback:
- A typed-but-not-selected value passed validation yet was dropped by
  get_selected_attributes (reads committed pills only). Treat any pending
  input as an error so it is never silently omitted from creation.
- Escape pill / pending values before interpolating them into the HTML
  error message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit d4da9a3d7d)
(cherry picked from commit 04c834d6a9)
2026-07-03 10:25:48 +00:00
Mihir Kandoi
66912173bd fix(item): rework multiple variant dialog for large numeric ranges
The 'Create Multiple Variants' dialog rendered one checkbox per attribute
value and read the numeric config from the variant attribute child row. This
broke in several ways:

- A template whose attribute was made numeric after being added kept
  numeric_values=0 on the child row, so the dialog treated it as non-numeric,
  queried the empty Item Attribute Value table, and showed no values.
- Enumerating a large range (e.g. 1-100000) into checkboxes froze the browser.

Rework the dialog:

- Read numeric_values / from_range / to_range / increment from the Item
  Attribute master, and guard increment > 0.
- Replace the checkbox-per-value list with one MultiSelectPills per attribute,
  with a search placeholder.
- Stop enumerating numeric ranges: preview the first few values and validate
  typed input against the range on demand, so huge ranges stay instant.
- Block variant creation with a modal error if any selected value or pending
  input is invalid (out of range, off-increment, or not a number), so garbage
  like '00A' can't reach creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 99152b8300)
(cherry picked from commit 025d0cd7f3)
2026-07-03 10:25:48 +00:00
Mihir Kandoi
1b1ea7f2aa fix(item-attribute): clear attribute values when marking numeric
Marking an attribute numeric hides the Item Attribute Values grid but leaves
its rows in the doc, whose mandatory Attribute Value / Abbreviation block the
save client-side before the server can clear them. Clear the table on the
client too so the save goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4afbd4d3d9)
(cherry picked from commit 374b340e73)
2026-07-03 10:25:47 +00:00
Frappe PR Bot
fd00cebbd2 chore(release): Bumped to Version 16.26.1
## [16.26.1](https://github.com/frappe/erpnext/compare/v16.26.0...v16.26.1) (2026-07-01)

### Bug Fixes

* **stock:** support quality inspection for stock entry by purpose (backport [#56446](https://github.com/frappe/erpnext/issues/56446)) ([613b3c1](613b3c16c2))
2026-07-01 07:42:04 +00:00
Mihir Kandoi
fe6c276e72 Merge pull request #56687 from frappe/mergify/bp/version-16/pr-56672
fix(stock): support quality inspection for stock entry by purpose (ba… (backport #56672)
2026-07-01 13:10:18 +05:30
Sudharsanan11
613b3c16c2 fix(stock): support quality inspection for stock entry by purpose (backport #56446)
(cherry picked from commit 40ca3b5e5d)
2026-07-01 07:28:45 +00:00
Frappe PR Bot
666becc670 chore(release): Bumped to Version 16.26.0
# [16.26.0](https://github.com/frappe/erpnext/compare/v16.25.0...v16.26.0) (2026-07-01)

### Bug Fixes

* add permission checks in whitelisted functions (backport [#53103](https://github.com/frappe/erpnext/issues/53103)) ([#56669](https://github.com/frappe/erpnext/issues/56669)) ([52ea964](52ea9641ce))
* adjust outstanding amount calculation in purchase and sales registers ([1a2a9b6](1a2a9b6cfc))
* allow rename for Quality Inspection Parameter ([410a772](410a772510))
* **asset:** conditionally show Is Fully Depreciated field ([0e730bf](0e730bf1c7))
* carry item-level project to Purchase Receipt GL entries (backport [#56568](https://github.com/frappe/erpnext/issues/56568)) ([#56619](https://github.com/frappe/erpnext/issues/56619)) ([0829cb4](0829cb45a8))
* **controllers:** fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) ([3abadc7](3abadc7a5f))
* **crm:** using `get_list` instead of `get_all` in `get_opportunities` (backport [#56463](https://github.com/frappe/erpnext/issues/56463)) ([#56466](https://github.com/frappe/erpnext/issues/56466)) ([30ba950](30ba950abd))
* do not allow closing the accounting period for future dates (backport [#56551](https://github.com/frappe/erpnext/issues/56551)) ([#56577](https://github.com/frappe/erpnext/issues/56577)) ([df1b443](df1b4431b6))
* exclude virtual child doctypes from deletion in transaction deletion record ([8a66570](8a665709d2))
* gross profit calculation with rate adjustment entries ([9c5b063](9c5b063884))
* handle missing serial and batch bundle in print format ([c84d4a2](c84d4a282d))
* ignored posting time 00:00:00 in RIV (backport [#56571](https://github.com/frappe/erpnext/issues/56571)) ([#56573](https://github.com/frappe/erpnext/issues/56573)) ([b4d83e5](b4d83e542a))
* job card timer issue (backport [#56405](https://github.com/frappe/erpnext/issues/56405)) ([#56406](https://github.com/frappe/erpnext/issues/56406)) ([54c45d7](54c45d7b22))
* **lead:** added missing read permission check on `get_lead_details` (backport [#56272](https://github.com/frappe/erpnext/issues/56272)) ([#56274](https://github.com/frappe/erpnext/issues/56274)) ([bd54c7f](bd54c7fea8))
* **letter-head:** guard company lookups when doc has no company field ([89059a9](89059a990f))
* link portal address rows to web form ([3a480c0](3a480c08b1))
* manual backport of [#55896](https://github.com/frappe/erpnext/issues/55896) ([490e125](490e125267))
* party aliases should be no copy ([0b42241](0b42241682))
* precision issue causing COGS in inter transfer PR (backport [#56420](https://github.com/frappe/erpnext/issues/56420)) ([#56425](https://github.com/frappe/erpnext/issues/56425)) ([e3958ad](e3958ad7bb))
* remove dead bundle helper call from purchase receipt print format ([2eaa635](2eaa635ab6))
* remove frappe.utils from jinja context in process statement of accounts ([37ec2d0](37ec2d0edd))
* rewrite item rate calculation (backport [#56315](https://github.com/frappe/erpnext/issues/56315)) ([ef3d444](ef3d444a60))
* set mr status to received when per_received is 100 even if per_ordered < 100 ([4181246](41812462b4))
* show contextual balance label on party dashboard for net balances ([a886d0b](a886d0b445))
* skip qty over-allowance check for non-stock items only ([bc313dc](bc313dc09d))
* **stock:** value batch/serial return from ledger when original receipt has no bundle (backport [#56631](https://github.com/frappe/erpnext/issues/56631)) ([#56646](https://github.com/frappe/erpnext/issues/56646)) ([2c18c16](2c18c16be6))
* sync Stock Reconciliation difference amount with GL after reposting (backport [#56574](https://github.com/frappe/erpnext/issues/56574)) ([#56585](https://github.com/frappe/erpnext/issues/56585)) ([e834098](e834098c28))
* update qty in future SLEs when cancelling documents ([#56638](https://github.com/frappe/erpnext/issues/56638)) ([01374db](01374db8da))
* update_qty_in_future_sle skips SLEs with same posting datetime ([#56612](https://github.com/frappe/erpnext/issues/56612)) ([5aa62d1](5aa62d1cda))
* Use correct doctype name for PCV perm-check (backport [#56606](https://github.com/frappe/erpnext/issues/56606)) ([#56611](https://github.com/frappe/erpnext/issues/56611)) ([762ce5c](762ce5c684))
* use correct variable to fetch valuation method ([d2c8df9](d2c8df9451))

### Features

* **accounts:** add configurable job timeout for Process Period Closing Voucher ([d389014](d389014e57))
2026-07-01 03:34:53 +00:00
Diptanil Saha
628b932d55 Merge pull request #56652 from frappe/version-16-hotfix
chore: release v16
2026-07-01 09:03:17 +05:30
56 changed files with 1539 additions and 2393 deletions

View File

@@ -14,35 +14,35 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.1",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.15.0",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.0",
"jotai-family": "^1.0.1",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-currency-input-field": "^4.0.5",
"react-day-picker": "9.14.0",
"react-dom": "^19.2.7",
"react-dom": "^19.2.6",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0",
@@ -51,15 +51,15 @@
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.1"
"typescript-eslint": "^8.48.0"
}
}

View File

@@ -1,5 +1,5 @@
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
@@ -25,7 +26,6 @@ import { Form } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { DateField } from "@/components/ui/form-elements"
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankClearanceSummary = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -18,7 +18,6 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -216,13 +215,38 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
})
} else {
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
/**
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
* So we need to compute the value of the expression
* We can use the eval function to do this. But we need to expose certain variables to the expression.
* One of them is transaction_amount which is the unallocated amount of the selected transaction
* @param expression - The expression to compute
* @returns The computed value
*/
const computeExpression = (expression: string) => {
const script = `
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
${expression};
`
let value = 0;
try {
value = window.eval(script);
} catch (error: unknown) {
console.error(error);
value = 0;
}
return value;
}
if (!acc?.debit && !acc?.credit) {
hasTotallyEmptyRowEarlier = true;
}
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import { useCallback, useMemo } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useFrappeGetCall } from "frappe-react-sdk"
@@ -18,7 +19,6 @@ import _ from "@/lib/translate"
import { toast } from "sonner"
import { useCopyToClipboard } from "usehooks-ts"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankReconciliationStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
}, [data])
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
}} />
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -1,6 +1,7 @@
import { useAtomValue, useSetAtom } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Paragraph } from "@/components/ui/typography"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
@@ -22,7 +23,6 @@ import { useCallback, useMemo, useState } from "react"
import { Link } from "react-router"
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import MarkdownRenderer from "@/components/ui/markdown"
const BankTransactions = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
}, [data, search, amountFilter, typeFilter, status])
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-2 py-2">
<div className="flex gap-2 justify-between items-center">
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<Button size='md' variant='subtle' asChild>
<Link to="/statement-importer">

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo } from "react"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
@@ -17,7 +18,6 @@ import { PartyPopper } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import _ from "@/lib/translate"
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const IncorrectlyClearedEntries = () => {
const companyID = useCurrentCompany()
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
[accountCurrency, onClearClick],
)
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
}} />
<br />
{data && data.message.result.length > 0 && <span>
<MarkdownRenderer content={entriesContent} />
<span dangerouslySetInnerHTML={{
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
<br />
{_("You can reset the clearing dates of these entries here.")}
</span>}
</span>
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -11,7 +11,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { H4, Paragraph } from "@/components/ui/typography"
import { today } from "@/lib/date"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
@@ -446,10 +445,11 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
// If it's a string and cannot be a number, then show it as a formula
if (isNaN(Number(value))) {
let calculatedValue = "";
try {
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
} catch (error: unknown) {
console.error(error);
calculatedValue = "Error";

View File

@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { Link, useNavigate } from 'react-router'
import { Link, useNavigate } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { Progress } from '@/components/ui/progress'
import { useSetAtom } from 'jotai'

View File

@@ -1,26 +0,0 @@
import { Parser } from 'safe-expr-eval'
const parser = new Parser()
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
const trimmed = expression.trim()
if (!trimmed) {
return 0
}
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
return Number(trimmed)
}
try {
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
if (typeof result !== 'number' || !Number.isFinite(result)) {
return 0
}
return result
} catch {
return 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.25.0"
__version__ = "16.26.2"
def get_default_company(user=None):

View File

@@ -829,9 +829,7 @@ 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")
# 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)
float_amount = get_float_amount(amount)
if "cr" in amount.lower():
return 0, float_amount
else:
@@ -934,18 +932,14 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if reader.is_encrypted:
# Try opening the PDF with a password - if no password is provided, try with a blank password
if not password:
password = ""
if not reader.decrypt(password):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
if reader.is_encrypted and (not password or not reader.decrypt(password)):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
tables = []

View File

@@ -104,36 +104,6 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(

View File

@@ -9,48 +9,6 @@ from frappe.model.document import Document
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
PYTHON_ONLY_OPERATORS = ("**", "//")
def _is_expr_eval_formula(formula: str) -> bool:
position = 0
while position < len(formula):
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
if not match:
return False
position = match.end()
return formula.count("(") == formula.count(")")
def validate_amount_formula(formula: str) -> None:
if not formula:
return
stripped = formula.strip()
if PLAIN_NUMBER_PATTERN.match(stripped):
return
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not _is_expr_eval_formula(stripped):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
python_formula = stripped.replace("^", "**")
try:
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
except Exception:
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not isinstance(result, (int | float)):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
class BankTransactionRule(Document):
# begin: auto-generated types
@@ -128,11 +86,6 @@ class BankTransactionRule(Document):
frappe.throw(
_("The last account row must not have any debit or credit amounts set.")
)
else:
if account.debit:
validate_amount_formula(account.debit)
if account.credit:
validate_amount_formula(account.credit)
# Validate regex
for rule in self.description_rules:

View File

@@ -231,45 +231,3 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
with self.assertRaises(ValidationError):
doc.insert()
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
return self._rule(
prefix,
[{"check": "Contains", "value": "x"}],
classify_as="Bank Entry",
bank_entry_type="Multiple Accounts",
accounts=accounts,
**fields,
)
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
doc = self._multiple_accounts_rule(
"be_formula",
accounts=[
{"account": self.bank, "debit": "200", "credit": ""},
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
{"account": self.cash, "debit": "", "credit": ""},
],
)
doc.insert()
self.assertTrue(doc.name)
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
malicious_formulas = [
"__import__('os')",
"eval('1+1')",
"open('/etc/passwd')",
"transaction_amount ** 2",
"transaction_amount // 2",
]
for formula in malicious_formulas:
with self.subTest(formula=formula):
doc = self._multiple_accounts_rule(
"be_bad_formula",
accounts=[
{"account": self.bank, "debit": formula, "credit": ""},
{"account": self.cash, "debit": "", "credit": ""},
],
)
with self.assertRaises(ValidationError):
doc.insert()

View File

@@ -71,7 +71,7 @@ frappe.ui.form.on("Journal Entry", {
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
erpnext.journal_entry.lock_reversal_entry(frm);
frm.set_read_only();
}
erpnext.toggle_naming_series();
@@ -564,13 +564,6 @@ $.extend(erpnext.journal_entry, {
});
},
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
var row = locals[cdt][cdn];

View File

@@ -352,15 +352,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.make_period_closing_voucher(posting_date="2021-03-31")
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
totals_after_cancel = frappe.db.sql(
"""

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -716,15 +716,13 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -791,7 +789,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -140,26 +140,6 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
def before_insert(self):
self.clear_clearance_date_on_amend()
def clear_clearance_date_on_amend(self):
"""Drop the bank reconciliation clearance date copied over while amending.
The framework copies `no_copy` fields when amending, so a reconciled
voucher would carry a stale clearance date into its amendment even though
the linked bank transaction gets unreconciled on cancellation.
"""
if not self.get("amended_from"):
return
if self.meta.has_field("clearance_date"):
self.clearance_date = None
for payment in self.get("payments") or []:
if payment.meta.has_field("clearance_date"):
payment.clearance_date = None
def on_update(self):
from erpnext.controllers.taxes_and_totals import process_item_wise_tax_details

View File

@@ -387,10 +387,6 @@ class StockController(AccountsController):
parent_details = self.get_parent_details_for_packed_items()
for row in self.get(table_name):
item_code = row.get("rm_item_code") or row.get("item_code")
if not item_code or not self.is_serial_batch_item(item_code):
continue
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle

View File

@@ -2322,145 +2322,6 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertTrue(len(reserved_entries) == 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_no_stock_reservation_via_purchase_receipt_when_reserve_stock_disabled(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.material_request.material_request import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
bom_tree = {"FG For SR No Auto Reserve": {"RM For SR No Auto Reserve": {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
warehouse = "_Test Warehouse - _TC"
# reserve_stock is deliberately left unset (defaults to 0): this is what happens when
# "Auto Reserve Stock" is off and nobody ticks "Reserve Stock" on the Production Plan by hand.
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
self.assertEqual(plan.reserve_stock, 0)
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
material_requests = frappe.get_all(
"Material Request", filters={"production_plan": plan.name}, pluck="name"
)
self.assertGreater(len(material_requests), 0)
for mr_name in list(set(material_requests)):
po = make_purchase_order(mr_name)
po.supplier = "_Test Supplier"
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_ignores_production_plans_with_reserve_stock_off_on_shared_purchase_order(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
warehouse = "_Test Warehouse - _TC"
bom_reserve = create_nested_bom({"FG SR Mixed Reserve": {"RM SR Mixed Reserve": {}}}, prefix="")
bom_skip = create_nested_bom({"FG SR Mixed Skip": {"RM SR Mixed Skip": {}}}, prefix="")
def make_submitted_plan(item_code, reserve_stock):
plan = create_production_plan(
item_code=item_code,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
reserve_stock=reserve_stock,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
return plan
plan_reserve = make_submitted_plan(bom_reserve.item, reserve_stock=1)
plan_skip = make_submitted_plan(bom_skip.item, reserve_stock=0)
self.assertEqual(plan_reserve.reserve_stock, 1)
self.assertEqual(plan_skip.reserve_stock, 0)
mr_reserve = frappe.get_all(
"Material Request", filters={"production_plan": plan_reserve.name}, pluck="name"
)[0]
mr_skip = frappe.get_all(
"Material Request", filters={"production_plan": plan_skip.name}, pluck="name"
)[0]
# One Purchase Order pulling rows from both Material Requests, so the Purchase Receipt made
# from it has both a reservable and a non-reservable Production Plan reference in `doc.items`.
po = frappe.new_doc("Purchase Order")
po.supplier = "_Test Supplier"
po.company = plan_reserve.company
po.schedule_date = nowdate()
for mr_name in (mr_reserve, mr_skip):
mr = frappe.get_doc("Material Request", mr_name)
for item in mr.items:
po.append(
"items",
{
"item_code": item.item_code,
"qty": item.qty,
"rate": 100,
"schedule_date": nowdate(),
"warehouse": warehouse,
"material_request": mr.name,
"material_request_item": item.name,
},
)
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
reserved_for_plan_reserve = StockReservation(plan_reserve).get_reserved_entries(
"Production Plan", plan_reserve.name
)
reserved_for_plan_skip = StockReservation(plan_skip).get_reserved_entries(
"Production Plan", plan_skip.name
)
self.assertGreater(len(reserved_for_plan_reserve), 0)
self.assertEqual(len(reserved_for_plan_skip), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_of_serial_nos_against_production_plan(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

View File

@@ -649,12 +649,6 @@ class WorkOrder(Document):
def update_status(self, status=None):
"""Update status of work order if unknown"""
if self.docstatus == 1:
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
# are reflected immediately, instead of only after the next status update call.
self.refresh_material_transferred_for_manufacturing()
if self.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)
@@ -1678,31 +1672,6 @@ class WorkOrder(Document):
if self.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
row_wise_serial_batch = frappe._dict({})
if self.reserve_stock:
row_wise_serial_batch = get_row_wise_serial_batch(self.name)
for row in self.required_items:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.reserve_stock:
self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def refresh_material_transferred_for_manufacturing(self):
"""Recompute material_transferred_for_manufacturing only, without touching per-row
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
"""
if self.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
@@ -1719,13 +1688,25 @@ class WorkOrder(Document):
(ste.docstatus == 1)
& (ste.work_order == self.name)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == is_return)
& (ste.is_return == 0)
)
.groupby(ste_child.item_code)
)
data = query.run(as_dict=1) or []
return frappe._dict({d.original_item or d.item_code: d.qty for d in data})
transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
row_wise_serial_batch = frappe._dict({})
if self.reserve_stock:
row_wise_serial_batch = get_row_wise_serial_batch(self.name)
for row in self.required_items:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.reserve_stock:
self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
@@ -1796,7 +1777,29 @@ class WorkOrder(Document):
doc.update_reserved_stock_in_bin()
def update_returned_qty(self):
returned_dict = self._material_transfer_qty_by_item(is_return=1)
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
query = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(
ste_child.item_code,
ste_child.original_item,
fn.Sum(ste_child.transfer_qty).as_("qty"),
)
.where(
(ste.docstatus == 1)
& (ste.work_order == self.name)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 1)
)
.groupby(ste_child.item_code)
)
data = query.run(as_dict=1) or []
returned_dict = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
for row in self.required_items:
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)

View File

@@ -34,7 +34,6 @@ def complete_onboarding_steps_if_record_exists(steps):
if (
step.action == "Create Entry"
and step.reference_document
and frappe.db.exists("DocType", step.reference_document)
and frappe.get_all(step.reference_document, limit=1)
):
frappe.db.set_value("Onboarding Step", step.name, "is_complete", 1, update_modified=False)

View File

@@ -19,101 +19,6 @@ frappe.setup.on("before_load", function () {
});
erpnext.setup.slides_settings = [
{
// Persona — help us tailor the setup
name: "persona",
title: __("A little about you"),
// subtitle shown under the title
help: __("A few quick questions so we can set things up the way you work."),
fields: [
{
fieldname: "persona_implementing_for",
label: __("Who are you setting this up for?"),
fieldtype: "Select",
options: ["", "My own business", "A company I work for", "A client I'm consulting for"].join(
"\n"
),
reqd: 1,
},
{
fieldname: "persona_company_size",
label: __("How big is the team?"),
fieldtype: "Select",
options: ["", "110", "1150", "51200", "2011,000", "1,000+"].join("\n"),
reqd: 1,
},
{
fieldname: "persona_industry",
label: __("What kind of work do you do?"),
fieldtype: "Select",
options: [
"",
"Manufacturing",
"Retail",
"Wholesale / Distribution",
"E-commerce",
"Services / Consulting",
"Construction / Real Estate",
"Technology / Software",
"Healthcare",
"Education",
"Agriculture",
"Food & Beverage",
"Non Profit",
"Other",
].join("\n"),
reqd: 1,
},
{
fieldname: "persona_current_system",
label: __("What do you use today?"),
fieldtype: "Select",
options: [
"",
"Tally",
"QuickBooks",
"Zoho",
"Sage",
"SAP",
"Microsoft Dynamics",
"Oracle NetSuite",
"Xero",
"Excel / Spreadsheets",
"Nothing yet - starting fresh",
"Other",
].join("\n"),
reqd: 1,
},
{
fieldtype: "Section Break",
description: __("Select the modules that you plan to implement"),
},
{ fieldname: "module_accounting", label: __("Accounting"), fieldtype: "Check" },
{ fieldname: "module_stock", label: __("Stock"), fieldtype: "Check" },
{ fieldtype: "Column Break" },
{ fieldname: "module_manufacturing", label: __("Manufacturing"), fieldtype: "Check" },
{ fieldname: "module_projects", label: __("Project Management"), fieldtype: "Check" },
],
onload: function (slide) {
this.bind_industry_modules(slide);
},
bind_industry_modules: function (slide) {
let me = this;
slide.get_input("persona_industry").on("change", function () {
me.apply_industry_modules(slide);
});
},
apply_industry_modules: function (slide) {
let industry = slide.get_field("persona_industry").get_value();
let modules = erpnext.setup.industry_modules[industry] || ["accounting"];
["accounting", "stock", "manufacturing", "projects"].forEach(function (module) {
slide.get_field("module_" + module).set_value(modules.includes(module) ? 1 : 0);
});
},
},
{
// Organization
name: "organization",
@@ -338,24 +243,6 @@ erpnext.setup.slides_settings = [
},
];
// Modules pre-selected on the persona slide based on the chosen industry.
// Keys must match the persona_industry option values. Accounting is always on.
erpnext.setup.industry_modules = {
Manufacturing: ["accounting", "stock", "manufacturing"],
Retail: ["accounting", "stock"],
"Wholesale / Distribution": ["accounting", "stock"],
"E-commerce": ["accounting", "stock"],
"Services / Consulting": ["accounting", "projects"],
"Construction / Real Estate": ["accounting", "stock", "projects"],
"Technology / Software": ["accounting", "projects"],
Healthcare: ["accounting", "stock"],
Education: ["accounting", "projects"],
Agriculture: ["accounting", "stock"],
"Food & Beverage": ["accounting", "stock", "manufacturing"],
"Non Profit": ["accounting", "projects"],
Other: ["accounting"],
};
// Source: https://en.wikipedia.org/wiki/Fiscal_year
// default 1st Jan - 31st Dec

View File

@@ -11,7 +11,6 @@ from frappe.contacts.address_and_contact import (
delete_contact_and_address,
load_address_and_contact,
)
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.model.utils.rename_doc import update_linked_doctypes
@@ -445,7 +444,7 @@ class Customer(TransactionBase):
@frappe.whitelist()
def make_quotation(source_name: str, target_doc: str | Document | None = None) -> Document:
def make_quotation(source_name, target_doc=None):
def set_missing_values(source, target):
_set_missing_values(source, target)
@@ -458,6 +457,9 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None) -
)
target_doc.quotation_to = "Customer"
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
price_list, currency = frappe.db.get_value(
"Customer", {"name": source_name}, ["default_price_list", "default_currency"]
@@ -467,10 +469,6 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None) -
if currency:
target_doc.currency = currency
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
return target_doc

View File

@@ -5,67 +5,19 @@
import json
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import flt
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import (
get_credit_limit,
get_customer_outstanding,
make_quotation,
parse_full_name,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestCustomer(ERPNextTestSuite):
def test_quotation_from_customer_uses_actual_exchange_rate(self):
company = "_Test Company"
company_currency = frappe.get_cached_value("Company", company, "default_currency")
foreign_currency = "USD" if company_currency != "USD" else "EUR"
frappe.defaults.set_user_default("company", company)
self.addCleanup(frappe.defaults.clear_user_default, "company")
# Seed a deterministic rate so the test does not depend on the live exchange-rate API.
rate = 83.0
exchange_filters = {
"date": nowdate(),
"from_currency": foreign_currency,
"to_currency": company_currency,
}
existing = frappe.db.exists("Currency Exchange", exchange_filters)
if existing:
frappe.db.set_value("Currency Exchange", existing, "exchange_rate", rate)
else:
exchange = frappe.get_doc(
{
"doctype": "Currency Exchange",
**exchange_filters,
"exchange_rate": rate,
"for_selling": 1,
"for_buying": 1,
}
).insert()
self.addCleanup(frappe.delete_doc, "Currency Exchange", exchange.name, force=1)
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "_Test Customer FX Quotation",
"customer_type": "Company",
"default_currency": foreign_currency,
}
).insert()
self.addCleanup(frappe.delete_doc, "Customer", customer.name, force=1)
quotation = make_quotation(customer.name)
self.assertEqual(quotation.currency, foreign_currency)
self.assertNotEqual(flt(quotation.conversion_rate), 1.0)
self.assertNotEqual(flt(quotation.conversion_rate), 0.0)
self.assertEqual(flt(quotation.conversion_rate), rate)
def test_get_customer_group_details(self):
doc = frappe.new_doc("Customer Group")
doc.customer_group_name = "_Testing Customer Group"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -346,48 +346,33 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "write_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Write Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Exchange Gain / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Exchange Gain/Loss Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -514,19 +499,15 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "accumulated_depreciation_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Accumulated Depreciation Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_expense_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Depreciation Expense Account",
"no_copy": 1,
"options": "Account"
@@ -541,39 +522,29 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "disposal_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Gain/Loss Account on Asset Disposal",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Depreciation Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "capital_work_in_progress_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Capital Work In Progress Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "asset_received_but_not_billed",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Received But Not Billed",
"no_copy": 1,
"options": "Account"
},
{
@@ -705,21 +676,15 @@
"options": "Warehouse"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_profit_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Profit / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "default_discount_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Payment Discount Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -761,10 +726,8 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_received_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Received Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -773,10 +736,8 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_paid_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Paid Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -856,12 +817,9 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_for_opening",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off for Opening",
"no_copy": 1,
"options": "Account"
},
{
@@ -1012,7 +970,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2026-07-02 07:21:21.794533",
"modified": "2026-04-17 17:11:46.586135",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -80,7 +80,6 @@ class Company(NestedSet):
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None
default_purchase_price_variance_account: DF.Link | None
default_receivable_account: DF.Link | None
default_sales_contact: DF.Link | None
default_scrap_warehouse: DF.Link | None

View File

@@ -4,13 +4,12 @@
import frappe
from frappe import _
from frappe.utils.telemetry import capture
from erpnext.setup.demo import setup_demo_data
from erpnext.setup.setup_wizard.operations import install_fixtures as fixtures
def get_setup_stages(args=None): # nosemgrep
def get_setup_stages(args=None):
stages = [
{
"status": _("Installing presets"),
@@ -29,13 +28,6 @@ def get_setup_stages(args=None): # nosemgrep
{"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")},
],
},
{
"status": _("Personalizing your setup"),
"fail_msg": _("Failed to personalize your setup"),
"tasks": [
{"fn": capture_user_persona, "args": args, "fail_msg": _("Failed to personalize your setup")}
],
},
]
if args.get("setup_demo"):
@@ -50,38 +42,15 @@ def get_setup_stages(args=None): # nosemgrep
return stages
def capture_user_persona(args): # nosemgrep
"""Send the persona answers captured on the setup slide to telemetry."""
if not args:
return
capture(
"user_persona_submitted",
"erpnext",
properties={
"implementing_for": args.get("persona_implementing_for"),
"company_size": args.get("persona_company_size"),
"industry": args.get("persona_industry"),
"current_system": args.get("persona_current_system"),
"module_accounting": bool(args.get("module_accounting")),
"module_stock": bool(args.get("module_stock")),
"module_manufacturing": bool(args.get("module_manufacturing")),
"module_projects": bool(args.get("module_projects")),
"country": args.get("country"),
"language": args.get("language"),
},
)
def stage_fixtures(args): # nosemgrep
def stage_fixtures(args):
fixtures.install(args.get("country"))
def setup_company(args): # nosemgrep
def setup_company(args):
fixtures.install_company(args)
def setup_defaults(args): # nosemgrep
def setup_defaults(args):
fixtures.install_defaults(frappe._dict(args))
@@ -90,7 +59,7 @@ def setup_demo(args): # nosemgrep
# Only for programmatical use
def setup_complete(args=None): # nosemgrep
def setup_complete(args=None):
stage_fixtures(args)
setup_company(args)
setup_defaults(args)

View File

@@ -9,7 +9,6 @@ from frappe import _
from frappe.contacts.doctype.address.address import get_company_address
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder import DocType
@@ -814,9 +813,7 @@ def get_returned_qty_map(delivery_note):
@frappe.whitelist()
def make_sales_invoice(
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
):
def make_sales_invoice(source_name, target_doc=None, args=None):
if args is None:
args = {}
if isinstance(args, str):
@@ -922,12 +919,7 @@ def make_sales_invoice(
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
if doc.is_return:
# A credit note made from a return Delivery Note should roll back the billed
# amount on the linked Sales Order too, so that per_billed stays consistent with
# per_delivered (which the return already reset).
doc.update_billed_amount_in_sales_order = True
else:
if not doc.is_return:
so, doctype, fieldname = doc.get_order_details()
if (
doc.linked_order_has_payment_terms(so, fieldname, doctype)

View File

@@ -2627,92 +2627,6 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
"""Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
a credit note made from the return of that Delivery Note must reset per_billed to 0
while leaving the delivery quantities exactly as the return already set them."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
so.load_from_db()
self.assertEqual(so.per_delivered, 100)
self.assertEqual(so.per_billed, 100)
return_dn = make_sales_return(dn.name)
return_dn.insert()
return_dn.submit()
# the return reverses the delivery quantities
so.load_from_db()
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
credit_note = make_sales_invoice(return_dn.name)
self.assertTrue(credit_note.is_return)
self.assertTrue(credit_note.update_billed_amount_in_sales_order)
# A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
# credit note only rolls back billing and never re-reverses the delivery quantities.
self.assertFalse(credit_note.update_stock)
credit_note.insert()
credit_note.submit()
# per_billed is reset, and the delivery state stays exactly as the return left it
so.load_from_db()
self.assertEqual(so.per_billed, 0)
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
self.assertEqual(so.items[0].returned_qty, 0)
# Cancelling the credit note should restore the billed amount on the Sales Order.
credit_note.cancel()
so.load_from_db()
self.assertEqual(so.per_billed, 100)
def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
# Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
# The DN carries si_detail in this path.
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_delivery_note(si.name)
dn.insert()
dn.submit()
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
# SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
# SO per_billed rolls back via the status_updater in update_prevdoc_status.
from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as make_dn_from_so,
)
from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice as make_si_from_so,
)
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_dn_from_so(so.name)
dn.insert()
dn.submit()
self.assertIsNone(dn.items[0].si_detail)
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_packed_item_serial_no_status(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -225,7 +225,6 @@ class Item(Document):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
self.validate_serialized_change_with_bundle()
self.validate_item_tax_net_rate_range()
if not self.is_new():
@@ -1103,25 +1102,6 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents"))
def validate_serialized_change_with_bundle(self):
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
if self.is_new() or self.has_serial_no or not self._doc_before_save:
return
# Only relevant when the item was serialized before and is now being unset.
if not self._doc_before_save.has_serial_no:
return
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
frappe.throw(
_(
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
).format(frappe.bold(self.name)),
title=_("Serial and Batch Bundle Exists"),
)
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
linked_doctypes = [
"Delivery Note Item",

View File

@@ -1044,47 +1044,6 @@ class TestItem(ERPNextTestSuite):
msg="Different Variant UOM should not be allowed when `allow_different_uom` is disabled.",
)
def test_cannot_unset_serialized_while_bundle_exists(self):
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
make_serial_batch_bundle,
)
item = make_item(
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
).name
serial_no = f"{item}-SN-01"
frappe.get_doc(
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
).insert()
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
bundle = make_serial_batch_bundle(
{
"item_code": item,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
"qty": 1,
"rate": 100,
"voucher_type": "Stock Entry",
"serial_nos": [serial_no],
"type_of_transaction": "Inward",
"do_not_submit": True,
"ignore_sabb_validation": True,
}
)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
self.assertRaises(frappe.ValidationError, doc.save)
# Once the bundle is removed, the item can be made non-serialized.
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
doc.save()
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")

View File

@@ -1034,13 +1034,6 @@ class PurchaseReceipt(BuyingController):
return
production_plan_references = self.get_production_plan_references()
if not production_plan_references:
return
reservable_plans = self.get_reservable_production_plans(production_plan_references)
if not reservable_plans:
return
production_plan_items = []
self.reload()
@@ -1048,9 +1041,6 @@ class PurchaseReceipt(BuyingController):
for row in self.items:
if row.material_request_item and row.material_request_item in production_plan_references:
_ref = production_plan_references[row.material_request_item]
if _ref.production_plan not in reservable_plans:
continue
docnames.append(_ref.production_plan)
row.update(
{
@@ -1076,25 +1066,6 @@ class PurchaseReceipt(BuyingController):
docnames, from_doctype="Production Plan", to_doctype="Work Order"
)
def get_reservable_production_plans(self, production_plan_references) -> set:
"""Production Plans that opted into stock reservation (``reserve_stock``).
A Production Plan only gets this flag set if "Auto Reserve Stock" was enabled in
Stock Settings when it was created, or the user ticked "Reserve Stock" manually.
Without this check, a Purchase Receipt would auto-reserve stock for every
Production Plan whenever "Enable Stock Reservation" is on, ignoring both of those.
"""
plan_names = {ref.production_plan for ref in production_plan_references.values()}
return {
p.name
for p in frappe.get_all(
"Production Plan",
filters={"name": ["in", list(plan_names)]},
fields=["name", "reserve_stock"],
)
if p.reserve_stock
}
def get_production_plan_references(self):
production_plan_references = frappe._dict()
material_request_items = []

File diff suppressed because one or more lines are too long

View File

@@ -492,7 +492,7 @@ class FIFOSlots:
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
elif batch_nos and row.get("has_batch_no"):
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
self._add_to_negative_fifo_head(row, fifo_queue)
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])

View File

@@ -1434,47 +1434,6 @@ class TestStockAgeing(ERPNextTestSuite):
self.assertEqual(item_result["total_qty"], -4.0)
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
def test_untagged_receipt_with_negative_batch_head(self):
"""An incoming SLE without batch details must not treat a negative
batch slot at the queue head as a qty slot (TypeError: str += float)."""
sle = [
frappe._dict(
name="Enclosure Item",
actual_qty=-10,
qty_after_transaction=-10,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no="QI-06448",
),
frappe._dict(
name="Enclosure Item",
actual_qty=45,
qty_after_transaction=35,
stock_value_difference=1051.65,
warehouse="WH 1",
posting_date="2021-12-05",
voucher_type="Purchase Receipt",
voucher_no="002",
has_serial_no=False,
serial_no=None,
batch_no=None,
serial_and_batch_bundle="SABB-00001294",
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Enclosure Item"]["fifo_queue"]
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate

View File

@@ -20,7 +20,6 @@ SLE_FIELDS = (
"outgoing_rate",
"stock_queue",
"batch_no",
"serial_no",
"stock_value",
"stock_value_difference",
"valuation_rate",
@@ -53,16 +52,16 @@ def add_invariant_check_fields(sles, filters):
balance_qty = 0.0
balance_stock_value = 0.0
incorrect_idx = None
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
)
incorrect_idx = 0
precision = frappe.get_precision("Stock Ledger Entry", "actual_qty")
for idx, sle in enumerate(sles):
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value(
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
)
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
fifo_qty = 0.0
fifo_value = 0.0
for qty, rate in queue:
fifo_qty += qty
fifo_value += qty * rate
if sle.actual_qty < 0:
sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
@@ -78,67 +77,57 @@ def add_invariant_check_fields(sles, filters):
if balance_qty is None:
balance_qty = sle.qty_after_transaction
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.balance_value_by_qty = (
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
)
sle.expected_qty_after_transaction = balance_qty
sle.stock_value_from_diff = balance_stock_value
# set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
sle.fifo_value_diff = sle.stock_value - fifo_value
sle.fifo_valuation_diff = (
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
)
sle.valuation_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
)
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
if maintains_fifo_queue(sle):
add_fifo_fields(sle, sles[idx - 1] if idx else None)
if not incorrect_idx and filters.get("show_incorrect_entries"):
if is_sle_has_correct_data(sle, precision):
continue
else:
incorrect_idx = idx
if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
incorrect_idx = idx
if idx > 0:
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value(
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
)
if filters.get("show_incorrect_entries"):
if incorrect_idx is None:
return []
return sles[max(incorrect_idx - 1, 0) :]
if incorrect_idx > 0:
sles = sles[cint(incorrect_idx) - 1 :]
return []
return sles
def maintains_fifo_queue(sle):
# no queue is maintained for serialized/batchwise-valued stock
return not (
sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
)
def is_sle_has_correct_data(sle, precision):
if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0:
print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision))
return False
def add_fifo_fields(sle, prev_sle):
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
fifo_qty = 0.0
fifo_value = 0.0
for qty, rate in queue:
fifo_qty += qty
fifo_value += qty * rate
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
sle.fifo_value_diff = sle.stock_value - fifo_value
sle.fifo_valuation_diff = (
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
)
# prev row may not maintain a queue; H and H - F stay blank across the gap
if prev_sle and prev_sle.fifo_stock_value is not None:
sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
def is_sle_has_correct_data(sle, float_precision, currency_precision):
return (
flt(sle.difference_in_qty, float_precision) == 0.0
and flt(sle.diff_value_diff, currency_precision) == 0.0
)
return True
def get_columns():

View File

@@ -1,76 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "Stores - _TC"
COMPANY = "_Test Company"
ITEM = "_Test Item"
class TestStockLedgerInvariantCheck(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE})
filters.update(extra)
return execute(filters)[1]
def make_movements(self) -> str:
frappe.db.set_value("Item", ITEM, "valuation_method", "FIFO")
make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01")
make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02")
make_stock_entry(item_code=ITEM, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03")
return ITEM
def test_diagnostic_rows_have_no_discrepancy(self):
item = self.make_movements()
data = self.run_report(item_code=item)
self.assertEqual(len(data), 3)
for row in data:
self.assertLess(abs(row.difference_in_qty), 0.01)
self.assertLess(abs(row.fifo_qty_diff), 0.01)
self.assertLess(abs(row.diff_value_diff), 0.01)
def test_running_balance_matches(self):
item = self.make_movements()
data = self.run_report(item_code=item)
self.assertEqual(data[-1].qty_after_transaction, 11)
def test_show_incorrect_entries(self):
item = self.make_movements()
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
sle = frappe.get_last_doc(
"Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5
)
data = self.run_report(item_code=item, show_incorrect_entries=1)
self.assertEqual(len(data), 2) # incorrect entry + one before it for context
self.assertEqual(data[-1].name, sle.name)
def test_batch_item_skips_fifo_queue_checks(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(
properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"}
).name
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
data = self.run_report(item_code=item)
self.assertTrue(data)
for row in data:
self.assertIsNone(row.fifo_qty_diff)
self.assertIsNone(row.fifo_value_diff)
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])

View File

@@ -205,10 +205,7 @@ def get_data(filters=None):
data = []
if item_warehouse_map:
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
)
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
for item_warehouse in item_warehouse_map:
report_data = stock_ledger_invariant_check(item_warehouse)
@@ -218,11 +215,7 @@ def get_data(filters=None):
for row in report_data:
if has_difference(
row,
float_precision,
currency_precision,
filters.difference_in,
item_warehouse.valuation_method or valuation_method,
row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method
):
row.update(
{
@@ -268,26 +261,23 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
return query.run(as_dict=1)
def has_difference(row, float_precision, currency_precision, difference_in, valuation_method):
def has_difference(row, precision, difference_in, valuation_method):
if valuation_method == "Moving Average":
qty_diff = flt(row.difference_in_qty, float_precision)
value_diff = flt(row.diff_value_diff, currency_precision)
valuation_diff = flt(row.valuation_diff, currency_precision)
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
valuation_diff = flt(row.valuation_diff, precision)
else:
qty_diff = flt(row.difference_in_qty, float_precision)
value_diff = flt(row.diff_value_diff, currency_precision)
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
if row.stock_queue and json.loads(row.stock_queue):
value_diff = value_diff or (
flt(row.fifo_value_diff, currency_precision)
or flt(row.fifo_difference_diff, currency_precision)
flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision)
)
qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision)
qty_diff = qty_diff or flt(row.fifo_qty_diff, precision)
valuation_diff = flt(row.valuation_diff, currency_precision) or flt(
row.fifo_valuation_diff, currency_precision
)
valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision)
if difference_in == "Qty" and qty_diff:
return True
@@ -297,5 +287,3 @@ def has_difference(row, float_precision, currency_precision, difference_in, valu
return True
elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff):
return True
return False