Compare commits

..

5 Commits

Author SHA1 Message Date
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
37 changed files with 1556 additions and 2227 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.1"
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

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

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

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

@@ -777,141 +777,62 @@ $.extend(erpnext.item, {
function make_fields_from_attribute_values(attr_dict) {
let fields = [];
let attributes = frm.doc.attributes.filter((row) => !row.disabled);
attributes.forEach((row, i) => {
let name = row.attribute;
let att_key = frm.doc.attributes.map((idx) => idx.attribute);
att_key.forEach((name, i) => {
if (i % 3 === 0) {
fields.push({ fieldtype: "Section Break" });
}
fields.push({ fieldtype: "Column Break" });
fields.push({ fieldtype: "Column Break", label: name });
fields.push({
fieldtype: "MultiSelectPills",
label: name,
fieldname: frappe.scrub(name),
placeholder: __("Search values..."),
get_data: (txt) => get_attribute_suggestions(attr_dict[name], txt),
onchange: update_primary_action,
fieldtype: "Data",
placeholder: "Search",
fieldname: `search_${frappe.scrub(name)}`,
onchange: function (e) {
let value = e.target.value;
let result = attr_dict[name].filter((attr_value) =>
attr_value.toString().toLowerCase().includes(value.toLowerCase())
);
attr_dict[name].forEach((attr_value) => {
if (result.includes(attr_value)) {
me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 0);
} else {
me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 1);
}
});
},
});
attr_dict[name].forEach((value) => {
fields.push({
fieldtype: "Check",
label: value,
fieldname: value,
default: 0,
onchange: function () {
let selected_attributes = get_selected_attributes();
let lengths = Object.keys(selected_attributes).map((key) => {
return selected_attributes[key].length;
});
if (!lengths.length) {
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
me.multiple_variant_dialog.disable_primary_action();
} else {
let no_of_combinations = lengths.reduce((a, b) => a * b, 1);
let msg;
if (no_of_combinations === 1) {
msg = __("Make {0} Variant", [no_of_combinations]);
} else {
msg = __("Make {0} Variants", [no_of_combinations]);
}
me.multiple_variant_dialog.get_primary_btn().html(msg);
me.multiple_variant_dialog.enable_primary_action();
}
},
});
});
});
return fields;
}
function get_attribute_suggestions(spec, txt) {
if (!spec) return [];
return Array.isArray(spec) ? filter_list(spec, txt) : numeric_suggestions(spec, txt);
}
// Cap matches so a long value list never hands everything to Awesomplete,
// which would freeze the browser.
function filter_list(values, txt) {
txt = (txt || "").toLowerCase();
let matches = [];
for (let value of values) {
if (!txt || value.toLowerCase().includes(txt)) {
matches.push(value);
if (matches.length >= 50) break;
}
}
return matches;
}
// Numeric ranges aren't enumerated. With no input, preview the first few
// values; once the user types, accept it only if it lies on the increment
// within [from, to]. Both paths are cheap even for huge ranges.
function numeric_suggestions(range, txt) {
let { from_range: from, to_range: to, increment } = range;
if (!(increment > 0) || from > to) return [];
txt = (txt || "").trim();
if (!txt) {
let preview = [];
for (
let value = from;
value <= to && preview.length < 50;
value = flt(value + increment, 6)
) {
preview.push(String(value));
}
return preview;
}
return is_valid_attribute_value(range, txt) ? [String(flt(txt, 6))] : [];
}
function is_valid_attribute_value(spec, value) {
if (!spec || !value) return false;
if (Array.isArray(spec)) return spec.includes(value);
let { from_range: from, to_range: to, increment } = spec;
if (!(increment > 0)) return false;
// Reject anything that isn't cleanly a number ("abc", "5000xyz", "");
// flt would coerce these to 0 and wrongly accept them.
let text = String(value).trim();
let num = Number(text);
if (text === "" || !Number.isFinite(num)) return false;
if (num < from || num > to) return false;
let steps = (num - from) / increment;
return Math.abs(Math.round(steps) - steps) <= 1e-6;
}
// Block variant creation if anything is wrong: an invalid committed pill, or
// text typed but not added as a pill (which get_selected_attributes would
// otherwise drop silently). The user must fix each before creation proceeds.
function validate_selected_attributes() {
let errors = [];
frm.doc.attributes.forEach((row) => {
if (row.disabled) return;
let field = me.multiple_variant_dialog.get_field(frappe.scrub(row.attribute));
if (!field) return;
let attribute = frappe.utils.escape_html(row.attribute);
let spec = attr_val_fields[row.attribute];
let invalid = [
...new Set((field.get_value() || []).filter((v) => !is_valid_attribute_value(spec, v))),
];
if (invalid.length) {
let values = invalid.map(frappe.utils.escape_html).join(", ");
errors.push(__("{0}: remove invalid value(s) {1}", [attribute, values]));
}
let pending = (field.$input?.val() || "").trim();
if (pending) {
let value = frappe.utils.escape_html(pending);
errors.push(
__("{0}: select the typed value {1} from the list or clear it", [attribute, value])
);
}
});
if (errors.length) {
frappe.throw({
title: __("Invalid Attribute Values"),
message: errors.join("<br>"),
indicator: "red",
});
}
}
function update_primary_action() {
let selected_attributes = get_selected_attributes();
let counts = Object.keys(selected_attributes).map((key) => selected_attributes[key].length);
if (!counts.length) {
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
me.multiple_variant_dialog.disable_primary_action();
} else {
let no_of_combinations = counts.reduce((a, b) => a * b, 1);
let msg =
no_of_combinations === 1
? __("Make {0} Variant", [no_of_combinations])
: __("Make {0} Variants", [no_of_combinations]);
me.multiple_variant_dialog.get_primary_btn().html(msg);
me.multiple_variant_dialog.enable_primary_action();
}
}
function make_and_show_dialog(fields) {
me.multiple_variant_dialog = new frappe.ui.Dialog({
title: __("Select Attribute Values"),
@@ -937,8 +858,6 @@ $.extend(erpnext.item, {
});
me.multiple_variant_dialog.set_primary_action(__("Create Variants"), () => {
validate_selected_attributes();
let selected_attributes = get_selected_attributes();
let use_template_image = me.multiple_variant_dialog.get_value("use_template_image");
@@ -966,70 +885,72 @@ $.extend(erpnext.item, {
});
});
$($(me.multiple_variant_dialog.$wrapper.find(".form-column")).find(".frappe-control")).css(
"margin-bottom",
"0px"
);
me.multiple_variant_dialog.disable_primary_action();
me.multiple_variant_dialog.clear();
me.multiple_variant_dialog.show();
me.multiple_variant_dialog.$wrapper
.find("div[data-fieldname^='search_']")
.find(".clearfix")
.hide();
}
function get_selected_attributes() {
let selected_attributes = {};
frm.doc.attributes.forEach((row) => {
if (row.disabled) return;
let values = me.multiple_variant_dialog.get_value(frappe.scrub(row.attribute));
if (values && values.length) {
selected_attributes[row.attribute] = values;
me.multiple_variant_dialog.$wrapper.find(".form-column").each((i, col) => {
if (i === 0) return;
let attribute_name = $(col).find(".column-label").html().trim();
selected_attributes[attribute_name] = [];
let checked_opts = $(col).find(".checkbox input");
checked_opts.each((i, opt) => {
if ($(opt).is(":checked")) {
selected_attributes[attribute_name].push($(opt).attr("data-fieldname"));
}
});
if (!selected_attributes[attribute_name].length) {
delete selected_attributes[attribute_name];
}
});
return selected_attributes;
}
frm.doc.attributes.forEach(function (d) {
if (!d.disabled) {
let p = new Promise((resolve) => {
// Read the numeric configuration from the Item Attribute master
// instead of the variant attribute row, which may be stale or
// blank if the attribute was made numeric after it was added here.
frappe.db
.get_value("Item Attribute", d.attribute, [
"numeric_values",
"from_range",
"to_range",
"increment",
])
.then((res) => {
let attr = res.message || {};
if (!attr.numeric_values) {
frappe
.call({
method: "frappe.client.get_list",
args: {
doctype: "Item Attribute Value",
filters: [["parent", "=", d.attribute]],
fields: ["attribute_value"],
limit_page_length: 0,
parent: "Item Attribute",
order_by: "idx",
},
})
.then((r) => {
attr_val_fields[d.attribute] = (r.message || []).map(
(row) => row.attribute_value
);
resolve();
if (!d.numeric_values) {
frappe
.call({
method: "frappe.client.get_list",
args: {
doctype: "Item Attribute Value",
filters: [["parent", "=", d.attribute]],
fields: ["attribute_value"],
limit_page_length: 0,
parent: "Item Attribute",
order_by: "idx",
},
})
.then((r) => {
if (r.message) {
attr_val_fields[d.attribute] = r.message.map(function (d) {
return d.attribute_value;
});
} else {
// Store the range instead of enumerating it; a large range
// (e.g. 1-100000) is slow to build and to search. Values are
// validated against the range on demand while typing.
attr_val_fields[d.attribute] = {
from_range: flt(attr.from_range),
to_range: flt(attr.to_range),
increment: flt(attr.increment),
};
resolve();
}
});
resolve();
}
});
} else {
let values = [];
for (var i = d.from_range; i <= d.to_range; i = flt(i + d.increment, 6)) {
values.push(i);
}
attr_val_fields[d.attribute] = values;
resolve();
}
});
promises.push(p);

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

@@ -1,13 +1,4 @@
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Item Attribute", {
numeric_values(frm) {
// Numeric attributes have no discrete values; drop the rows so their
// mandatory Attribute Value / Abbreviation don't block the save.
if (frm.doc.numeric_values) {
frm.clear_table("item_attribute_values");
frm.refresh_field("item_attribute_values");
}
},
});
frappe.ui.form.on("Item Attribute", {});

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