mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-01 14:58:37 +00:00
Compare commits
9 Commits
v16.26.1
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9aa4491f1 | ||
|
|
19c318df68 | ||
|
|
2cb577b912 | ||
|
|
53c17bf331 | ||
|
|
e083d195cf | ||
|
|
7f1ef59dc5 | ||
|
|
710e021638 | ||
|
|
224cf19f33 | ||
|
|
40ca3b5e5d |
@@ -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.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"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.15.0",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-router": "^8.1.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.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@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.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
"typescript-eslint": "^8.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -26,6 +25,7 @@ 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>
|
||||
<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>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
@@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
|
||||
})
|
||||
} else {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
|
||||
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -19,6 +18,7 @@ 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>
|
||||
<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>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -23,6 +22,7 @@ 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">
|
||||
<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>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -18,6 +17,7 @@ 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>
|
||||
<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.")
|
||||
}} />
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<MarkdownRenderer content={entriesContent} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</Paragraph>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -445,11 +446,10 @@ 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 = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
|
||||
@@ -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-dom'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
26
banking/src/lib/amountFormula.ts
Normal file
26
banking/src/lib/amountFormula.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
2655
banking/yarn.lock
2655
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -829,7 +829,9 @@ 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")
|
||||
float_amount = get_float_amount(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)
|
||||
if "cr" in amount.lower():
|
||||
return 0, float_amount
|
||||
else:
|
||||
@@ -932,14 +934,18 @@ 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 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"),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
|
||||
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
|
||||
tables = []
|
||||
|
||||
@@ -9,6 +9,48 @@ 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
|
||||
@@ -86,6 +128,11 @@ 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:
|
||||
|
||||
@@ -231,3 +231,45 @@ 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()
|
||||
|
||||
@@ -46,6 +46,42 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
|
||||
)
|
||||
from erpnext.stock.stock_ledger import get_items_to_be_repost
|
||||
|
||||
# Purposes whose inward (t_warehouse) row is inspected.
|
||||
QI_INCOMING_PURPOSES = (
|
||||
"Material Receipt",
|
||||
"Repack",
|
||||
"Receive from Customer",
|
||||
"Subcontracting Return",
|
||||
)
|
||||
|
||||
# Purposes whose outgoing (s_warehouse) row is inspected. This is an explicit
|
||||
# allow-list rather than "everything that isn't incoming" so a new purpose can't
|
||||
# silently start requiring a QI. Material Consumption for Manufacture is left out
|
||||
# on purpose: an inspection_required BOM inspects the manufactured output (handled
|
||||
# by the "Manufacture" finished-good rule), not each consumed raw material.
|
||||
# Keep this in sync with erpnext.stock.qi_* helpers in transaction.js.
|
||||
QI_OUTGOING_PURPOSES = (
|
||||
"Material Issue",
|
||||
"Material Transfer",
|
||||
"Material Transfer for Manufacture",
|
||||
"Send to Subcontractor",
|
||||
"Subcontracting Delivery",
|
||||
"Disassemble",
|
||||
)
|
||||
|
||||
|
||||
def stock_entry_row_requires_inspection(purpose, row):
|
||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
return False
|
||||
if purpose == "Manufacture":
|
||||
return bool(row.is_finished_item)
|
||||
if purpose in QI_INCOMING_PURPOSES:
|
||||
return bool(row.t_warehouse)
|
||||
if purpose in QI_OUTGOING_PURPOSES:
|
||||
return bool(row.s_warehouse and row.s_warehouse != row.t_warehouse)
|
||||
return False
|
||||
|
||||
|
||||
class StockController(AccountsController):
|
||||
def validate(self):
|
||||
@@ -1477,8 +1513,8 @@ class StockController(AccountsController):
|
||||
"Item", row.item_code, inspection_required_fieldname
|
||||
):
|
||||
qi_required = True
|
||||
elif self.doctype == "Stock Entry" and row.t_warehouse:
|
||||
qi_required = True # inward stock needs inspection
|
||||
elif self.doctype == "Stock Entry":
|
||||
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
|
||||
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
// Keep these in sync with QI_INCOMING_PURPOSES / QI_OUTGOING_PURPOSES /
|
||||
// stock_entry_row_requires_inspection in controllers/stock_controller.py.
|
||||
erpnext.stock = erpnext.stock || {};
|
||||
erpnext.stock.qi_incoming_purposes = [
|
||||
"Material Receipt",
|
||||
"Repack",
|
||||
"Receive from Customer",
|
||||
"Subcontracting Return",
|
||||
];
|
||||
erpnext.stock.qi_outgoing_purposes = [
|
||||
"Material Issue",
|
||||
"Material Transfer",
|
||||
"Material Transfer for Manufacture",
|
||||
"Send to Subcontractor",
|
||||
"Subcontracting Delivery",
|
||||
"Disassemble",
|
||||
];
|
||||
erpnext.stock.is_incoming_qi_purpose = (purpose) =>
|
||||
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
|
||||
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
|
||||
if (row.type || row.is_legacy_scrap_item) return false;
|
||||
if (purpose === "Manufacture") return !!row.is_finished_item;
|
||||
if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse;
|
||||
if (erpnext.stock.qi_outgoing_purposes.includes(purpose))
|
||||
return !!row.s_warehouse && row.s_warehouse !== row.t_warehouse;
|
||||
return false;
|
||||
};
|
||||
|
||||
erpnext.TransactionController = class TransactionController extends erpnext.taxes_and_totals {
|
||||
setup() {
|
||||
super.setup();
|
||||
@@ -408,13 +436,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
);
|
||||
}
|
||||
|
||||
const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
|
||||
const incoming_purposes = ["Manufacture", "Material Receipt"];
|
||||
const inspection_type =
|
||||
incoming_doctypes.includes(this.frm.doc.doctype) ||
|
||||
(this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose))
|
||||
? "Incoming"
|
||||
: "Outgoing";
|
||||
const inspection_type = this.quality_inspection_type();
|
||||
|
||||
let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection");
|
||||
quality_inspection_field.get_route_options_for_new_doc = function (row) {
|
||||
@@ -2901,13 +2923,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
];
|
||||
|
||||
const me = this;
|
||||
const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
|
||||
const incoming_purposes = ["Manufacture", "Material Receipt"];
|
||||
const inspection_type =
|
||||
incoming_doctypes.includes(this.frm.doc.doctype) ||
|
||||
(this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose))
|
||||
? "Incoming"
|
||||
: "Outgoing";
|
||||
const inspection_type = this.quality_inspection_type();
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Select Items for Quality Inspection"),
|
||||
size: "extra-large",
|
||||
@@ -2999,14 +3015,23 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
});
|
||||
}
|
||||
|
||||
quality_inspection_type() {
|
||||
const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
|
||||
const is_incoming =
|
||||
incoming_doctypes.includes(this.frm.doc.doctype) ||
|
||||
(this.frm.doc.doctype === "Stock Entry" &&
|
||||
erpnext.stock.is_incoming_qi_purpose(this.frm.doc.purpose));
|
||||
return is_incoming ? "Incoming" : "Outgoing";
|
||||
}
|
||||
|
||||
has_inspection_required(item) {
|
||||
if (this.frm.doc.doctype === "Stock Entry" && this.frm.doc.purpose == "Manufacture") {
|
||||
if (item.is_finished_item && !item.quality_inspection) {
|
||||
return true;
|
||||
}
|
||||
} else if (!item.quality_inspection) {
|
||||
if (item.quality_inspection) {
|
||||
return false;
|
||||
}
|
||||
if (this.frm.doc.doctype !== "Stock Entry") {
|
||||
return true;
|
||||
}
|
||||
return erpnext.stock.row_requires_quality_inspection(this.frm.doc.purpose, item);
|
||||
}
|
||||
|
||||
get_method_for_payment() {
|
||||
|
||||
@@ -19,6 +19,101 @@ 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: ["", "1–10", "11–50", "51–200", "201–1,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",
|
||||
@@ -243,6 +338,24 @@ 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
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
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):
|
||||
def get_setup_stages(args=None): # nosemgrep
|
||||
stages = [
|
||||
{
|
||||
"status": _("Installing presets"),
|
||||
@@ -28,6 +29,13 @@ def get_setup_stages(args=None):
|
||||
{"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"):
|
||||
@@ -42,15 +50,38 @@ def get_setup_stages(args=None):
|
||||
return stages
|
||||
|
||||
|
||||
def stage_fixtures(args):
|
||||
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
|
||||
fixtures.install(args.get("country"))
|
||||
|
||||
|
||||
def setup_company(args):
|
||||
def setup_company(args): # nosemgrep
|
||||
fixtures.install_company(args)
|
||||
|
||||
|
||||
def setup_defaults(args):
|
||||
def setup_defaults(args): # nosemgrep
|
||||
fixtures.install_defaults(frappe._dict(args))
|
||||
|
||||
|
||||
@@ -59,7 +90,7 @@ def setup_demo(args): # nosemgrep
|
||||
|
||||
|
||||
# Only for programmatical use
|
||||
def setup_complete(args=None):
|
||||
def setup_complete(args=None): # nosemgrep
|
||||
stage_fixtures(args)
|
||||
setup_company(args)
|
||||
setup_defaults(args)
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -813,7 +814,9 @@ def get_returned_qty_map(delivery_note):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(source_name, target_doc=None, args=None):
|
||||
def make_sales_invoice(
|
||||
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
|
||||
):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
@@ -919,7 +922,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
|
||||
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
|
||||
if not doc.is_return:
|
||||
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:
|
||||
so, doctype, fieldname = doc.get_order_details()
|
||||
if (
|
||||
doc.linked_order_has_payment_terms(so, fieldname, doctype)
|
||||
|
||||
@@ -2627,6 +2627,92 @@ 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
|
||||
|
||||
@@ -8,6 +8,10 @@ from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import cint, cstr, flt, get_link_to_form, get_number_format_info
|
||||
|
||||
from erpnext.controllers.stock_controller import (
|
||||
QI_INCOMING_PURPOSES,
|
||||
QI_OUTGOING_PURPOSES,
|
||||
)
|
||||
from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import (
|
||||
get_template_details,
|
||||
)
|
||||
@@ -385,13 +389,43 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
["items.quality_inspection", "is", "not set"],
|
||||
]
|
||||
|
||||
require_distinct_warehouse = False
|
||||
|
||||
if reference_doctype == "Stock Entry":
|
||||
purpose = frappe.get_cached_value("Stock Entry", filters.get("reference_name"), "purpose")
|
||||
my_filters.extend(
|
||||
[
|
||||
"and",
|
||||
["items.t_warehouse", "is", "not set"],
|
||||
["items.type", "is", "not set"],
|
||||
"and",
|
||||
["items.is_legacy_scrap_item", "=", 0],
|
||||
]
|
||||
)
|
||||
if purpose == "Manufacture":
|
||||
my_filters.extend(
|
||||
[
|
||||
"and",
|
||||
["items.is_finished_item", "=", 1],
|
||||
]
|
||||
)
|
||||
elif purpose in QI_INCOMING_PURPOSES:
|
||||
my_filters.extend(
|
||||
[
|
||||
"and",
|
||||
["items.t_warehouse", "is", "set"],
|
||||
]
|
||||
)
|
||||
elif purpose in QI_OUTGOING_PURPOSES:
|
||||
my_filters.extend(
|
||||
[
|
||||
"and",
|
||||
["items.s_warehouse", "is", "set"],
|
||||
]
|
||||
)
|
||||
require_distinct_warehouse = True
|
||||
else:
|
||||
# purpose requires no quality inspection
|
||||
return []
|
||||
elif filters.get("inspection_type") != "In Process":
|
||||
my_filters.extend(
|
||||
[
|
||||
@@ -412,7 +446,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
]
|
||||
)
|
||||
|
||||
return frappe.get_query(
|
||||
query = frappe.get_query(
|
||||
reference_doctype,
|
||||
fields=["items.item_code, items.item_name"],
|
||||
filters=my_filters,
|
||||
@@ -421,7 +455,15 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
order_by="items.item_code",
|
||||
ignore_permissions=False,
|
||||
distinct=True,
|
||||
).run()
|
||||
)
|
||||
if require_distinct_warehouse:
|
||||
# The cross-column guard (s_warehouse != t_warehouse) can't be expressed in frappe's
|
||||
# filter-list syntax, so it is appended as a raw query-builder condition. This relies on
|
||||
# the "items.s_warehouse" filter above having already LEFT-JOINed the child table, so
|
||||
# child.t_warehouse references that same joined table.
|
||||
child = frappe.qb.DocType(frappe.get_meta(reference_doctype).get_field("items").options)
|
||||
query = query.where(child.t_warehouse.isnull() | (child.s_warehouse != child.t_warehouse))
|
||||
return query.run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -199,6 +199,10 @@ frappe.ui.form.on("Stock Entry", {
|
||||
},
|
||||
|
||||
setup_quality_inspection: function (frm) {
|
||||
frm.get_docfield("items", "quality_inspection").depends_on = (row) =>
|
||||
frm.doc.inspection_required &&
|
||||
erpnext.stock.row_requires_quality_inspection(frm.doc.purpose, row);
|
||||
|
||||
if (!frm.doc.inspection_required) {
|
||||
return;
|
||||
}
|
||||
@@ -216,11 +220,12 @@ frappe.ui.form.on("Stock Entry", {
|
||||
}
|
||||
|
||||
let quality_inspection_field = frm.get_docfield("items", "quality_inspection");
|
||||
const incoming_purposes = ["Manufacture", "Material Receipt"];
|
||||
quality_inspection_field.get_route_options_for_new_doc = function (row) {
|
||||
if (frm.is_new()) return {};
|
||||
return {
|
||||
inspection_type: incoming_purposes.includes(frm.doc.purpose) ? "Incoming" : "Outgoing",
|
||||
inspection_type: erpnext.stock.is_incoming_qi_purpose(frm.doc.purpose)
|
||||
? "Incoming"
|
||||
: "Outgoing",
|
||||
reference_type: frm.doc.doctype,
|
||||
reference_name: frm.doc.name,
|
||||
child_row_reference: row.doc.name,
|
||||
|
||||
@@ -1174,16 +1174,21 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
# stock the source warehouse for transfer / issue purposes
|
||||
make_stock_entry(item_code=item_code, target=s_wh, qty=100, basic_rate=100)
|
||||
|
||||
# purpose -> warehouses for the moved row; inward (with target) requires QI
|
||||
# purpose -> warehouses for the moved row and the direction QI is required on:
|
||||
# Material Receipt inspects the inward row, Transfer/Issue inspect the outgoing row.
|
||||
purposes = {
|
||||
"Material Receipt": {"to_warehouse": t_wh},
|
||||
"Material Transfer": {"from_warehouse": s_wh, "to_warehouse": t_wh},
|
||||
"Material Issue": {"from_warehouse": s_wh},
|
||||
"Material Receipt": {"warehouses": {"to_warehouse": t_wh}, "inspection_type": "Incoming"},
|
||||
"Material Transfer": {
|
||||
"warehouses": {"from_warehouse": s_wh, "to_warehouse": t_wh},
|
||||
"inspection_type": "Outgoing",
|
||||
},
|
||||
"Material Issue": {"warehouses": {"from_warehouse": s_wh}, "inspection_type": "Outgoing"},
|
||||
}
|
||||
|
||||
for purpose, warehouses in purposes.items():
|
||||
for purpose, config in purposes.items():
|
||||
with self.subTest(purpose=purpose):
|
||||
needs_qi = "to_warehouse" in warehouses
|
||||
warehouses = config["warehouses"]
|
||||
inspection_type = config["inspection_type"]
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
@@ -1199,13 +1204,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
allowed = check_item_quality_inspection("Stock Entry", 0, se.as_dict().get("items"))
|
||||
self.assertTrue(any(row.get("item_code") == item_code for row in allowed))
|
||||
|
||||
if not needs_qi:
|
||||
# outward-only entry: QI is not enforced
|
||||
se.submit()
|
||||
self.assertEqual(se.docstatus, 1)
|
||||
continue
|
||||
|
||||
# inward entry without QI must block submission
|
||||
# entry without QI must block submission
|
||||
self.assertRaises(QualityInspectionRequiredError, se.submit)
|
||||
|
||||
# a rejected QI must also block submission
|
||||
@@ -1222,13 +1221,13 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
reference_type="Stock Entry",
|
||||
reference_name=se_rej.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Incoming",
|
||||
inspection_type=inspection_type,
|
||||
status="Rejected",
|
||||
)
|
||||
se_rej.reload()
|
||||
self.assertRaises(QualityInspectionRejectedError, se_rej.submit)
|
||||
|
||||
# a submitted, accepted QI links itself to the inward row; submission then succeeds
|
||||
# a submitted, accepted QI links itself to the inspected row; submission then succeeds
|
||||
se_ok = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=5,
|
||||
@@ -1242,7 +1241,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
reference_type="Stock Entry",
|
||||
reference_name=se_ok.name,
|
||||
item_code=item_code,
|
||||
inspection_type="Incoming",
|
||||
inspection_type=inspection_type,
|
||||
status="Accepted",
|
||||
)
|
||||
se_ok.reload()
|
||||
@@ -1425,15 +1424,15 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
row.s_warehouse = source_warehouse
|
||||
mfg.submit()
|
||||
|
||||
# disassemble with inspection required -> the component rows need a QI
|
||||
# disassemble with inspection required -> the consumed (outgoing) rows need a QI
|
||||
dis = frappe.get_doc(make_wo_stock_entry(wo.name, "Disassemble", 1))
|
||||
dis.inspection_required = 1
|
||||
dis.insert()
|
||||
self.assertRaises(QualityInspectionRequiredError, dis.submit)
|
||||
|
||||
# a rejected QI on any disassembled component row must also block submission
|
||||
# a rejected QI on any consumed (outgoing) row must also block submission
|
||||
qis = []
|
||||
for item_code in {row.item_code for row in dis.items if row.t_warehouse}:
|
||||
for item_code in {row.item_code for row in dis.items if row.s_warehouse}:
|
||||
qis.append(
|
||||
create_quality_inspection(
|
||||
reference_type="Stock Entry",
|
||||
@@ -2830,6 +2829,44 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit()
|
||||
frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit()
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Manufacturing Settings",
|
||||
{"material_consumption": 1, "backflush_raw_materials_based_on": "BOM"},
|
||||
)
|
||||
def test_qi_not_required_for_material_consumption_for_manufacture(self):
|
||||
"""An inspection_required BOM inspects the finished good (the Manufacture rule),
|
||||
not each consumed raw material, so Material Consumption for Manufacture (whose
|
||||
rows are outgoing only) must still submit without a Quality Inspection."""
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as _make_stock_entry
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
|
||||
|
||||
fg_item = make_item("_Test QI Consumption FG", properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item("_Test QI Consumption RM", properties={"is_stock_item": 1}).name
|
||||
warehouse = "Stores - WP"
|
||||
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_submit=True)
|
||||
bom.inspection_required = 1
|
||||
bom.submit()
|
||||
|
||||
se = make_stock_entry(item_code=rm_item, target=warehouse, qty=5, rate=10, purpose="Material Receipt")
|
||||
|
||||
work_order = make_work_order(bom.name, fg_item, 5)
|
||||
work_order.company = se.company
|
||||
work_order.skip_transfer = 1
|
||||
work_order.source_warehouse = warehouse
|
||||
work_order.fg_warehouse = warehouse
|
||||
work_order.submit()
|
||||
|
||||
consumption = frappe.get_doc(
|
||||
_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)
|
||||
)
|
||||
# the mapper copies inspection_required from the BOM ...
|
||||
self.assertEqual(consumption.inspection_required, 1)
|
||||
# ... but the consumed rows are outgoing-only, so no QI is required and submit succeeds
|
||||
consumption.submit()
|
||||
self.assertEqual(consumption.docstatus, 1)
|
||||
|
||||
def test_qi_creation_with_naming_rule_company_condition(self):
|
||||
"""
|
||||
Unit test case to check the document naming rule with company condition
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
"options": "Batch"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:parent.inspection_required && doc.t_warehouse",
|
||||
"depends_on": "eval:parent.inspection_required",
|
||||
"fieldname": "quality_inspection",
|
||||
"fieldtype": "Link",
|
||||
"label": "Quality Inspection",
|
||||
@@ -679,7 +679,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-27 11:40:38.294196",
|
||||
"modified": "2026-06-30 12:18:34.132425",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
|
||||
Reference in New Issue
Block a user