mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-02 21:26:55 +00:00
Compare commits
5 Commits
version-16
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd00cebbd2 | ||
|
|
fe6c276e72 | ||
|
|
613b3c16c2 | ||
|
|
666becc670 | ||
|
|
628b932d55 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
2617
banking/yarn.lock
2617
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ["", "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",
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user