mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-01 06:48:36 +00:00
Compare commits
2 Commits
develop
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
286ac77a05 | ||
|
|
ddb07bcc0a |
39
.github/POSTGRES_COMPATIBILITY.md
vendored
39
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -45,9 +45,7 @@ Flag a changed query that uses any of these:
|
||||
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
|
||||
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
|
||||
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
|
||||
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select
|
||||
**only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the
|
||||
MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead.
|
||||
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
|
||||
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
|
||||
unquoted (or double-quoted) alias.
|
||||
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
|
||||
@@ -121,7 +119,7 @@ These don't error, so a one-engine CI stays green. Flag them:
|
||||
|
||||
---
|
||||
|
||||
## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (the single most important rule)
|
||||
## 3. The `GROUP BY` row-count trap (the single most important rule)
|
||||
|
||||
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
|
||||
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
|
||||
@@ -142,14 +140,6 @@ versa) to make a number "more correct" — that changes the MariaDB value. The w
|
||||
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
|
||||
scope for a portability fix.
|
||||
|
||||
**The same trap applies to `SELECT DISTINCT`.** To satisfy PostgreSQL's "an `ORDER BY` expr must
|
||||
appear in the select list under `DISTINCT`" rule, **do not blindly add the ordered column to the
|
||||
select** — if it is not single-valued per existing distinct row, the `DISTINCT` key grows and
|
||||
MariaDB returns **more rows** (a regression), exactly as adding a non-FD column to `GROUP BY` does.
|
||||
Add it only when it is functionally dependent on the existing select columns; otherwise drop the
|
||||
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
|
||||
unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. False positives — do NOT flag these
|
||||
@@ -180,31 +170,14 @@ These are auto-handled by the framework and are **not** breaks:
|
||||
|
||||
---
|
||||
|
||||
## 6. Refactors and raw-SQL→ORM conversions are not automatically 1:1
|
||||
|
||||
A commit labeled a **refactor** or a **raw-`frappe.db.sql` → `frappe.qb`/ORM conversion** is meant
|
||||
to preserve behaviour — but it easily doesn't, and the change passes the static checker and a
|
||||
one-engine green run. **Diff the `WHERE`/predicate, the `JOIN`/`ON` conditions, and the resulting
|
||||
row set — not just the `SELECT` shape.** A conversion that silently widens or narrows the filter
|
||||
changes the rows touched on **both** engines and is a regression hiding under a "refactor" label.
|
||||
|
||||
Real example: an `UPDATE` whose bound was `posting_datetime > X` gained an
|
||||
`OR (posting_datetime == X AND creation > args.creation)` branch during a "`sql` → `qb` refactor",
|
||||
widening the rows updated on both engines. Even when such a change is a deliberate bug-fix it must
|
||||
be called out and tested — it is **not** the no-op the refactor label implies. Confirm the
|
||||
converted query touches exactly the same rows with the same values MariaDB produced before.
|
||||
|
||||
---
|
||||
|
||||
## How to review
|
||||
|
||||
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL),
|
||||
(b) match a divergence in §2/§3 (different result across engines), or (c) change the row set under
|
||||
a refactor/conversion label (§6)? If so, comment with the
|
||||
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
|
||||
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
|
||||
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
|
||||
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
|
||||
splits the row count") so the fix is unambiguous.
|
||||
|
||||
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
|
||||
§1 breaks; the **semantic** §2/§3 divergences and the §6 refactor/conversion row-set changes are
|
||||
exactly what a reviewer (and this guide) must cover, because no static check can see them.
|
||||
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
|
||||
cover, because no static check can see them.
|
||||
|
||||
12
.github/helper/install.sh
vendored
12
.github/helper/install.sh
vendored
@@ -297,10 +297,14 @@ if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
|
||||
# Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by
|
||||
# start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is
|
||||
# NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service
|
||||
# container.
|
||||
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
|
||||
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
|
||||
# service-container DB and never calls start-db.sh, so the flags must be applied here.
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
|
||||
-c "ALTER SYSTEM SET fsync = 'off'" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
|
||||
-c "SELECT pg_reload_conf()";
|
||||
fi
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: pip
|
||||
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
uses: pre-commit/action@v3.0.0
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
|
||||
7
.github/workflows/patch.yml
vendored
7
.github/workflows/patch.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
|
||||
# it from the GitHub release every run.
|
||||
# ~100MB from frappe.io every run.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
@@ -76,10 +76,7 @@ jobs:
|
||||
|
||||
- name: Download erpnext v14 backup
|
||||
if: steps.cache-v14.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \
|
||||
-o ~/erpnext-v14.sql.gz \
|
||||
https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz
|
||||
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
||||
7
.github/workflows/server-tests-mariadb.yml
vendored
7
.github/workflows/server-tests-mariadb.yml
vendored
@@ -107,13 +107,6 @@ jobs:
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
SKIP_WKHTMLTOX_SETUP: "1"
|
||||
|
||||
- name: Warm up test data
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
|
||||
EOF
|
||||
|
||||
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
|
||||
6
.github/workflows/server-tests-postgres.yml
vendored
6
.github/workflows/server-tests-postgres.yml
vendored
@@ -108,11 +108,7 @@ jobs:
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
|
||||
# Clean shutdown so the baked datadir is consistent. Do NOT swallow a failed stop with
|
||||
# `|| true`: moving and tarring a still-running cluster ships a torn datadir the shards
|
||||
# cannot crash-recover (full_page_writes is off). Fail the job instead — mirrors the
|
||||
# MariaDB sister's "don't bake a dirty datadir" guard.
|
||||
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop
|
||||
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
|
||||
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
|
||||
|
||||
- name: Package bench for test shards
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -48,6 +48,7 @@ repos:
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
erpnext/public/js/controllers/.*|
|
||||
erpnext/templates/pages/order.js|
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3413,11 +3413,6 @@ rolldown@1.0.3:
|
||||
"@rolldown/binding-win32-arm64-msvc" "1.0.3"
|
||||
"@rolldown/binding-win32-x64-msvc" "1.0.3"
|
||||
|
||||
safe-expr-eval@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/safe-expr-eval/-/safe-expr-eval-1.0.4.tgz#3694739b3eb42b906417b94a5e6a74f3c21041b5"
|
||||
integrity sha512-p6ILL9oYItu8Dqm7atFCq3JXW/S1AcZDXQMHRhkRIGpF96SIwEpzogjVNM87mCg+6rfz3Q//rwn1zH2EW2TQ/Q==
|
||||
|
||||
scheduler@^0.27.0:
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||
get_outstanding_reference_documents,
|
||||
get_payment_entry,
|
||||
)
|
||||
from erpnext.utilities.bulk_transaction import transaction_processing
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_payment_entries(
|
||||
grouped_invoices: str | list | None = None,
|
||||
ungrouped_invoices: str | list | None = None,
|
||||
):
|
||||
"""Create draft Payment Entries from AP report invoice selection."""
|
||||
frappe.has_permission("Payment Entry", "create", throw=True)
|
||||
|
||||
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
|
||||
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
|
||||
|
||||
if not grouped_invoices and not ungrouped_invoices:
|
||||
frappe.throw(_("No Purchase Invoices selected"))
|
||||
|
||||
if ungrouped_invoices:
|
||||
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
|
||||
transaction_processing(data, "Purchase Invoice", "Payment Entry")
|
||||
|
||||
if grouped_invoices:
|
||||
groups = {}
|
||||
for d in grouped_invoices:
|
||||
key = (d["supplier"], d["party_account"])
|
||||
groups.setdefault(
|
||||
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
|
||||
)["vouchers"].append(d["voucher_no"])
|
||||
|
||||
frappe.msgprint(
|
||||
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
|
||||
)
|
||||
frappe.enqueue(
|
||||
make_grouped_payment_entries,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
groups=list(groups.values()),
|
||||
)
|
||||
|
||||
|
||||
def make_grouped_payment_entries(groups):
|
||||
created, failed = 0, 0
|
||||
|
||||
for group in groups:
|
||||
supplier = group["supplier"]
|
||||
try:
|
||||
frappe.db.savepoint("bulk_pe")
|
||||
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
|
||||
if not pe:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
failed += 1
|
||||
frappe.log_error(
|
||||
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
|
||||
message=_(
|
||||
"No outstanding invoices found for the selected vouchers in account {0}"
|
||||
).format(group["party_account"]),
|
||||
)
|
||||
continue
|
||||
|
||||
pe.flags.ignore_validate = True
|
||||
pe.set_title_field()
|
||||
pe.insert(ignore_mandatory=True)
|
||||
created += 1
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
failed += 1
|
||||
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
|
||||
|
||||
message = _("Created {0} draft Grouped Payment Entries").format(created)
|
||||
|
||||
if failed:
|
||||
message += " — " + _("{0} skipped (see Error Log)").format(failed)
|
||||
|
||||
frappe.publish_realtime(
|
||||
"msgprint",
|
||||
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
|
||||
user=frappe.session.user,
|
||||
after_commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_grouped_payment_entry(supplier, party_account, names):
|
||||
pe = get_payment_entry("Purchase Invoice", names[0])
|
||||
pe.set("references", [])
|
||||
|
||||
refs = get_outstanding_reference_documents(
|
||||
{
|
||||
"party_type": "Supplier",
|
||||
"party": supplier,
|
||||
"party_account": party_account,
|
||||
"company": pe.company,
|
||||
"vouchers": [frappe._dict(voucher_type="Purchase Invoice", voucher_no=n) for n in names],
|
||||
}
|
||||
)
|
||||
|
||||
for r in refs:
|
||||
if r.voucher_type != "Purchase Invoice":
|
||||
continue
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
"reference_doctype": r.voucher_type,
|
||||
"reference_name": r.voucher_no,
|
||||
"bill_no": r.get("bill_no"),
|
||||
"due_date": r.get("due_date"),
|
||||
"payment_term": r.get("payment_term"),
|
||||
"total_amount": r.invoice_amount,
|
||||
"outstanding_amount": r.outstanding_amount,
|
||||
"allocated_amount": r.outstanding_amount,
|
||||
"exchange_rate": r.get("exchange_rate") or 1,
|
||||
},
|
||||
)
|
||||
|
||||
if not pe.references:
|
||||
return None
|
||||
|
||||
# received_amount is in paid_to account currency; convert to paid_from account currency for paid_amount
|
||||
pe.received_amount = sum(r.allocated_amount for r in pe.references)
|
||||
pe.paid_amount = flt(pe.received_amount * pe.target_exchange_rate, pe.precision("paid_amount"))
|
||||
pe.set_amounts()
|
||||
return pe
|
||||
@@ -582,7 +582,6 @@ def make_gl_entries(
|
||||
frappe.db.commit()
|
||||
except Exception as e:
|
||||
if frappe.in_test:
|
||||
frappe.db.rollback()
|
||||
doc.log_error(f"Error while processing deferred accounting for Invoice {doc.name}")
|
||||
raise e
|
||||
else:
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -37,20 +36,8 @@ class AccountingPeriod(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
self.validate_overlap()
|
||||
|
||||
def validate_dates(self):
|
||||
if getdate(self.start_date) > getdate(self.end_date):
|
||||
frappe.throw(_("Start Date cannot be after End Date"))
|
||||
|
||||
if getdate(self.end_date) > getdate(nowdate()):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Accounting Period cannot be created for a future date. End Date {0} is after today."
|
||||
).format(frappe.bold(frappe.format(self.end_date, "Date")))
|
||||
)
|
||||
|
||||
def before_insert(self):
|
||||
self.bootstrap_doctypes_for_closing()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
from frappe.utils import add_months, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_period.accounting_period import (
|
||||
ClosedAccountingPeriod,
|
||||
@@ -93,7 +93,7 @@ def create_accounting_period(**args):
|
||||
|
||||
accounting_period = frappe.new_doc("Accounting Period")
|
||||
accounting_period.start_date = args.start_date or nowdate()
|
||||
accounting_period.end_date = args.end_date or nowdate()
|
||||
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
|
||||
accounting_period.company = args.company or "_Test Company"
|
||||
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
|
||||
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})
|
||||
|
||||
@@ -47,7 +47,6 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
for key, value in header_map.items():
|
||||
fields.update({key: d[int(value) - 1]})
|
||||
|
||||
frappe.db.savepoint("bank_entry")
|
||||
try:
|
||||
bank_transaction = frappe.get_doc({"doctype": "Bank Transaction"})
|
||||
bank_transaction.update(fields)
|
||||
@@ -57,8 +56,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
bank_transaction.submit()
|
||||
success += 1
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bank_entry")
|
||||
frappe.log_error(title="Bank entry creation failed")
|
||||
bank_transaction.log_error("Bank entry creation failed")
|
||||
errors += 1
|
||||
|
||||
return {"success": success, "errors": errors}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -73,7 +73,7 @@ class ExchangeRateRevaluation(Document):
|
||||
|
||||
def validate_mandatory(self):
|
||||
if not (self.company and self.posting_date):
|
||||
frappe.throw(_("Please select Company and Posting Date to get entries"))
|
||||
frappe.throw(_("Please select Company and Posting Date to getting entries"))
|
||||
|
||||
def before_submit(self):
|
||||
self.remove_accounts_without_gain_loss()
|
||||
@@ -601,7 +601,6 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
.select(gl.voucher_type, gl.voucher_no)
|
||||
.where(Criterion.all(conditions))
|
||||
.orderby(gl.posting_date, order=Order.desc)
|
||||
.orderby(gl.name, order=Order.desc)
|
||||
.limit(1)
|
||||
.run()[0]
|
||||
)
|
||||
@@ -616,7 +615,6 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
|
||||
)
|
||||
.orderby(gl.posting_date, order=Order.desc)
|
||||
.orderby(gl.name, order=Order.desc)
|
||||
.limit(1)
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
@@ -65,7 +65,6 @@ def start_merge(docname):
|
||||
total = len(ledger_merge.merge_accounts)
|
||||
for row in ledger_merge.merge_accounts:
|
||||
if not row.merged:
|
||||
frappe.db.savepoint("ledger_merge_row")
|
||||
try:
|
||||
merge_account(
|
||||
row.account,
|
||||
@@ -80,7 +79,8 @@ def start_merge(docname):
|
||||
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
|
||||
)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="ledger_merge_row")
|
||||
if not frappe.in_test:
|
||||
frappe.db.rollback()
|
||||
ledger_merge.log_error("Ledger merge failed")
|
||||
finally:
|
||||
if successful_merges == total:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
@@ -64,9 +63,13 @@ class TestLoyaltyPointEntry(ERPNextTestSuite):
|
||||
self.assertEqual(doc.loyalty_points, -7)
|
||||
|
||||
# Check balance
|
||||
lpe = frappe.qb.DocType("Loyalty Point Entry")
|
||||
balance = (
|
||||
frappe.qb.from_(lpe).select(Sum(lpe.loyalty_points)).where(lpe.customer == self.customer_name)
|
||||
).run()[0][0]
|
||||
balance = frappe.db.sql(
|
||||
"""
|
||||
SELECT SUM(loyalty_points)
|
||||
FROM `tabLoyalty Point Entry`
|
||||
WHERE customer = %s
|
||||
""",
|
||||
(self.customer_name,),
|
||||
)[0][0]
|
||||
|
||||
self.assertEqual(balance, 3) # 10 added, 7 redeemed
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
|
||||
@@ -263,12 +262,14 @@ class TestLoyaltyProgram(ERPNextTestSuite):
|
||||
|
||||
def get_points_earned(self):
|
||||
def get_returned_amount():
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
returned_amount = (
|
||||
frappe.qb.from_(si)
|
||||
.select(Sum(si.grand_total))
|
||||
.where((si.docstatus == 1) & (si.is_return == 1) & (si.return_against == self.name))
|
||||
).run()
|
||||
returned_amount = frappe.db.sql(
|
||||
"""
|
||||
select sum(grand_total)
|
||||
from `tabSales Invoice`
|
||||
where docstatus=1 and is_return=1 and ifnull(return_against, '')=%s
|
||||
""",
|
||||
self.name,
|
||||
)
|
||||
return abs(flt(returned_amount[0][0])) if returned_amount else 0
|
||||
|
||||
lp_details = get_loyalty_program_details_with_points(
|
||||
|
||||
@@ -514,12 +514,10 @@ class PaymentEntry(AccountsController):
|
||||
invoice_names.add((ref.reference_doctype, ref.reference_name))
|
||||
|
||||
for doctype, name in invoice_names:
|
||||
frappe.db.savepoint("subscription_update")
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.refresh_subscription_status()
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="subscription_update")
|
||||
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
|
||||
|
||||
def set_missing_values(self):
|
||||
@@ -2795,9 +2793,6 @@ def get_open_payment_requests_for_references(references=None):
|
||||
.where(PR.docstatus == 1)
|
||||
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
|
||||
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
|
||||
# unique tiebreaker so PRs sharing a transaction_date allocate in the same order on both engines
|
||||
.orderby(PR.creation, order=frappe.qb.asc)
|
||||
.orderby(PR.name, order=frappe.qb.asc)
|
||||
).run(as_dict=True)
|
||||
|
||||
if not response:
|
||||
|
||||
@@ -10,9 +10,9 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_pu
|
||||
|
||||
class TestPOSInvoiceMerging(POSInvoiceTestMixin):
|
||||
def clear_pos_data(self):
|
||||
frappe.db.delete("POS Opening Entry")
|
||||
frappe.db.delete("POS Closing Entry")
|
||||
frappe.db.delete("POS Invoice")
|
||||
frappe.db.sql("delete from `tabPOS Opening Entry`;")
|
||||
frappe.db.sql("delete from `tabPOS Closing Entry`;")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`;")
|
||||
|
||||
def setUp(self):
|
||||
self.clear_pos_data()
|
||||
|
||||
@@ -25,11 +25,15 @@ class TestPOSProfile(ERPNextTestSuite):
|
||||
items = get_items_list(doc, doc.company)
|
||||
customers = get_customers_list(doc)
|
||||
|
||||
products_count = frappe.db.count("Item", {"item_group": "_Test Item Group"})
|
||||
customers_count = frappe.db.count("Customer", {"customer_group": "_Test Customer Group"})
|
||||
products_count = frappe.db.sql(
|
||||
""" select count(name) from tabItem where item_group = '_Test Item Group'""", as_list=1
|
||||
)
|
||||
customers_count = frappe.db.sql(
|
||||
""" select count(name) from tabCustomer where customer_group = '_Test Customer Group'"""
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), products_count)
|
||||
self.assertEqual(len(customers), customers_count)
|
||||
self.assertEqual(len(items), products_count[0][0])
|
||||
self.assertEqual(len(customers), customers_count[0][0])
|
||||
|
||||
def test_disabled_pos_profile_creation(self):
|
||||
make_pos_profile(name="_Test POS Profile 001", disabled=1)
|
||||
@@ -79,6 +83,7 @@ class TestPOSProfile(ERPNextTestSuite):
|
||||
def get_customers_list(pos_profile=None):
|
||||
if pos_profile is None:
|
||||
pos_profile = {}
|
||||
cond = "1=1"
|
||||
customer_groups = []
|
||||
if pos_profile.get("customer_groups"):
|
||||
# Get customers based on the customer groups defined in the POS profile
|
||||
@@ -86,16 +91,14 @@ def get_customers_list(pos_profile=None):
|
||||
customer_groups.extend(
|
||||
[d.get("name") for d in get_child_nodes("Customer Group", d.get("customer_group"))]
|
||||
)
|
||||
|
||||
filters = {"disabled": 0}
|
||||
if customer_groups:
|
||||
filters["customer_group"] = ["in", customer_groups]
|
||||
cond = "customer_group in ({})".format(", ".join(["%s"] * len(customer_groups)))
|
||||
|
||||
return (
|
||||
frappe.get_all(
|
||||
"Customer",
|
||||
filters=filters,
|
||||
fields=["name", "customer_name", "customer_group", "territory"],
|
||||
frappe.db.sql(
|
||||
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
|
||||
and {cond}""",
|
||||
tuple(customer_groups),
|
||||
as_dict=1,
|
||||
)
|
||||
or {}
|
||||
)
|
||||
@@ -132,8 +135,8 @@ def get_items_list(pos_profile, company):
|
||||
|
||||
|
||||
def make_pos_profile(**args):
|
||||
frappe.db.delete("POS Payment Method")
|
||||
frappe.db.delete("POS Profile")
|
||||
frappe.db.sql("delete from `tabPOS Payment Method`")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
|
||||
@@ -91,9 +91,7 @@ class TestPricingRule(ERPNextTestSuite):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("discount_percentage"), 5)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Pricing Rule", {"campaign": "_Test Campaign"}, "priority", None, update_modified=False
|
||||
)
|
||||
frappe.db.sql("update `tabPricing Rule` set priority=NULL where campaign='_Test Campaign'")
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import MultiplePricingRuleConflict
|
||||
|
||||
self.assertRaises(MultiplePricingRuleConflict, get_item_details, args)
|
||||
|
||||
@@ -92,7 +92,7 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -131,7 +130,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
@@ -331,33 +329,20 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
handled = False
|
||||
if (
|
||||
item.item_code
|
||||
and item.item_code in stock_items
|
||||
and item.get("purchase_receipt")
|
||||
and not doc.is_return
|
||||
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
|
||||
):
|
||||
handled = self.make_standard_cost_srbnb_split(
|
||||
gl_entries, item, expense_account, account_currency, base_amount
|
||||
)
|
||||
|
||||
if not handled:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
@@ -530,107 +515,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
},
|
||||
)
|
||||
|
||||
def make_standard_cost_srbnb_split(
|
||||
self, gl_entries, item, expense_account, account_currency, base_amount
|
||||
):
|
||||
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
|
||||
value the receipt actually booked and post the (Net Amount - standard) difference to the
|
||||
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
|
||||
can't be resolved."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
precision = item.precision("base_net_amount")
|
||||
standard_value = flt(self.get_pr_stock_value(item), precision)
|
||||
if not standard_value:
|
||||
return False
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": standard_value,
|
||||
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
variance = flt(base_amount - standard_value, precision)
|
||||
if variance:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": get_purchase_price_variance_account(item.item_code, doc.company),
|
||||
"against": doc.supplier,
|
||||
"debit": variance,
|
||||
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def get_pr_stock_value(self, item):
|
||||
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
|
||||
row is billing.
|
||||
|
||||
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
|
||||
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
|
||||
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
|
||||
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
|
||||
non-stock invoice).
|
||||
|
||||
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
|
||||
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
|
||||
pr_detail = frappe.db.get_value(
|
||||
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
|
||||
)
|
||||
if not pr_detail or not pr_detail.warehouse:
|
||||
return 0.0
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
result = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.stock_value_difference))
|
||||
.where(
|
||||
(sle.voucher_type == "Purchase Receipt")
|
||||
& (sle.voucher_no == item.purchase_receipt)
|
||||
& (sle.voucher_detail_no == item.pr_detail)
|
||||
& (sle.warehouse == pr_detail.warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
)
|
||||
).run()
|
||||
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
if not accepted_value or not flt(pr_detail.stock_qty):
|
||||
return accepted_value
|
||||
|
||||
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
|
||||
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
|
||||
|
||||
def get_stock_variance_account(self, item):
|
||||
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
|
||||
Variance; for all other items it keeps the existing behaviour (default expense account)."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if item.item_code and get_valuation_method(item.item_code, self.doc.company) == "Standard Cost":
|
||||
return get_purchase_price_variance_account(item.item_code, self.doc.company)
|
||||
return self.doc.get_company_default("default_expense_account")
|
||||
|
||||
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
|
||||
doc = self.doc
|
||||
net_amt_precision = item.precision("base_net_amount")
|
||||
@@ -652,7 +536,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
@@ -677,7 +561,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
and warehouse_debit_amount
|
||||
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
):
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
|
||||
@@ -173,10 +173,6 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
return value;
|
||||
},
|
||||
|
||||
get_datatable_options(options) {
|
||||
return Object.assign(options, { checkboxColumn: true });
|
||||
},
|
||||
|
||||
onload: function (report) {
|
||||
report.page.add_inner_button(__("Accounts Payable Summary"), function () {
|
||||
var filters = report.get_values();
|
||||
@@ -186,145 +182,9 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
|
||||
if (frappe.model.can_create("Payment Entry")) {
|
||||
report.page.add_inner_button(
|
||||
__("Create Payment Entries"),
|
||||
function () {
|
||||
erpnext.accounts.create_payment_entries_from_payable_report(report);
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
|
||||
const datatable = report.datatable;
|
||||
if (!datatable) return;
|
||||
|
||||
const rows = datatable.rowmanager
|
||||
.getCheckedRows()
|
||||
.map((i) => datatable.datamanager.data[i])
|
||||
.filter((r) => r && r.voucher_type === "Purchase Invoice" && r.voucher_no);
|
||||
|
||||
if (!rows.length) {
|
||||
frappe.msgprint(__("Select one or more Purchase Invoice rows"));
|
||||
return;
|
||||
}
|
||||
|
||||
// build per-(supplier, party_account) summary to match backend grouping key
|
||||
const supplierMap = {};
|
||||
for (const r of rows) {
|
||||
const key = `${r.party}||${r.party_account}`;
|
||||
if (!supplierMap[key]) {
|
||||
supplierMap[key] = {
|
||||
supplier: r.party,
|
||||
party_account: r.party_account,
|
||||
count: 0,
|
||||
outstanding: 0,
|
||||
};
|
||||
}
|
||||
supplierMap[key].count += 1;
|
||||
supplierMap[key].outstanding += r.outstanding || 0;
|
||||
}
|
||||
|
||||
const overviewFields = [
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "supplier",
|
||||
label: __("Supplier"),
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "party_account",
|
||||
label: __("Payable Account"),
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
fieldtype: "Int",
|
||||
fieldname: "invoices",
|
||||
label: __("Invoices"),
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
fieldtype: "Float",
|
||||
fieldname: "payable_amount",
|
||||
label: __("Payable Amount"),
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Create Payment Entries"),
|
||||
fields: [
|
||||
{
|
||||
fieldname: "supplier_overview",
|
||||
fieldtype: "Table",
|
||||
label: __("Supplier Overview"),
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
fields: overviewFields,
|
||||
data: Object.values(supplierMap).map((d) => ({
|
||||
supplier: d.supplier,
|
||||
party_account: d.party_account,
|
||||
invoices: d.count,
|
||||
payable_amount: d.outstanding,
|
||||
})),
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Create"),
|
||||
secondary_action_label: __("Cancel"),
|
||||
secondary_action() {
|
||||
dialog.hide();
|
||||
report.datatable.rowmanager.checkAll(false);
|
||||
},
|
||||
primary_action() {
|
||||
dialog.hide();
|
||||
|
||||
const groupedKeys = new Set(
|
||||
Object.values(supplierMap)
|
||||
.filter((d) => d.count > 1)
|
||||
.map((d) => `${d.supplier}||${d.party_account}`)
|
||||
);
|
||||
|
||||
const grouped_invoices = [];
|
||||
const ungrouped_invoices = [];
|
||||
for (const r of rows) {
|
||||
const payload = {
|
||||
voucher_no: r.voucher_no,
|
||||
supplier: r.party,
|
||||
party_account: r.party_account,
|
||||
};
|
||||
(groupedKeys.has(`${r.party}||${r.party_account}`)
|
||||
? grouped_invoices
|
||||
: ungrouped_invoices
|
||||
).push(payload);
|
||||
}
|
||||
|
||||
const clearSelection = () => report.datatable.rowmanager.checkAll(false);
|
||||
|
||||
frappe
|
||||
.call({
|
||||
method: "erpnext.accounts.bulk_payment.create_payment_entries",
|
||||
args: { grouped_invoices, ungrouped_invoices },
|
||||
})
|
||||
.then(clearSelection)
|
||||
.catch(clearSelection);
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
};
|
||||
|
||||
erpnext.utils.add_dimensions("Accounts Payable", 10);
|
||||
|
||||
function get_party_type_options() {
|
||||
|
||||
@@ -135,9 +135,38 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
template = frappe.get_doc("Payment Terms Template", "_Test Payment Term Template")
|
||||
first_term = frappe.get_doc("Payment Term", template.terms[0].payment_term)
|
||||
expected_payment_term = first_term.description or first_term.name
|
||||
payment_term1 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
|
||||
).insert()
|
||||
payment_term2 = frappe.get_doc(
|
||||
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
|
||||
).insert()
|
||||
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Terms Template",
|
||||
"template_name": "_Test 50-50",
|
||||
"terms": [
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term1.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 15,
|
||||
},
|
||||
{
|
||||
"doctype": "Payment Terms Template Detail",
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"payment_term": payment_term2.name,
|
||||
"description": "_Test 50-50",
|
||||
"invoice_portion": 50,
|
||||
"credit_days": 30,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
@@ -164,10 +193,12 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
row = report[1][0]
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, expected_payment_term], [row.voucher_no, row.payment_term])
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
).insert()
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
@@ -196,7 +227,9 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
|
||||
).insert()
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
|
||||
@@ -1422,10 +1422,10 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
|
||||
# must be applied explicitly. The report should only show permitted customers.
|
||||
original_customer = self.customer
|
||||
second_customer = "_Test Customer 1"
|
||||
second_customer = "_Test AR Perm Customer"
|
||||
|
||||
# create_customer overrides self.customer, so build the restricted invoice first
|
||||
self.customer = second_customer
|
||||
self.create_customer(customer_name=second_customer)
|
||||
self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
self.customer = original_customer
|
||||
|
||||
@@ -59,9 +59,16 @@ class TestCashFlow(ERPNextTestSuite):
|
||||
|
||||
def test_cash_purchase_of_asset_is_investing_outflow(self):
|
||||
"""Buying a fixed asset for cash is an investing outflow that reduces net change in cash."""
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
asset_account = "Office Equipment - _TC"
|
||||
create_account(
|
||||
account_name="_Test Cash Flow Asset",
|
||||
company=self.company,
|
||||
parent_account="Fixed Assets - _TC",
|
||||
account_type="Fixed Asset",
|
||||
)
|
||||
asset_account = "_Test Cash Flow Asset - _TC"
|
||||
|
||||
before = self.net_change_in_cash()
|
||||
# debit the fixed asset, credit cash -> cash goes out
|
||||
|
||||
@@ -64,12 +64,16 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
||||
|
||||
def test_opening_total_and_closing_balances(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
self.clear_old_entries()
|
||||
# reuse bootstrap non-party accounts; clear_old_entries() leaves them clean of GL
|
||||
account = "_Test Account Cost for Goods Sold - _TC"
|
||||
offset = "_Test Bank - _TC"
|
||||
account = create_account(
|
||||
account_name="_Test GL Account", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
make_journal_entry(account, offset, 1000, posting_date=add_days(today(), -60), submit=True) # opening
|
||||
make_journal_entry(account, offset, 200, posting_date=today(), submit=True) # in period
|
||||
|
||||
@@ -83,13 +87,19 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
self.assertEqual(labelled["'Closing (Opening + Total)'"]["debit"], 1200)
|
||||
|
||||
def test_categorize_by_account_subtotals(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
self.clear_old_entries()
|
||||
# reuse bootstrap non-party accounts; clear_old_entries() leaves them clean of GL
|
||||
account_a = "_Test Account Cost for Goods Sold - _TC"
|
||||
account_b = "_Test Bank - _TC"
|
||||
offset = "_Test Cash - _TC"
|
||||
account_a = create_account(
|
||||
account_name="_Test GL Account A", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
account_b = create_account(
|
||||
account_name="_Test GL Account B", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
make_journal_entry(account_a, offset, 300, posting_date=today(), submit=True)
|
||||
make_journal_entry(account_b, offset, 400, posting_date=today(), submit=True)
|
||||
|
||||
|
||||
@@ -562,12 +562,7 @@ class GrossProfitGenerator:
|
||||
row.base_amount = packed_item.base_amount
|
||||
|
||||
# get buying amount
|
||||
if row.is_debit_note:
|
||||
# Rate adjustment debit notes have no stock movement, so buying amount is zero
|
||||
if not grouped_by_invoice:
|
||||
row.qty = 0
|
||||
row.buying_amount = 0
|
||||
elif row.item_code in product_bundles:
|
||||
if row.item_code in product_bundles:
|
||||
row.buying_amount = flt(
|
||||
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
|
||||
self.currency_precision,
|
||||
@@ -900,11 +895,7 @@ class GrossProfitGenerator:
|
||||
if row.cost_center:
|
||||
query = query.where(purchase_invoice_item.cost_center == row.cost_center)
|
||||
|
||||
query = (
|
||||
query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc)
|
||||
.orderby(purchase_invoice.name, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
)
|
||||
query = query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc).limit(1)
|
||||
last_purchase_rate = query.run()
|
||||
|
||||
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
||||
@@ -965,7 +956,6 @@ class GrossProfitGenerator:
|
||||
SalesInvoice.customer_group,
|
||||
SalesInvoice.customer_name,
|
||||
SalesInvoice.territory,
|
||||
SalesInvoice.is_debit_note,
|
||||
SalesInvoiceItem.item_code,
|
||||
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||
SalesInvoiceItem.item_name,
|
||||
@@ -1146,7 +1136,6 @@ class GrossProfitGenerator:
|
||||
"posting_time": row.posting_time,
|
||||
"project": row.project,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"customer": row.customer,
|
||||
"customer_group": row.customer_group,
|
||||
"customer_name": row.customer_name,
|
||||
@@ -1185,7 +1174,6 @@ class GrossProfitGenerator:
|
||||
"description": item.description,
|
||||
"warehouse": item.warehouse or row.warehouse,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"item_group": "",
|
||||
"brand": "",
|
||||
"dn_detail": row.dn_detail,
|
||||
|
||||
@@ -700,160 +700,6 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertIsNone(data[1].buying_rate)
|
||||
self.assertEqual(data[1]["gross_profit_%"], 20)
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
if item_code:
|
||||
dn.items[0].item_code = item_code
|
||||
dn.items[0].item_name = item_code
|
||||
dn.is_debit_note = 1
|
||||
dn.return_against = against_invoice.name
|
||||
dn.items[0].allow_zero_valuation_rate = 1
|
||||
return dn.save().submit()
|
||||
|
||||
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
|
||||
"""
|
||||
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
|
||||
since there is no stock movement. Gross profit equals the adjustment amount
|
||||
and gross profit % equals 100%.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
dn_item_rows = [
|
||||
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
|
||||
]
|
||||
self.assertEqual(len(dn_item_rows), 1)
|
||||
|
||||
dn_row = dn_item_rows[0]
|
||||
self.assertEqual(dn_row.buying_amount, 0.0)
|
||||
self.assertEqual(dn_row.selling_amount, 20.0)
|
||||
self.assertEqual(dn_row.gross_profit, 20.0)
|
||||
self.assertEqual(dn_row["gross_profit_%"], 100.0)
|
||||
|
||||
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
|
||||
"""
|
||||
The original invoice's GP should be derived solely from its own selling
|
||||
amount and COGS — the rate adjustment debit note must not alter it.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
|
||||
self.assertEqual(len(sinv_item_rows), 1)
|
||||
|
||||
sinv_row = sinv_item_rows[0]
|
||||
self.assertEqual(sinv_row.selling_amount, 200.0)
|
||||
self.assertEqual(sinv_row.buying_amount, 100.0)
|
||||
self.assertEqual(sinv_row.gross_profit, 100.0)
|
||||
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
|
||||
|
||||
def test_debit_note_qty_not_inflated_in_grouped_report(self):
|
||||
"""
|
||||
When grouped by Item Code, the debit note (qty=0) must not inflate
|
||||
the group's qty or buying_amount. The selling amount and average
|
||||
selling rate correctly reflect the rate adjustment.
|
||||
"""
|
||||
item = create_item("_Test Rate Adjustment Debit Note Item")
|
||||
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=item.item_code,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_code,
|
||||
cost_center=self.cost_center,
|
||||
warehouse=self.warehouse,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
update_stock=1,
|
||||
currency="INR",
|
||||
income_account=self.income_account,
|
||||
expense_account=self.expense_account,
|
||||
)
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Item Code",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
# group_by="Item Code" column order:
|
||||
# [item_code, item_name, brand, description, qty, base_rate,
|
||||
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
|
||||
item_row = next((row for row in data if row[0] == item.item_code), None)
|
||||
self.assertIsNotNone(item_row)
|
||||
|
||||
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
|
||||
item_row[4],
|
||||
item_row[5],
|
||||
item_row[8],
|
||||
item_row[7],
|
||||
item_row[9],
|
||||
item_row[10],
|
||||
)
|
||||
|
||||
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
|
||||
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
|
||||
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
|
||||
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
|
||||
self.assertEqual(gross_profit, 120.0) # 220 - 100
|
||||
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
|
||||
|
||||
|
||||
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Sum
|
||||
@@ -128,32 +127,17 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
row.update({frappe.scrub(tax_acc): tax_amount})
|
||||
|
||||
# total tax, grand total, rounded total & outstanding amount
|
||||
|
||||
outstanding_precision = (
|
||||
get_field_precision(
|
||||
frappe.get_meta("Purchase Invoice").get_field("outstanding_amount"),
|
||||
currency=company_currency,
|
||||
)
|
||||
or 2
|
||||
)
|
||||
row.update(
|
||||
{
|
||||
"total_tax": total_tax,
|
||||
"grand_total": inv.base_grand_total,
|
||||
"rounded_total": inv.base_rounded_total,
|
||||
"outstanding_amount": inv.outstanding_amount,
|
||||
}
|
||||
)
|
||||
|
||||
if inv.doctype == "Purchase Invoice":
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
row.update({"debit": inv.base_grand_total, "credit": 0.0})
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -426,7 +410,6 @@ def get_invoices(filters, additional_query_columns):
|
||||
pi.base_rounded_total,
|
||||
pi.outstanding_amount,
|
||||
pi.mode_of_payment,
|
||||
pi.conversion_rate,
|
||||
)
|
||||
.where(pi.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, flt, today
|
||||
from frappe.utils import add_months, today
|
||||
|
||||
from erpnext.accounts.report.purchase_register.purchase_register import execute
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
@@ -90,35 +90,6 @@ class TestPurchaseRegister(ERPNextTestSuite):
|
||||
self.assertEqual(first_row.total_tax, 100)
|
||||
self.assertEqual(first_row.grand_total, 1100)
|
||||
|
||||
def test_purchase_currency_conversion(self):
|
||||
usd_creditors = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "USD Creditors",
|
||||
"parent_account": "Accounts Payable - _TC",
|
||||
"company": "_Test Company",
|
||||
"account_type": "Payable",
|
||||
"root_type": "Liability",
|
||||
"report_type": "Balance Sheet",
|
||||
"account_currency": "USD",
|
||||
}
|
||||
).insert()
|
||||
foreign_invoice = make_purchase_invoice()
|
||||
foreign_invoice.db_set("currency", "USD")
|
||||
foreign_invoice.db_set("conversion_rate", 80)
|
||||
foreign_invoice.db_set("credit_to", usd_creditors.name)
|
||||
foreign_invoice.db_set("outstanding_amount", 100.236)
|
||||
local_invoice = make_purchase_invoice()
|
||||
local_invoice.db_set("currency", "INR")
|
||||
local_invoice.db_set("conversion_rate", 1)
|
||||
local_invoice.db_set("outstanding_amount", 200.456)
|
||||
columns, data, *_ = execute(frappe._dict({"company": foreign_invoice.company}))
|
||||
outstanding_precision = 2
|
||||
|
||||
data_by_name = {x.get("voucher_no"): x.get("outstanding_amount") for x in data}
|
||||
self.assertEqual(data_by_name.get(foreign_invoice.name), flt((100.236 * 80), outstanding_precision))
|
||||
self.assertEqual(data_by_name.get(local_invoice.name), flt(200.456, outstanding_precision))
|
||||
|
||||
def test_purchase_register_ledger_view(self):
|
||||
filters = frappe._dict(
|
||||
company="_Test Company 6",
|
||||
|
||||
@@ -141,31 +141,17 @@ def _execute(filters, additional_table_columns=None):
|
||||
|
||||
# total tax, grand total, outstanding amount & rounded total
|
||||
|
||||
outstanding_precision = (
|
||||
get_field_precision(
|
||||
frappe.get_meta("Sales Invoice").get_field("outstanding_amount"),
|
||||
currency=company_currency,
|
||||
)
|
||||
or 2
|
||||
)
|
||||
row.update(
|
||||
{
|
||||
"tax_total": total_tax,
|
||||
"grand_total": inv.base_grand_total,
|
||||
"rounded_total": inv.base_rounded_total,
|
||||
"outstanding_amount": inv.outstanding_amount,
|
||||
}
|
||||
)
|
||||
|
||||
if inv.doctype == "Sales Invoice":
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
row.update({"debit": inv.base_grand_total, "credit": 0.0})
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -462,7 +448,6 @@ def get_invoices(filters, additional_query_columns):
|
||||
si.is_internal_customer,
|
||||
si.represents_company,
|
||||
si.company,
|
||||
si.conversion_rate,
|
||||
)
|
||||
.where(si.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
from frappe.utils import getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_register.sales_register import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.selling.doctype.customer.test_customer import make_customer
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -250,25 +249,3 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
}
|
||||
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
|
||||
self.assertDictEqual(result_output, expected_result)
|
||||
|
||||
def test_outstanding_currency_conversion(self):
|
||||
foreign_invoice = create_sales_invoice(
|
||||
customer="_Test Customer",
|
||||
posting_date=add_days(today(), -1),
|
||||
qty=1,
|
||||
rate=100,
|
||||
)
|
||||
foreign_invoice.db_set("currency", "USD")
|
||||
foreign_invoice.db_set("conversion_rate", 80)
|
||||
foreign_invoice.db_set("outstanding_amount", 100.236)
|
||||
make_customer("_Test Customer2")
|
||||
local_invoice = create_sales_invoice(
|
||||
customer="_Test Customer2", currency="INR", conversion_rate=1, qty=1, rate=200
|
||||
)
|
||||
local_invoice.db_set("outstanding_amount", 200.456)
|
||||
columns, data, *_ = execute(frappe._dict({"company": foreign_invoice.company}))
|
||||
outstanding_precision = 2
|
||||
|
||||
data_by_name = {x.get("voucher_no"): x.get("outstanding_amount") for x in data}
|
||||
self.assertEqual(data_by_name.get(foreign_invoice.name), flt((100.236 * 80), outstanding_precision))
|
||||
self.assertEqual(data_by_name.get(local_invoice.name), flt(200.456, outstanding_precision))
|
||||
|
||||
@@ -597,21 +597,11 @@ def execute_synced_report(filters):
|
||||
|
||||
def get_data_duckdb(filters, conn):
|
||||
# accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
|
||||
accounts = frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": filters.company},
|
||||
fields=[
|
||||
"name",
|
||||
"account_number",
|
||||
"parent_account",
|
||||
"account_name",
|
||||
"root_type",
|
||||
"report_type",
|
||||
"is_group",
|
||||
"lft",
|
||||
"rgt",
|
||||
],
|
||||
order_by="lft",
|
||||
accounts = frappe.db.sql(
|
||||
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
|
||||
from `tabAccount` where company=%s order by lft""",
|
||||
filters.company,
|
||||
as_dict=True,
|
||||
)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.trial_balance_for_party.trial_balance_for_party import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestTrialBalanceForParty(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"party_type": "Customer",
|
||||
"fiscal_year": "_Test Fiscal Year 2026",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def party_row(self, party, **extra):
|
||||
return next(row for row in self.run_report(party=party, **extra) if row.get("party") == party)
|
||||
|
||||
def test_sales_invoice_shown_as_period_debit(self):
|
||||
customer = "_Test Customer"
|
||||
create_sales_invoice(customer=customer, qty=1, rate=10000, posting_date="2026-06-01")
|
||||
|
||||
row = self.party_row(customer)
|
||||
self.assertEqual(row["opening_debit"], 0)
|
||||
self.assertEqual(row["debit"], 10000)
|
||||
self.assertEqual(row["credit"], 0)
|
||||
self.assertEqual(row["closing_debit"], 10000)
|
||||
self.assertEqual(row["closing_credit"], 0)
|
||||
|
||||
def test_receipt_nets_invoice_in_closing(self):
|
||||
customer = "_Test Customer"
|
||||
create_sales_invoice(customer=customer, qty=1, rate=10000, posting_date="2026-06-01")
|
||||
create_payment_entry(
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=customer,
|
||||
paid_from="Debtors - _TC",
|
||||
paid_to="_Test Bank - _TC",
|
||||
paid_amount=4000,
|
||||
save=True,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
row = self.party_row(customer)
|
||||
self.assertEqual(row["debit"], 10000)
|
||||
self.assertEqual(row["credit"], 4000)
|
||||
# closing nets debit against credit: 10000 - 4000
|
||||
self.assertEqual(row["closing_debit"], 6000)
|
||||
self.assertEqual(row["closing_credit"], 0)
|
||||
|
||||
def test_prior_period_invoice_shown_as_opening(self):
|
||||
customer = "_Test Customer"
|
||||
# invoice dated before from_date should land in the opening balance, not within-period
|
||||
create_sales_invoice(customer=customer, qty=1, rate=10000, posting_date="2025-12-01")
|
||||
|
||||
row = self.party_row(customer)
|
||||
self.assertEqual(row["opening_debit"], 10000)
|
||||
self.assertEqual(row["debit"], 0)
|
||||
self.assertEqual(row["closing_debit"], 10000)
|
||||
|
||||
def test_exclude_zero_balance_parties(self):
|
||||
customer = "_Test Customer"
|
||||
create_sales_invoice(customer=customer, qty=1, rate=10000, posting_date="2026-06-01")
|
||||
create_payment_entry(
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=customer,
|
||||
paid_from="Debtors - _TC",
|
||||
paid_to="_Test Bank - _TC",
|
||||
paid_amount=10000,
|
||||
save=True,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# fully settled party still shows by default ...
|
||||
self.assertEqual(self.party_row(customer)["closing_debit"], 0)
|
||||
# ... but is hidden when zero-balance parties are excluded
|
||||
parties = {row.get("party") for row in self.run_report(exclude_zero_balance_parties=1)}
|
||||
self.assertNotIn(customer, parties)
|
||||
|
||||
def test_purchase_invoice_shown_as_supplier_credit(self):
|
||||
supplier = "_Test Supplier"
|
||||
make_purchase_invoice(supplier=supplier, qty=1, rate=8000, posting_date="2026-06-01")
|
||||
|
||||
row = self.party_row(supplier, party_type="Supplier")
|
||||
self.assertEqual(row["credit"], 8000)
|
||||
self.assertEqual(row["debit"], 0)
|
||||
self.assertEqual(row["closing_credit"], 8000)
|
||||
self.assertEqual(row["closing_debit"], 0)
|
||||
|
||||
def test_totals_row_sums_party_rows(self):
|
||||
create_sales_invoice(customer="_Test Customer 1", qty=1, rate=10000, posting_date="2026-06-01")
|
||||
create_sales_invoice(customer="_Test Customer 2", qty=1, rate=6000, posting_date="2026-06-01")
|
||||
|
||||
data = self.run_report()
|
||||
totals = data[-1] # totals row is appended last
|
||||
party_rows = data[:-1]
|
||||
for column in (
|
||||
"opening_debit",
|
||||
"opening_credit",
|
||||
"debit",
|
||||
"credit",
|
||||
"closing_debit",
|
||||
"closing_credit",
|
||||
):
|
||||
self.assertEqual(totals[column], sum(row[column] for row in party_rows))
|
||||
@@ -150,9 +150,6 @@ def add_gl_entry(
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
if project:
|
||||
gl_entry["project"] = project
|
||||
|
||||
if voucher_detail_no:
|
||||
gl_entry["voucher_detail_no"] = voucher_detail_no
|
||||
|
||||
|
||||
@@ -93,11 +93,6 @@ frappe.ui.form.on("Asset", {
|
||||
frappe.ui.form.trigger("Asset", "asset_type");
|
||||
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
|
||||
|
||||
if (frm.doc.docstatus < 1 && frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
|
||||
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
|
||||
frm.set_value("is_fully_depreciated", 0);
|
||||
}
|
||||
|
||||
let has_create_buttons = false;
|
||||
if (frm.doc.docstatus == 1) {
|
||||
if (["Submitted", "Partially Depreciated"].includes(frm.doc.status)) {
|
||||
@@ -732,10 +727,6 @@ frappe.ui.form.on("Asset", {
|
||||
|
||||
calculate_depreciation: function (frm) {
|
||||
frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation);
|
||||
if (frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
|
||||
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
|
||||
frm.set_value("is_fully_depreciated", 0);
|
||||
}
|
||||
if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.net_purchase_amount) {
|
||||
frm.trigger("set_finance_book");
|
||||
} else {
|
||||
|
||||
@@ -450,11 +450,10 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:(doc.asset_type == \"Existing Asset\" && !doc.calculate_depreciation) || doc.calculate_depreciation",
|
||||
"fieldname": "is_fully_depreciated",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Fully Depreciated",
|
||||
"read_only_depends_on": "eval:doc.calculate_depreciation"
|
||||
"hidden": 1,
|
||||
"label": "Is Fully Depreciated"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus > 0",
|
||||
|
||||
@@ -132,10 +132,6 @@ class Asset(AccountsController):
|
||||
self.validate_gross_and_purchase_amount()
|
||||
self.validate_finance_books()
|
||||
|
||||
if self.calculate_depreciation:
|
||||
# Is Fully Depreciated is only applicable to manually entered existing assets
|
||||
self.is_fully_depreciated = 0
|
||||
|
||||
def before_save(self):
|
||||
self.total_asset_cost = self.net_purchase_amount + self.additional_asset_cost
|
||||
self.status = self.get_status()
|
||||
|
||||
@@ -187,7 +187,6 @@ def make_depreciation_entry(
|
||||
for d in depr_schedule_doc.get("depreciation_schedule")[
|
||||
(sch_start_idx or 0) : (sch_end_idx or len(depr_schedule_doc.get("depreciation_schedule")))
|
||||
]:
|
||||
frappe.db.savepoint("depr_entry")
|
||||
try:
|
||||
_make_journal_entry_for_depreciation(
|
||||
depr_schedule_doc,
|
||||
@@ -203,7 +202,6 @@ def make_depreciation_entry(
|
||||
accounting_dimensions,
|
||||
)
|
||||
except Exception as e:
|
||||
frappe.db.rollback(save_point="depr_entry")
|
||||
depr_posting_error = e
|
||||
|
||||
asset.reload()
|
||||
|
||||
@@ -139,7 +139,6 @@ class AssetMovement(Document):
|
||||
.select(asm_item.target_location, asm_item.to_employee)
|
||||
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
|
||||
.orderby(asm.transaction_date, order=frappe.qb.desc)
|
||||
.orderby(asm.name, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
@@ -238,13 +238,22 @@ class TestAssetRepair(ERPNextTestSuite):
|
||||
submit=1,
|
||||
)
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit"))
|
||||
.where((gle.voucher_type == "Asset Repair") & (gle.voucher_no == asset_repair.name))
|
||||
.groupby(gle.account)
|
||||
).run(as_dict=True)
|
||||
gl_entries = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
account,
|
||||
sum(debit) as debit,
|
||||
sum(credit) as credit
|
||||
from `tabGL Entry`
|
||||
where
|
||||
voucher_type='Asset Repair'
|
||||
and voucher_no=%s
|
||||
group by
|
||||
account
|
||||
""",
|
||||
asset_repair.name,
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -278,13 +287,22 @@ class TestAssetRepair(ERPNextTestSuite):
|
||||
submit=1,
|
||||
)
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit"))
|
||||
.where((gle.voucher_type == "Asset Repair") & (gle.voucher_no == asset_repair.name))
|
||||
.groupby(gle.account)
|
||||
).run(as_dict=True)
|
||||
gl_entries = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
account,
|
||||
sum(debit) as debit,
|
||||
sum(credit) as credit
|
||||
from `tabGL Entry`
|
||||
where
|
||||
voucher_type='Asset Repair'
|
||||
and voucher_no=%s
|
||||
group by
|
||||
account
|
||||
""",
|
||||
asset_repair.name,
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
|
||||
@@ -94,12 +94,11 @@ class TestAssetValueAdjustment(ERPNextTestSuite):
|
||||
("_Test Fixed Asset - _TC", 0.0, 4625.29),
|
||||
)
|
||||
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Journal Entry", "voucher_no": adj_doc.journal_entry},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account",
|
||||
as_list=True,
|
||||
gle = frappe.db.sql(
|
||||
"""select account, debit, credit from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_no = %s
|
||||
order by account""",
|
||||
adj_doc.journal_entry,
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
@@ -185,12 +184,11 @@ class TestAssetValueAdjustment(ERPNextTestSuite):
|
||||
("_Test Fixed Asset - _TC", 0.0, 5175.29),
|
||||
)
|
||||
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Journal Entry", "voucher_no": adj_doc.journal_entry},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account",
|
||||
as_list=True,
|
||||
gle = frappe.db.sql(
|
||||
"""select account, debit, credit from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_no = %s
|
||||
order by account""",
|
||||
adj_doc.journal_entry,
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
|
||||
@@ -3,26 +3,11 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
|
||||
from erpnext.assets.doctype.asset_capitalization.test_asset_capitalization import (
|
||||
create_asset_capitalization,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_value_adjustment.test_asset_value_adjustment import (
|
||||
make_asset_value_adjustment,
|
||||
)
|
||||
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
|
||||
|
||||
|
||||
class TestFixedAssetRegister(AssetSetup):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(company="_Test Company", **extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def report_row(self, asset_name, **extra):
|
||||
return next(row for row in self.run_report(**extra) if row["asset_id"] == asset_name)
|
||||
|
||||
def test_report_lists_submitted_asset(self):
|
||||
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
|
||||
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
|
||||
@@ -33,170 +18,16 @@ class TestFixedAssetRegister(AssetSetup):
|
||||
location="Test Location",
|
||||
submit=1,
|
||||
)
|
||||
ids = {
|
||||
row["asset_id"]
|
||||
for row in self.run_report(
|
||||
status="In Location",
|
||||
filter_based_on="Date Range",
|
||||
from_date="2020-01-01",
|
||||
to_date="2030-12-31",
|
||||
date_based_on="Purchase Date",
|
||||
)
|
||||
}
|
||||
self.assertIn(asset.name, ids)
|
||||
|
||||
def test_asset_appears_with_purchase_value(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"status": "In Location",
|
||||
"filter_based_on": "Date Range",
|
||||
"from_date": "2020-01-01",
|
||||
"to_date": "2030-12-31",
|
||||
"date_based_on": "Purchase Date",
|
||||
}
|
||||
)
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["net_purchase_amount"], 100000)
|
||||
self.assertEqual(row["asset_value"], 100000) # no depreciation yet
|
||||
self.assertEqual(row["asset_category"], "Computers")
|
||||
|
||||
def test_asset_value_reduced_by_opening_depreciation(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
opening_accumulated_depreciation=20000,
|
||||
opening_number_of_booked_depreciations=2,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["opening_accumulated_depreciation"], 20000)
|
||||
self.assertEqual(row["asset_value"], 80000) # 100000 - 20000
|
||||
|
||||
def test_status_in_location_filter_shows_active_asset(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
ids = {row["asset_id"] for row in self.run_report(status="In Location")}
|
||||
self.assertIn(asset.name, ids)
|
||||
|
||||
def test_asset_category_filter(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
ids = {row["asset_id"] for row in self.run_report(asset_category="Computers")}
|
||||
self.assertIn(asset.name, ids)
|
||||
|
||||
def test_group_by_asset_category_sums_values(self):
|
||||
before_net, before_value = self.computers_group_totals()
|
||||
|
||||
create_asset(item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True)
|
||||
create_asset(
|
||||
item_code="Macbook Pro",
|
||||
asset_name="Macbook Pro 2",
|
||||
net_purchase_amount=50000,
|
||||
purchase_amount=50000,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
after_net, after_value = self.computers_group_totals()
|
||||
# assert on the delta so pre-existing Computers assets don't skew the totals
|
||||
self.assertEqual(after_net - before_net, 150000)
|
||||
self.assertEqual(after_value - before_value, 150000)
|
||||
|
||||
def computers_group_totals(self):
|
||||
row = next(
|
||||
(r for r in self.run_report(group_by="Asset Category") if r["asset_category"] == "Computers"),
|
||||
None,
|
||||
)
|
||||
return (row["net_purchase_amount"], row["asset_value"]) if row else (0, 0)
|
||||
|
||||
def test_booked_depreciation_reduces_asset_value(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
calculate_depreciation=1,
|
||||
available_for_use_date="2019-12-31",
|
||||
depreciation_start_date="2020-12-31",
|
||||
frequency_of_depreciation=12,
|
||||
total_number_of_depreciations=3,
|
||||
expected_value_after_useful_life=10000,
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# books one depreciation entry of (100000 - 10000) / 3 = 30000
|
||||
post_depreciation_entries(date="2021-01-01")
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["depreciated_amount"], 30000)
|
||||
self.assertEqual(row["asset_value"], 70000) # 100000 - 30000
|
||||
|
||||
def test_revaluation_adjusts_asset_value(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
# revalue the asset upwards by 20000
|
||||
make_asset_value_adjustment(
|
||||
asset=asset.name, current_asset_value=100000, new_asset_value=120000
|
||||
).submit()
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["asset_value"], 120000) # 100000 + 20000 revaluation
|
||||
|
||||
def test_depreciation_and_revaluation_together(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
calculate_depreciation=1,
|
||||
available_for_use_date="2019-12-31",
|
||||
depreciation_start_date="2020-12-31",
|
||||
frequency_of_depreciation=12,
|
||||
total_number_of_depreciations=3,
|
||||
expected_value_after_useful_life=10000,
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# books one depreciation entry of (100000 - 10000) / 3 = 30000, leaving 70000
|
||||
post_depreciation_entries(date="2021-01-01")
|
||||
|
||||
# revalue the depreciated asset down from 70000 to 60000
|
||||
make_asset_value_adjustment(
|
||||
asset=asset.name, current_asset_value=70000, new_asset_value=60000
|
||||
).submit()
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["depreciated_amount"], 30000)
|
||||
self.assertEqual(row["asset_value"], 60000) # 100000 - 30000 depreciation - 10000 revaluation
|
||||
|
||||
def test_sold_asset_hidden_from_in_location_and_shown_in_disposed(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
create_sales_invoice(item_code="Macbook Pro", asset=asset.name, qty=1, rate=80000)
|
||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
|
||||
|
||||
self.assertNotIn(asset.name, {row["asset_id"] for row in self.run_report(status="In Location")})
|
||||
self.assertIn(asset.name, {row["asset_id"] for row in self.run_report(status="Disposed")})
|
||||
|
||||
def test_capitalized_asset_hidden_from_in_location_and_shown_in_disposed(self):
|
||||
consumed_asset = create_asset(
|
||||
asset_name="Consumed Asset",
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
submit=True,
|
||||
)
|
||||
composite_asset = create_asset(
|
||||
asset_name="Composite Asset", asset_type="Composite Asset", submit=False
|
||||
)
|
||||
|
||||
create_asset_capitalization(
|
||||
target_asset=composite_asset.name, consumed_asset=consumed_asset.name, submit=1
|
||||
)
|
||||
self.assertEqual(frappe.db.get_value("Asset", consumed_asset.name, "status"), "Capitalized")
|
||||
|
||||
self.assertNotIn(
|
||||
consumed_asset.name, {row["asset_id"] for row in self.run_report(status="In Location")}
|
||||
)
|
||||
self.assertIn(consumed_asset.name, {row["asset_id"] for row in self.run_report(status="Disposed")})
|
||||
data = execute(filters)[1]
|
||||
asset_ids = {row.get("asset_id") for row in data}
|
||||
self.assertIn(asset.name, asset_ids)
|
||||
|
||||
@@ -232,11 +232,9 @@ def make_subcontracting_order(
|
||||
target_doc.save()
|
||||
|
||||
if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
|
||||
frappe.db.savepoint("submit_subcontracting_order")
|
||||
try:
|
||||
target_doc.submit()
|
||||
except Exception as e:
|
||||
frappe.db.rollback(save_point="submit_subcontracting_order")
|
||||
target_doc.add_comment("Comment", _("Submit Action Failed") + "<br><br>" + str(e))
|
||||
|
||||
if notify:
|
||||
|
||||
@@ -547,7 +547,6 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
@@ -562,7 +561,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-27 16:12:33.190257",
|
||||
"modified": "2026-06-22 12:23:09.241125",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -405,10 +405,16 @@ def get_default_scorecard_standing():
|
||||
def make_default_records():
|
||||
install_variable_docs = get_default_scorecard_variables()
|
||||
for d in install_variable_docs:
|
||||
d["doctype"] = "Supplier Scorecard Variable"
|
||||
frappe.get_doc(d).insert(ignore_if_duplicate=True)
|
||||
try:
|
||||
d["doctype"] = "Supplier Scorecard Variable"
|
||||
frappe.get_doc(d).insert()
|
||||
except frappe.NameError:
|
||||
pass
|
||||
|
||||
install_standing_docs = get_default_scorecard_standing()
|
||||
for d in install_standing_docs:
|
||||
d["doctype"] = "Supplier Scorecard Standing"
|
||||
frappe.get_doc(d).insert(ignore_if_duplicate=True)
|
||||
try:
|
||||
d["doctype"] = "Supplier Scorecard Standing"
|
||||
frappe.get_doc(d).insert()
|
||||
except frappe.NameError:
|
||||
pass
|
||||
|
||||
@@ -274,7 +274,7 @@ class AccountsController(TransactionBase):
|
||||
if invalid_advances := [x for x in self.advances if not x.reference_type or not x.reference_name]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Rows: {0} in {1} section are invalid. Reference Name should point to a valid Payment Entry or Journal Entry."
|
||||
"Rows: {0} in {1} section are Invalid. Reference Name should point to a valid Payment Entry or Journal Entry."
|
||||
).format(
|
||||
frappe.bold(comma_and([x.idx for x in invalid_advances])),
|
||||
frappe.bold(_("Advance Payments")),
|
||||
@@ -1233,7 +1233,7 @@ class AccountsController(TransactionBase):
|
||||
{"sales_order": None, "sales_order_item": None},
|
||||
)
|
||||
|
||||
frappe.msgprint(_("Purchase Orders {0} are unlinked").format("\n".join(linked_po)))
|
||||
frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po)))
|
||||
|
||||
def get_company_default(self, fieldname, ignore_validation=False):
|
||||
from erpnext.accounts.utils import get_company_default
|
||||
|
||||
@@ -129,7 +129,7 @@ class BuyingController(SubcontractingController):
|
||||
msg += f"<li>{po} ({date})</li>"
|
||||
msg += "</ul>"
|
||||
|
||||
frappe.throw(msg)
|
||||
frappe.throw(_(msg))
|
||||
|
||||
def create_package_for_transfer(self) -> None:
|
||||
"""Create serial and batch package for Sourece Warehouse in case of inter transfer."""
|
||||
@@ -287,7 +287,7 @@ class BuyingController(SubcontractingController):
|
||||
if self.is_return and len(not_cancelled_asset):
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} has submitted assets linked to it. You need to cancel the assets to create purchase return."
|
||||
"{} has submitted assets linked to it. You need to cancel the assets to create purchase return."
|
||||
).format(self.return_against),
|
||||
title=_("Not Allowed"),
|
||||
)
|
||||
@@ -738,7 +738,7 @@ class BuyingController(SubcontractingController):
|
||||
frappe.throw(
|
||||
_("Row #{idx}: {field_label} can not be negative for item {item_code}.").format(
|
||||
idx=item_row["idx"],
|
||||
field_label=_(frappe.get_meta(item_row.doctype).get_label(fieldname)),
|
||||
field_label=frappe.get_meta(item_row.doctype).get_label(fieldname),
|
||||
item_code=frappe.bold(item_row["item_code"]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Abs, NullIf, Sum
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.utils import cint, flt, format_datetime, get_datetime
|
||||
|
||||
import erpnext
|
||||
@@ -77,7 +77,7 @@ def validate_return_against(doc):
|
||||
# validate update stock
|
||||
if doc.doctype == "Sales Invoice" and doc.update_stock and not ref_doc.update_stock:
|
||||
frappe.throw(
|
||||
_("'Update Stock' cannot be checked because items are not delivered via {0}").format(
|
||||
_("'Update Stock' can not be checked because items are not delivered via {0}").format(
|
||||
doc.return_against
|
||||
)
|
||||
)
|
||||
@@ -145,7 +145,7 @@ def validate_returned_items(doc):
|
||||
ref.rate
|
||||
and flt(d.rate) > ref.rate
|
||||
and doc.doctype in ("Delivery Note", "Sales Invoice")
|
||||
and get_valuation_method(d.item_code, doc.company) != "Moving Average"
|
||||
and get_valuation_method(ref.item_code, doc.company) != "Moving Average"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format(
|
||||
@@ -766,7 +766,7 @@ def get_rate_for_return(
|
||||
select_field = "incoming_rate"
|
||||
else:
|
||||
StockLedgerEntry = frappe.qb.DocType("Stock Ledger Entry")
|
||||
select_field = Abs(StockLedgerEntry.stock_value_difference / NullIf(StockLedgerEntry.actual_qty, 0))
|
||||
select_field = Abs(StockLedgerEntry.stock_value_difference / StockLedgerEntry.actual_qty)
|
||||
|
||||
item_details = frappe.get_cached_value("Item", item_code, ["has_batch_no", "has_expiry_date"], as_dict=1)
|
||||
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
|
||||
|
||||
@@ -297,7 +297,7 @@ class SellingController(StockController):
|
||||
throw(
|
||||
_(
|
||||
"""Row #{0}: Selling rate for item {1} is lower than its {2}.
|
||||
Selling {3} should be at least {4}.<br><br>Alternatively,
|
||||
Selling {3} should be atleast {4}.<br><br>Alternatively,
|
||||
you can disable '{5}' in {6} to bypass
|
||||
this validation."""
|
||||
).format(
|
||||
@@ -869,7 +869,7 @@ class SellingController(StockController):
|
||||
|
||||
duplicate_items_msg = _("Item {0} entered multiple times.").format(frappe.bold(d.item_code))
|
||||
duplicate_items_msg += "<br><br>"
|
||||
duplicate_items_msg += _("Please enable {0} in {1} to allow same item in multiple rows").format(
|
||||
duplicate_items_msg += _("Please enable {} in {} to allow same item in multiple rows").format(
|
||||
frappe.bold(_("Allow Item to Be Added Multiple Times in a Transaction")),
|
||||
get_link_to_form("Selling Settings", "Selling Settings"),
|
||||
)
|
||||
@@ -898,7 +898,7 @@ class SellingController(StockController):
|
||||
|
||||
if not self.get("is_internal_customer") and any(d.get("target_warehouse") for d in items):
|
||||
msg = _("Target Warehouse is set for some items but the customer is not an internal customer.")
|
||||
msg += " " + _("This {0} will be treated as material transfer.").format(_(self.doctype))
|
||||
msg += " " + _("This {} will be treated as material transfer.").format(_(self.doctype))
|
||||
frappe.msgprint(msg, title="Internal Transfer", alert=True)
|
||||
|
||||
def validate_items(self):
|
||||
|
||||
@@ -144,7 +144,7 @@ status_map = {
|
||||
],
|
||||
[
|
||||
"Partially Ordered",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
|
||||
],
|
||||
],
|
||||
"POS Opening Entry": [
|
||||
@@ -286,10 +286,10 @@ class StatusUpdater(Document):
|
||||
# get unique transactions to update
|
||||
for d in self.get_all_children():
|
||||
if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be a positive number").format(d.item_code))
|
||||
frappe.throw(_("For an item {0}, quantity must be positive number").format(d.item_code))
|
||||
|
||||
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be a negative number").format(d.item_code))
|
||||
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
|
||||
|
||||
if (
|
||||
not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"]
|
||||
@@ -300,7 +300,7 @@ class StatusUpdater(Document):
|
||||
if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"For item {0}, rate must be a positive number. To allow negative rates, enable {1} in {2}"
|
||||
"For item {0}, rate must be a positive number. To Allow negative rates, enable {1} in {2}"
|
||||
).format(
|
||||
frappe.bold(d.item_code),
|
||||
frappe.bold(_("`Allow Negative rates for Items`")),
|
||||
|
||||
@@ -820,8 +820,6 @@ def create_item_wise_repost_entries(
|
||||
):
|
||||
"""Using a voucher create repost item valuation records for all item-warehouse pairs."""
|
||||
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
stock_ledger_entries = get_items_to_be_repost(voucher_type, voucher_no)
|
||||
|
||||
distinct_item_warehouses = set()
|
||||
@@ -833,11 +831,6 @@ def create_item_wise_repost_entries(
|
||||
continue
|
||||
distinct_item_warehouses.add(item_wh)
|
||||
|
||||
# Standard Cost items don't need a full repost: a backdated entry only shifts future balances
|
||||
# (qty and value at the standard rate), which is done in place by update_qty_in_future_sle.
|
||||
if get_valuation_method(sle.item_code) == "Standard Cost":
|
||||
continue
|
||||
|
||||
repost_entry = frappe.new_doc("Repost Item Valuation")
|
||||
repost_entry.based_on = "Item and Warehouse"
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ class SubcontractingController(StockController):
|
||||
)
|
||||
if bom_item != item.item_code:
|
||||
frappe.throw(
|
||||
_("Row {0}: Please select a valid BOM for Item {1}.").format(
|
||||
_("Row {0}: Please select an valid BOM for Item {1}.").format(
|
||||
item.idx, item.item_name
|
||||
)
|
||||
)
|
||||
@@ -1053,10 +1053,8 @@ class SubcontractingController(StockController):
|
||||
link = get_link_to_form(
|
||||
self.subcontract_data.order_doctype, row.get(self.subcontract_data.order_field)
|
||||
)
|
||||
msg = _("The Batch No {0} has not been supplied against the {1} {2}").format(
|
||||
frappe.bold(row.get("batch_no")), self.subcontract_data.order_doctype, link
|
||||
)
|
||||
frappe.throw(msg, title=_("Incorrect Batch Consumed"))
|
||||
msg = f'The Batch No {frappe.bold(row.get("batch_no"))} has not supplied against the {self.subcontract_data.order_doctype} {link}'
|
||||
frappe.throw(_(msg), title=_("Incorrect Batch Consumed"))
|
||||
|
||||
def __validate_serial_no(self, row, key):
|
||||
if row.get("serial_and_batch_bundle") and self.__transferred_items.get(key).get("serial_no"):
|
||||
@@ -1068,10 +1066,8 @@ class SubcontractingController(StockController):
|
||||
link = get_link_to_form(
|
||||
self.subcontract_data.order_doctype, row.get(self.subcontract_data.order_field)
|
||||
)
|
||||
msg = _("The Serial Nos {0} have not been supplied against the {1} {2}").format(
|
||||
incorrect_sn, self.subcontract_data.order_doctype, link
|
||||
)
|
||||
frappe.throw(msg, title=_("Incorrect Serial Number Consumed"))
|
||||
msg = f"The Serial Nos {incorrect_sn} has not supplied against the {self.subcontract_data.order_doctype} {link}"
|
||||
frappe.throw(_(msg), title=_("Incorrect Serial Number Consumed"))
|
||||
|
||||
def __validate_supplied_or_received_items(self):
|
||||
if self.doctype not in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
|
||||
|
||||
@@ -78,7 +78,7 @@ class SubcontractingInwardController:
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Item {1} mismatch. Changing the item code is not permitted, add another row instead."
|
||||
"Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead."
|
||||
).format(item.idx, get_link_to_form("Item", item.item_code))
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ class SubcontractingInwardController:
|
||||
or frappe.get_cached_value("Subcontracting Inward Order Item", item.scio_detail, "item_code")
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Item {1} mismatch. Changing the item code is not permitted.").format(
|
||||
_("Row #{0}: Item {1} mismatch. Changing of item code is not permitted.").format(
|
||||
item.idx, get_link_to_form("Item", item.item_code)
|
||||
)
|
||||
)
|
||||
@@ -441,7 +441,7 @@ class SubcontractingInwardController:
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Batch No(s) {1} are not a part of the linked Subcontracting Inward Order. Please select valid Batch No(s)."
|
||||
"Row #{0}: Batch No(s) {1} is not a part of the linked Subcontracting Inward Order. Please select valid Batch No(s)."
|
||||
).format(
|
||||
item.idx,
|
||||
", ".join([get_link_to_form("Batch No", bn) for bn in incorrect_batch_nos]),
|
||||
|
||||
@@ -131,9 +131,9 @@ class calculate_taxes_and_totals:
|
||||
if item.item_tax_template not in taxes:
|
||||
item.item_tax_template = taxes[0]
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"Row {0}: Item Tax template for {1} updated as per validity and rate applied"
|
||||
).format(item.idx, frappe.bold(item.item_code))
|
||||
_("Row {0}: Item Tax template updated as per validity and rate applied").format(
|
||||
item.idx, frappe.bold(item.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
# For correct tax_amount calculation re-computation is required
|
||||
@@ -564,7 +564,7 @@ class calculate_taxes_and_totals:
|
||||
+ "<br>".join(invalid_rows)
|
||||
)
|
||||
|
||||
frappe.throw(message)
|
||||
frappe.throw(_(message))
|
||||
|
||||
def get_tax_amount_if_for_valuation_or_deduction(self, tax_amount, tax):
|
||||
# if just for valuation, do not add the tax amount in total
|
||||
|
||||
@@ -56,10 +56,10 @@ def validate_filters(filters):
|
||||
frappe.throw(_("{0} is mandatory").format(_(f)))
|
||||
|
||||
if not frappe.db.exists("Fiscal Year", filters.get("fiscal_year")):
|
||||
frappe.throw(_("Fiscal Year {0} does not exist").format(filters.get("fiscal_year")))
|
||||
frappe.throw(_("Fiscal Year {0} Does Not Exist").format(filters.get("fiscal_year")))
|
||||
|
||||
if filters.get("based_on") == filters.get("group_by"):
|
||||
frappe.throw(_("'Based On' and 'Group By' can not be the same"))
|
||||
frappe.throw(_("'Based On' and 'Group By' can not be same"))
|
||||
|
||||
if filters.get("period_based_on") and filters.period_based_on not in ["bill_date", "posting_date"]:
|
||||
frappe.throw(
|
||||
|
||||
@@ -308,4 +308,4 @@ def add_role_for_portal_user(portal_user, role):
|
||||
return
|
||||
|
||||
user_doc.add_roles(role)
|
||||
frappe.msgprint(_("Added {1} role to user {0}.").format(frappe.bold(user_doc.name), role), alert=True)
|
||||
frappe.msgprint(_("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True)
|
||||
|
||||
@@ -59,7 +59,7 @@ class AppointmentBookingSettings(Document):
|
||||
err_msg = _("<b>From Time</b> cannot be later than <b>To Time</b> for {0}").format(
|
||||
record.day_of_week
|
||||
)
|
||||
frappe.throw(err_msg)
|
||||
frappe.throw(_(err_msg))
|
||||
|
||||
def duration_is_divisible(self, from_time, to_time):
|
||||
timedelta = to_time - from_time
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class CRMSettings(Document):
|
||||
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please add at least one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -174,7 +174,6 @@ def send_mail(entry, email_campaign):
|
||||
subject = frappe.render_template(email_template.get("subject"), context)
|
||||
content = frappe.render_template(email_template.response_, context)
|
||||
|
||||
frappe.db.savepoint("email_campaign_send")
|
||||
try:
|
||||
comm = make(
|
||||
doctype="Email Campaign",
|
||||
@@ -198,7 +197,6 @@ def send_mail(entry, email_campaign):
|
||||
queue_separately=True,
|
||||
)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="email_campaign_send")
|
||||
frappe.log_error(title="Email Campaign Failed.")
|
||||
|
||||
return comm
|
||||
|
||||
@@ -26,7 +26,6 @@ def create_prospect_against_crm_deal():
|
||||
prospect.insert()
|
||||
prospect_name = prospect.name
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(
|
||||
frappe.get_traceback(),
|
||||
f"Error while creating prospect against CRM Deal: {frappe.form_dict.get('crm_deal_id')}",
|
||||
@@ -71,7 +70,6 @@ def create_address(doctype, docname, address):
|
||||
if not address:
|
||||
return
|
||||
address = frappe.parse_json(address)
|
||||
frappe.db.savepoint("crm_create_address")
|
||||
try:
|
||||
_address = frappe.db.exists("Address", address.get("name"))
|
||||
if not _address:
|
||||
@@ -99,7 +97,6 @@ def create_address(doctype, docname, address):
|
||||
address.save(ignore_permissions=True)
|
||||
return address.name
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="crm_create_address")
|
||||
frappe.log_error(frappe.get_traceback(), f"Error while creating address for {docname}")
|
||||
|
||||
|
||||
@@ -160,7 +157,6 @@ def create_customer(customer_data: dict | None = None):
|
||||
create_address("Customer", customer_name, customer_data.get("address"))
|
||||
return customer_name
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
|
||||
pass
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ def add_institution(token: str, response: str | dict):
|
||||
)
|
||||
bank.insert()
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error("Plaid Link Error")
|
||||
else:
|
||||
bank = frappe.get_doc("Bank", response["institution"]["name"])
|
||||
@@ -155,7 +154,6 @@ def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
|
||||
)
|
||||
|
||||
else:
|
||||
frappe.db.savepoint("plaid_update_account")
|
||||
try:
|
||||
existing_account = frappe.get_doc("Bank Account", existing_bank_account)
|
||||
existing_account.update(
|
||||
@@ -171,10 +169,9 @@ def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
|
||||
existing_account.save()
|
||||
result.append(existing_bank_account)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="plaid_update_account")
|
||||
frappe.log_error("Plaid Link Error")
|
||||
frappe.throw(
|
||||
_("There was an error updating Bank Account {0} while linking with Plaid.").format(
|
||||
_("There was an error updating Bank Account {} while linking with Plaid.").format(
|
||||
existing_bank_account
|
||||
),
|
||||
title=_("Plaid Link Failed"),
|
||||
|
||||
3274
erpnext/locale/ar.po
3274
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
62914
erpnext/locale/bg.po
62914
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3316
erpnext/locale/bs.po
3316
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2996
erpnext/locale/cs.po
2996
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
2992
erpnext/locale/da.po
2992
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3307
erpnext/locale/de.po
3307
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3294
erpnext/locale/eo.po
3294
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3238
erpnext/locale/es.po
3238
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3246
erpnext/locale/fa.po
3246
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3114
erpnext/locale/fr.po
3114
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3008
erpnext/locale/hi.po
3008
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3385
erpnext/locale/hr.po
3385
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3002
erpnext/locale/hu.po
3002
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3108
erpnext/locale/id.po
3108
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
2994
erpnext/locale/it.po
2994
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3052
erpnext/locale/ko.po
3052
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2984
erpnext/locale/my.po
2984
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3008
erpnext/locale/nb.po
3008
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3293
erpnext/locale/nl.po
3293
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3018
erpnext/locale/pl.po
3018
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2996
erpnext/locale/pt.po
2996
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3293
erpnext/locale/ru.po
3293
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3012
erpnext/locale/sl.po
3012
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user