mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 23:48:38 +00:00
Merge remote-tracking branch 'origin/develop' into chore/test-stock-and-account-value-comparison
# Conflicts: # erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
This commit is contained in:
49
.github/POSTGRES_COMPATIBILITY.md
vendored
49
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -45,7 +45,9 @@ 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.
|
||||
- **`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.
|
||||
- **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
|
||||
@@ -119,7 +121,7 @@ These don't error, so a one-engine CI stays green. Flag them:
|
||||
|
||||
---
|
||||
|
||||
## 3. The `GROUP BY` row-count trap (the single most important rule)
|
||||
## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (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
|
||||
@@ -140,6 +142,14 @@ 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
|
||||
@@ -167,17 +177,44 @@ These are auto-handled by the framework and are **not** breaks:
|
||||
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
|
||||
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
|
||||
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
|
||||
- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work
|
||||
must survive.** A full rollback un-poisons the txn but also discards every row the handler committed
|
||||
*before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB
|
||||
regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback
|
||||
safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A
|
||||
full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway),
|
||||
(b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be
|
||||
**atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use
|
||||
a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract:
|
||||
do **not** return the doc when the savepoint was rolled back.
|
||||
|
||||
---
|
||||
|
||||
## 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), or
|
||||
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
|
||||
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
|
||||
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 are exactly what a reviewer (and this guide) must
|
||||
cover, because no static check can see them.
|
||||
§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.
|
||||
|
||||
12
.github/helper/install.sh
vendored
12
.github/helper/install.sh
vendored
@@ -297,14 +297,10 @@ 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;
|
||||
|
||||
# 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()";
|
||||
# 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.
|
||||
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.0
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
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
|
||||
# ~100MB from frappe.io every run.
|
||||
# it from the GitHub release every run.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
@@ -76,7 +76,10 @@ jobs:
|
||||
|
||||
- name: Download erpnext v14 backup
|
||||
if: steps.cache-v14.outputs.cache-hit != 'true'
|
||||
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
|
||||
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
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
||||
7
.github/workflows/server-tests-mariadb.yml
vendored
7
.github/workflows/server-tests-mariadb.yml
vendored
@@ -107,6 +107,13 @@ 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: |
|
||||
|
||||
13
.github/workflows/server-tests-postgres.yml
vendored
13
.github/workflows/server-tests-postgres.yml
vendored
@@ -105,10 +105,19 @@ jobs:
|
||||
FRAPPE_BRANCH: develop
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Warm up test data
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
|
||||
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
|
||||
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
|
||||
# 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
|
||||
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
|
||||
|
||||
- name: Package bench for test shards
|
||||
@@ -128,7 +137,7 @@ jobs:
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Python Unit Tests (PG)
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -48,7 +48,6 @@ repos:
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
erpnext/public/js/controllers/.*|
|
||||
erpnext/templates/pages/order.js|
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
|
||||
@@ -14,35 +14,35 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.15.0",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-router": "^8.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -51,15 +51,15 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
"typescript-eslint": "^8.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
@@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { DateField } from "@/components/ui/form-elements"
|
||||
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankClearanceSummary = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
|
||||
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
)
|
||||
|
||||
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
@@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
|
||||
})
|
||||
} else {
|
||||
|
||||
/**
|
||||
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
|
||||
* So we need to compute the value of the expression
|
||||
* We can use the eval function to do this. But we need to expose certain variables to the expression.
|
||||
* One of them is transaction_amount which is the unallocated amount of the selected transaction
|
||||
* @param expression - The expression to compute
|
||||
* @returns The computed value
|
||||
*/
|
||||
const computeExpression = (expression: string) => {
|
||||
|
||||
const script = `
|
||||
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
|
||||
${expression};
|
||||
`
|
||||
|
||||
let value = 0;
|
||||
|
||||
try {
|
||||
value = window.eval(script);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
|
||||
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
@@ -19,6 +18,7 @@ import _ from "@/lib/translate"
|
||||
import { toast } from "sonner"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankReconciliationStatement = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
|
||||
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
|
||||
}, [data])
|
||||
|
||||
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankTransactions = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
|
||||
|
||||
}, [data, search, amountFilter, typeFilter, status])
|
||||
|
||||
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
@@ -18,6 +17,7 @@ import { PartyPopper } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const IncorrectlyClearedEntries = () => {
|
||||
const companyID = useCurrentCompany()
|
||||
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
[accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
|
||||
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
}} />
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<MarkdownRenderer content={entriesContent} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</Paragraph>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { today } from "@/lib/date"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
@@ -445,11 +446,10 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
|
||||
// If it's a string and cannot be a number, then show it as a formula
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
|
||||
let calculatedValue = "";
|
||||
|
||||
try {
|
||||
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
26
banking/src/lib/amountFormula.ts
Normal file
26
banking/src/lib/amountFormula.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Parser } from 'safe-expr-eval'
|
||||
|
||||
const parser = new Parser()
|
||||
|
||||
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
|
||||
|
||||
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
|
||||
const trimmed = expression.trim()
|
||||
if (!trimmed) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
|
||||
return Number(trimmed)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
|
||||
if (typeof result !== 'number' || !Number.isFinite(result)) {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
2655
banking/yarn.lock
2655
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,12 @@ def normalize_ctx_input(T: type) -> callable:
|
||||
|
||||
def decorator(func: callable):
|
||||
# conserve annotations for frappe.utils.typing_validations
|
||||
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
|
||||
@functools.wraps(
|
||||
func,
|
||||
assigned=(
|
||||
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
|
||||
),
|
||||
)
|
||||
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
|
||||
if isinstance(ctx, Document):
|
||||
ctx = T(**ctx.as_dict())
|
||||
|
||||
129
erpnext/accounts/bulk_payment.py
Normal file
129
erpnext/accounts/bulk_payment.py
Normal file
@@ -0,0 +1,129 @@
|
||||
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,6 +582,7 @@ 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,6 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -36,8 +37,20 @@ 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 add_months, nowdate
|
||||
from frappe.utils import 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 add_months(nowdate(), 1)
|
||||
accounting_period.end_date = args.end_date or nowdate()
|
||||
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})
|
||||
|
||||
@@ -829,7 +829,9 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
amount = transaction_row.get("amount")
|
||||
float_amount = get_float_amount(amount)
|
||||
|
||||
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
|
||||
float_amount = abs(get_float_amount(amount) or 0)
|
||||
if "cr" in amount.lower():
|
||||
return 0, float_amount
|
||||
else:
|
||||
@@ -932,14 +934,18 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(io.BytesIO(content))
|
||||
if reader.is_encrypted and (not password or not reader.decrypt(password)):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
if reader.is_encrypted:
|
||||
# Try opening the PDF with a password - if no password is provided, try with a blank password
|
||||
if not password:
|
||||
password = ""
|
||||
if not reader.decrypt(password):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
|
||||
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
|
||||
tables = []
|
||||
|
||||
@@ -47,6 +47,7 @@ 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)
|
||||
@@ -56,7 +57,8 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
bank_transaction.submit()
|
||||
success += 1
|
||||
except Exception:
|
||||
bank_transaction.log_error("Bank entry creation failed")
|
||||
frappe.db.rollback(save_point="bank_entry")
|
||||
frappe.log_error(title="Bank entry creation failed")
|
||||
errors += 1
|
||||
|
||||
return {"success": success, "errors": errors}
|
||||
|
||||
@@ -9,6 +9,48 @@ from frappe.model.document import Document
|
||||
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
|
||||
|
||||
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
|
||||
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
|
||||
PYTHON_ONLY_OPERATORS = ("**", "//")
|
||||
|
||||
|
||||
def _is_expr_eval_formula(formula: str) -> bool:
|
||||
position = 0
|
||||
while position < len(formula):
|
||||
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
|
||||
if not match:
|
||||
return False
|
||||
position = match.end()
|
||||
|
||||
return formula.count("(") == formula.count(")")
|
||||
|
||||
|
||||
def validate_amount_formula(formula: str) -> None:
|
||||
if not formula:
|
||||
return
|
||||
|
||||
stripped = formula.strip()
|
||||
if PLAIN_NUMBER_PATTERN.match(stripped):
|
||||
return
|
||||
|
||||
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
if not _is_expr_eval_formula(stripped):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
|
||||
python_formula = stripped.replace("^", "**")
|
||||
|
||||
try:
|
||||
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
|
||||
except Exception:
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
if not isinstance(result, (int | float)):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
|
||||
class BankTransactionRule(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -86,6 +128,11 @@ class BankTransactionRule(Document):
|
||||
frappe.throw(
|
||||
_("The last account row must not have any debit or credit amounts set.")
|
||||
)
|
||||
else:
|
||||
if account.debit:
|
||||
validate_amount_formula(account.debit)
|
||||
if account.credit:
|
||||
validate_amount_formula(account.credit)
|
||||
|
||||
# Validate regex
|
||||
for rule in self.description_rules:
|
||||
|
||||
@@ -231,3 +231,45 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.insert()
|
||||
|
||||
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
|
||||
return self._rule(
|
||||
prefix,
|
||||
[{"check": "Contains", "value": "x"}],
|
||||
classify_as="Bank Entry",
|
||||
bank_entry_type="Multiple Accounts",
|
||||
accounts=accounts,
|
||||
**fields,
|
||||
)
|
||||
|
||||
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
|
||||
doc = self._multiple_accounts_rule(
|
||||
"be_formula",
|
||||
accounts=[
|
||||
{"account": self.bank, "debit": "200", "credit": ""},
|
||||
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
|
||||
{"account": self.cash, "debit": "", "credit": ""},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
|
||||
malicious_formulas = [
|
||||
"__import__('os')",
|
||||
"eval('1+1')",
|
||||
"open('/etc/passwd')",
|
||||
"transaction_amount ** 2",
|
||||
"transaction_amount // 2",
|
||||
]
|
||||
for formula in malicious_formulas:
|
||||
with self.subTest(formula=formula):
|
||||
doc = self._multiple_accounts_rule(
|
||||
"be_bad_formula",
|
||||
accounts=[
|
||||
{"account": self.bank, "debit": formula, "credit": ""},
|
||||
{"account": self.cash, "debit": "", "credit": ""},
|
||||
],
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.insert()
|
||||
|
||||
@@ -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 getting entries"))
|
||||
frappe.throw(_("Please select Company and Posting Date to get entries"))
|
||||
|
||||
def before_submit(self):
|
||||
self.remove_accounts_without_gain_loss()
|
||||
@@ -601,6 +601,7 @@ 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]
|
||||
)
|
||||
@@ -615,6 +616,7 @@ 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]
|
||||
)
|
||||
|
||||
@@ -107,6 +107,9 @@ def auto_create_fiscal_year():
|
||||
)
|
||||
|
||||
for d in fiscal_year:
|
||||
# savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the
|
||||
# statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration
|
||||
frappe.db.savepoint("auto_create_fiscal_year")
|
||||
try:
|
||||
current_fy = frappe.get_doc("Fiscal Year", d[0])
|
||||
|
||||
@@ -127,7 +130,7 @@ def auto_create_fiscal_year():
|
||||
|
||||
new_fy.insert(ignore_permissions=True)
|
||||
except frappe.NameError:
|
||||
pass
|
||||
frappe.db.rollback(save_point="auto_create_fiscal_year")
|
||||
|
||||
|
||||
def get_from_and_to_date(fiscal_year):
|
||||
|
||||
@@ -65,6 +65,7 @@ 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,
|
||||
@@ -79,8 +80,7 @@ def start_merge(docname):
|
||||
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
|
||||
)
|
||||
except Exception:
|
||||
if not frappe.in_test:
|
||||
frappe.db.rollback()
|
||||
frappe.db.rollback(save_point="ledger_merge_row")
|
||||
ledger_merge.log_error("Ledger merge failed")
|
||||
finally:
|
||||
if successful_merges == total:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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
|
||||
@@ -63,13 +64,9 @@ class TestLoyaltyPointEntry(ERPNextTestSuite):
|
||||
self.assertEqual(doc.loyalty_points, -7)
|
||||
|
||||
# Check balance
|
||||
balance = frappe.db.sql(
|
||||
"""
|
||||
SELECT SUM(loyalty_points)
|
||||
FROM `tabLoyalty Point Entry`
|
||||
WHERE customer = %s
|
||||
""",
|
||||
(self.customer_name,),
|
||||
)[0][0]
|
||||
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]
|
||||
|
||||
self.assertEqual(balance, 3) # 10 added, 7 redeemed
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 (
|
||||
@@ -262,14 +263,12 @@ class TestLoyaltyProgram(ERPNextTestSuite):
|
||||
|
||||
def get_points_earned(self):
|
||||
def get_returned_amount():
|
||||
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,
|
||||
)
|
||||
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()
|
||||
return abs(flt(returned_amount[0][0])) if returned_amount else 0
|
||||
|
||||
lp_details = get_loyalty_program_details_with_points(
|
||||
|
||||
@@ -514,10 +514,12 @@ 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):
|
||||
@@ -2793,6 +2795,9 @@ 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:
|
||||
|
||||
@@ -663,7 +663,6 @@ class POSInvoice(SalesInvoice):
|
||||
def set_pos_fields(self, for_validate=False):
|
||||
"""Set retail related fields from POS Profiles"""
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_pos_profile,
|
||||
get_pos_profile_item_details_,
|
||||
)
|
||||
@@ -736,7 +735,7 @@ class POSInvoice(SalesInvoice):
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), profile.get("company"), profile
|
||||
frappe._dict(item.as_dict()), profile.get("company"), profile
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
|
||||
@@ -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.sql("delete from `tabPOS Opening Entry`;")
|
||||
frappe.db.sql("delete from `tabPOS Closing Entry`;")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`;")
|
||||
frappe.db.delete("POS Opening Entry")
|
||||
frappe.db.delete("POS Closing Entry")
|
||||
frappe.db.delete("POS Invoice")
|
||||
|
||||
def setUp(self):
|
||||
self.clear_pos_data()
|
||||
|
||||
@@ -25,15 +25,11 @@ class TestPOSProfile(ERPNextTestSuite):
|
||||
items = get_items_list(doc, doc.company)
|
||||
customers = get_customers_list(doc)
|
||||
|
||||
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'"""
|
||||
)
|
||||
products_count = frappe.db.count("Item", {"item_group": "_Test Item Group"})
|
||||
customers_count = frappe.db.count("Customer", {"customer_group": "_Test Customer Group"})
|
||||
|
||||
self.assertEqual(len(items), products_count[0][0])
|
||||
self.assertEqual(len(customers), customers_count[0][0])
|
||||
self.assertEqual(len(items), products_count)
|
||||
self.assertEqual(len(customers), customers_count)
|
||||
|
||||
def test_disabled_pos_profile_creation(self):
|
||||
make_pos_profile(name="_Test POS Profile 001", disabled=1)
|
||||
@@ -83,7 +79,6 @@ 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
|
||||
@@ -91,14 +86,16 @@ 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"))]
|
||||
)
|
||||
cond = "customer_group in ({})".format(", ".join(["%s"] * len(customer_groups)))
|
||||
|
||||
filters = {"disabled": 0}
|
||||
if customer_groups:
|
||||
filters["customer_group"] = ["in", customer_groups]
|
||||
|
||||
return (
|
||||
frappe.db.sql(
|
||||
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
|
||||
and {cond}""",
|
||||
tuple(customer_groups),
|
||||
as_dict=1,
|
||||
frappe.get_all(
|
||||
"Customer",
|
||||
filters=filters,
|
||||
fields=["name", "customer_name", "customer_group", "territory"],
|
||||
)
|
||||
or {}
|
||||
)
|
||||
@@ -135,8 +132,8 @@ def get_items_list(pos_profile, company):
|
||||
|
||||
|
||||
def make_pos_profile(**args):
|
||||
frappe.db.sql("delete from `tabPOS Payment Method`")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.delete("POS Payment Method")
|
||||
frappe.db.delete("POS Profile")
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ class TestPricingRule(ERPNextTestSuite):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("discount_percentage"), 5)
|
||||
|
||||
frappe.db.sql("update `tabPricing Rule` set priority=NULL where campaign='_Test Campaign'")
|
||||
frappe.db.set_value(
|
||||
"Pricing Rule", {"campaign": "_Test Campaign"}, "priority", None, update_modified=False
|
||||
)
|
||||
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 Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.has_permission("Process Period Closing Voucher", "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,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -130,6 +131,7 @@ 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)
|
||||
@@ -329,20 +331,33 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
@@ -515,6 +530,107 @@ 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")
|
||||
@@ -536,7 +652,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
@@ -561,7 +677,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 = doc.get_company_default("default_expense_account")
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
|
||||
@@ -126,13 +126,13 @@ class POSService:
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
|
||||
from erpnext.stock.get_item_details import get_pos_profile_item_details_
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if not item.get("item_code"):
|
||||
continue
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
frappe._dict(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
|
||||
@@ -173,6 +173,10 @@ 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();
|
||||
@@ -182,9 +186,145 @@ 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() {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-25 12:03:36.559152",
|
||||
"modified": "2026-07-01 13:37:41.185347",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -40,6 +40,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -135,38 +135,9 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
@@ -193,12 +164,10 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
row = report[1][0]
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
self.assertEqual([pi.name, expected_payment_term], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
).insert()
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
@@ -227,9 +196,7 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
|
||||
).insert()
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-25 12:03:28.812092",
|
||||
"modified": "2026-07-01 13:37:44.167999",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -34,6 +34,6 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -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 AR Perm Customer"
|
||||
second_customer = "_Test Customer 1"
|
||||
|
||||
# create_customer overrides self.customer, so build the restricted invoice first
|
||||
self.create_customer(customer_name=second_customer)
|
||||
self.customer = second_customer
|
||||
self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
self.customer = original_customer
|
||||
|
||||
@@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
|
||||
return chart
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
|
||||
@@ -59,16 +59,9 @@ 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
|
||||
|
||||
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"
|
||||
asset_account = "Office Equipment - _TC"
|
||||
|
||||
before = self.net_change_in_cash()
|
||||
# debit the fixed asset, credit cash -> cash goes out
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:35.057216",
|
||||
"modified": "2026-07-01 13:36:06.682661",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ def get_columns(filters):
|
||||
return columns
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
|
||||
@@ -64,16 +64,12 @@ 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()
|
||||
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"
|
||||
)
|
||||
# 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"
|
||||
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
|
||||
|
||||
@@ -87,19 +83,13 @@ 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()
|
||||
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"
|
||||
)
|
||||
# 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"
|
||||
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,7 +562,12 @@ class GrossProfitGenerator:
|
||||
row.base_amount = packed_item.base_amount
|
||||
|
||||
# get buying amount
|
||||
if row.item_code in product_bundles:
|
||||
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:
|
||||
row.buying_amount = flt(
|
||||
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
|
||||
self.currency_precision,
|
||||
@@ -895,7 +900,11 @@ 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).limit(1)
|
||||
query = (
|
||||
query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc)
|
||||
.orderby(purchase_invoice.name, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
)
|
||||
last_purchase_rate = query.run()
|
||||
|
||||
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
||||
@@ -956,6 +965,7 @@ 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,
|
||||
@@ -1136,6 +1146,7 @@ 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,
|
||||
@@ -1174,6 +1185,7 @@ 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,6 +700,160 @@ 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}):
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:15.898375",
|
||||
"modified": "2026-07-01 13:36:14.934965",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
|
||||
return chart
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
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
|
||||
@@ -127,17 +128,32 @@ 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})
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -410,6 +426,7 @@ 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, today
|
||||
from frappe.utils import add_months, flt, 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,6 +90,35 @@ 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,17 +141,31 @@ 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})
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -448,6 +462,7 @@ def get_invoices(filters, additional_query_columns):
|
||||
si.is_internal_customer,
|
||||
si.represents_company,
|
||||
si.company,
|
||||
si.conversion_rate,
|
||||
)
|
||||
.where(si.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import frappe
|
||||
from frappe.utils import getdate, today
|
||||
from frappe.utils import add_days, flt, 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
|
||||
|
||||
@@ -249,3 +250,25 @@ 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))
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:42.740436",
|
||||
"modified": "2026-07-01 17:32:21.801141",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -583,7 +583,7 @@ def hide_group_accounts(data):
|
||||
return non_group_accounts_data
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
@@ -597,11 +597,21 @@ 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.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,
|
||||
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",
|
||||
)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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,6 +150,9 @@ def add_gl_entry(
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
if project:
|
||||
gl_entry["project"] = project
|
||||
|
||||
if voucher_detail_no:
|
||||
gl_entry["voucher_detail_no"] = voucher_detail_no
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from frappe.utils import cint, flt, parse_json
|
||||
import erpnext
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
ItemDetailsCtx,
|
||||
_get_item_tax_template,
|
||||
_get_item_tax_template_from_item_group,
|
||||
get_item_tax_map,
|
||||
@@ -350,7 +349,7 @@ def set_balance_in_account_currency(
|
||||
|
||||
|
||||
def set_child_tax_template_and_map(item, child_item, parent_doc) -> None:
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"posting_date": parent_doc.transaction_date,
|
||||
|
||||
@@ -93,6 +93,11 @@ 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)) {
|
||||
@@ -727,6 +732,10 @@ 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,10 +450,11 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:(doc.asset_type == \"Existing Asset\" && !doc.calculate_depreciation) || doc.calculate_depreciation",
|
||||
"fieldname": "is_fully_depreciated",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Fully Depreciated"
|
||||
"label": "Is Fully Depreciated",
|
||||
"read_only_depends_on": "eval:doc.calculate_depreciation"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus > 0",
|
||||
|
||||
@@ -132,6 +132,10 @@ 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,6 +187,7 @@ 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,
|
||||
@@ -202,6 +203,7 @@ def make_depreciation_entry(
|
||||
accounting_dimensions,
|
||||
)
|
||||
except Exception as e:
|
||||
frappe.db.rollback(save_point="depr_entry")
|
||||
depr_posting_error = e
|
||||
|
||||
asset.reload()
|
||||
|
||||
@@ -500,7 +500,7 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
|
||||
item_group_defaults = get_item_group_defaults(item.name, company)
|
||||
brand_defaults = get_brand_defaults(item.name, company)
|
||||
out.cost_center = get_default_cost_center(
|
||||
ItemDetailsCtx({"item_code": item.name, "company": company}),
|
||||
frappe._dict({"item_code": item.name, "company": company}),
|
||||
item_defaults,
|
||||
item_group_defaults,
|
||||
brand_defaults,
|
||||
|
||||
@@ -139,6 +139,7 @@ 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,22 +238,13 @@ class TestAssetRepair(ERPNextTestSuite):
|
||||
submit=1,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -287,22 +278,13 @@ class TestAssetRepair(ERPNextTestSuite):
|
||||
submit=1,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
|
||||
@@ -94,11 +94,12 @@ class TestAssetValueAdjustment(ERPNextTestSuite):
|
||||
("_Test Fixed Asset - _TC", 0.0, 4625.29),
|
||||
)
|
||||
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
@@ -184,11 +185,12 @@ class TestAssetValueAdjustment(ERPNextTestSuite):
|
||||
("_Test Fixed Asset - _TC", 0.0, 5175.29),
|
||||
)
|
||||
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
|
||||
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."""
|
||||
@@ -18,16 +33,170 @@ class TestFixedAssetRegister(AssetSetup):
|
||||
location="Test Location",
|
||||
submit=1,
|
||||
)
|
||||
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",
|
||||
}
|
||||
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
|
||||
)
|
||||
data = execute(filters)[1]
|
||||
asset_ids = {row.get("asset_id") for row in data}
|
||||
self.assertIn(asset.name, asset_ids)
|
||||
|
||||
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")})
|
||||
|
||||
@@ -232,9 +232,11 @@ 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:
|
||||
|
||||
@@ -1531,11 +1531,11 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
(via the standard item lookup the form uses) without going through
|
||||
the Sales Order → Purchase Order mapping pipeline.
|
||||
"""
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
|
||||
from erpnext.stock.get_item_details import get_item_details
|
||||
|
||||
item = make_item("_Test Drop Ship From Master", {"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"doctype": "Purchase Order",
|
||||
|
||||
@@ -547,6 +547,7 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
@@ -561,7 +562,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-22 12:23:09.241125",
|
||||
"modified": "2026-06-27 16:12:33.190257",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -55,9 +55,16 @@ class SupplierScorecard(Document):
|
||||
self.update_standing()
|
||||
|
||||
def on_update(self):
|
||||
score = make_all_scorecards(self.name)
|
||||
if score > 0:
|
||||
self.save()
|
||||
# Guard against recursion: the save() below re-enters on_update().
|
||||
if self.flags.in_rescore:
|
||||
return
|
||||
if make_all_scorecards(self.name) > 0:
|
||||
# New periods were created; re-save to refresh score and standings.
|
||||
self.flags.in_rescore = True
|
||||
try:
|
||||
self.save()
|
||||
finally:
|
||||
self.flags.in_rescore = False
|
||||
|
||||
def validate_standings(self):
|
||||
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps
|
||||
@@ -405,16 +412,10 @@ def get_default_scorecard_standing():
|
||||
def make_default_records():
|
||||
install_variable_docs = get_default_scorecard_variables()
|
||||
for d in install_variable_docs:
|
||||
try:
|
||||
d["doctype"] = "Supplier Scorecard Variable"
|
||||
frappe.get_doc(d).insert()
|
||||
except frappe.NameError:
|
||||
pass
|
||||
d["doctype"] = "Supplier Scorecard Variable"
|
||||
frappe.get_doc(d).insert(ignore_if_duplicate=True)
|
||||
|
||||
install_standing_docs = get_default_scorecard_standing()
|
||||
for d in install_standing_docs:
|
||||
try:
|
||||
d["doctype"] = "Supplier Scorecard Standing"
|
||||
frappe.get_doc(d).insert()
|
||||
except frappe.NameError:
|
||||
pass
|
||||
d["doctype"] = "Supplier Scorecard Standing"
|
||||
frappe.get_doc(d).insert(ignore_if_duplicate=True)
|
||||
|
||||
@@ -47,7 +47,6 @@ from erpnext.controllers.sales_and_purchase_return import validate_return
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_item_details,
|
||||
)
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
@@ -274,7 +273,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")),
|
||||
@@ -782,7 +781,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
ctx: ItemDetailsCtx = ItemDetailsCtx(parent_dict.copy())
|
||||
ctx: frappe._dict = frappe._dict(parent_dict.copy())
|
||||
ctx.update(item.as_dict())
|
||||
|
||||
ctx.update(
|
||||
@@ -1233,7 +1232,7 @@ class AccountsController(TransactionBase):
|
||||
{"sales_order": None, "sales_order_item": None},
|
||||
)
|
||||
|
||||
frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po)))
|
||||
frappe.msgprint(_("Purchase Orders {0} are unlinked").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(
|
||||
_(
|
||||
"{} has submitted assets linked to it. You need to cancel the assets to create purchase return."
|
||||
"{0} 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"]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
from erpnext.utilities.query import get_filter_conditions_qb
|
||||
|
||||
@@ -1056,7 +1056,7 @@ def get_tax_template(doctype: str, txt: str, searchfield: str, start: int, page_
|
||||
valid_from = filters.get("valid_from")
|
||||
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
|
||||
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"item_code": filters.get("item_code"),
|
||||
"posting_date": valid_from,
|
||||
|
||||
@@ -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, Sum
|
||||
from frappe.query_builder.functions import Abs, NullIf, 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' can not be checked because items are not delivered via {0}").format(
|
||||
_("'Update Stock' cannot 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(ref.item_code, doc.company) != "Moving Average"
|
||||
and get_valuation_method(d.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 / StockLedgerEntry.actual_qty)
|
||||
select_field = Abs(StockLedgerEntry.stock_value_difference / NullIf(StockLedgerEntry.actual_qty, 0))
|
||||
|
||||
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 atleast {4}.<br><br>Alternatively,
|
||||
Selling {3} should be at least {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 {} in {} to allow same item in multiple rows").format(
|
||||
duplicate_items_msg += _("Please enable {0} in {1} 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 {} will be treated as material transfer.").format(_(self.doctype))
|
||||
msg += " " + _("This {0} 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.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.per_received < 100 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 positive number").format(d.item_code))
|
||||
frappe.throw(_("For an item {0}, quantity must be a 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 negative number").format(d.item_code))
|
||||
frappe.throw(_("For an item {0}, quantity must be a 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,6 +820,8 @@ 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()
|
||||
@@ -831,6 +833,11 @@ 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 an valid BOM for Item {1}.").format(
|
||||
_("Row {0}: Please select a valid BOM for Item {1}.").format(
|
||||
item.idx, item.item_name
|
||||
)
|
||||
)
|
||||
@@ -1053,8 +1053,10 @@ class SubcontractingController(StockController):
|
||||
link = get_link_to_form(
|
||||
self.subcontract_data.order_doctype, row.get(self.subcontract_data.order_field)
|
||||
)
|
||||
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"))
|
||||
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"))
|
||||
|
||||
def __validate_serial_no(self, row, key):
|
||||
if row.get("serial_and_batch_bundle") and self.__transferred_items.get(key).get("serial_no"):
|
||||
@@ -1066,8 +1068,10 @@ class SubcontractingController(StockController):
|
||||
link = get_link_to_form(
|
||||
self.subcontract_data.order_doctype, row.get(self.subcontract_data.order_field)
|
||||
)
|
||||
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"))
|
||||
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"))
|
||||
|
||||
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 of item code is not permitted, add another row instead."
|
||||
"Row #{0}: Item {1} mismatch. Changing the 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 of item code is not permitted.").format(
|
||||
_("Row #{0}: Item {1} mismatch. Changing the 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} is not a part of the linked Subcontracting Inward Order. Please select valid Batch No(s)."
|
||||
"Row #{0}: Batch No(s) {1} are 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]),
|
||||
|
||||
@@ -21,7 +21,6 @@ from erpnext.controllers.accounts_controller import (
|
||||
from erpnext.deprecation_dumpster import deprecated
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
ItemDetailsCtx,
|
||||
_get_item_tax_template,
|
||||
get_item_tax_map,
|
||||
)
|
||||
@@ -99,7 +98,7 @@ class calculate_taxes_and_totals:
|
||||
for item in self.doc.items:
|
||||
if item.item_code and item.get("item_tax_template"):
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"net_rate": item.net_rate or item.rate,
|
||||
"base_net_rate": item.base_net_rate or item.base_rate,
|
||||
@@ -131,9 +130,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 updated as per validity and rate applied").format(
|
||||
item.idx, frappe.bold(item.item_code)
|
||||
)
|
||||
_(
|
||||
"Row {0}: Item Tax template for {1} 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 +563,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 same"))
|
||||
frappe.throw(_("'Based On' and 'Group By' can not be the 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, delete_custom_fields
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_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 atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
"Please add at least one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ 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",
|
||||
@@ -197,6 +198,7 @@ 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,6 +26,7 @@ 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')}",
|
||||
@@ -70,6 +71,7 @@ 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:
|
||||
@@ -97,6 +99,7 @@ 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}")
|
||||
|
||||
|
||||
@@ -151,14 +154,26 @@ def create_customer(customer_data: dict | None = None):
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
customer_name = customer.name
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
|
||||
return
|
||||
|
||||
# Link contacts/address under a savepoint so a failure here does NOT discard the Customer just
|
||||
# created (a full rollback would; MariaDB kept it pre-migration). Linking is best-effort.
|
||||
frappe.db.savepoint("crm_customer_links")
|
||||
try:
|
||||
contacts = frappe.parse_json(customer_data.get("contacts"))
|
||||
create_contacts(contacts, customer_name, "Customer", customer_name)
|
||||
create_address("Customer", customer_name, customer_data.get("address"))
|
||||
return customer_name
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
|
||||
pass
|
||||
frappe.db.rollback(save_point="crm_customer_links")
|
||||
frappe.log_error(frappe.get_traceback(), "Error while linking contacts/address to new Customer")
|
||||
# keep the Customer, but preserve the pre-existing contract of returning None on a linking failure
|
||||
# so CRM callers still see the failure signal
|
||||
return
|
||||
|
||||
return customer_name
|
||||
|
||||
|
||||
def validate_frappe_crm_sync():
|
||||
|
||||
@@ -69,6 +69,7 @@ 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"])
|
||||
@@ -154,6 +155,7 @@ 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(
|
||||
@@ -169,9 +171,10 @@ 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 {} while linking with Plaid.").format(
|
||||
_("There was an error updating Bank Account {0} while linking with Plaid.").format(
|
||||
existing_bank_account
|
||||
),
|
||||
title=_("Plaid Link Failed"),
|
||||
@@ -212,7 +215,14 @@ def sync_transactions(bank, bank_account):
|
||||
result = []
|
||||
if transactions:
|
||||
for transaction in reversed(transactions):
|
||||
result += new_bank_transaction(transaction)
|
||||
# per-transaction savepoint: a failed insert/submit must not discard the Bank
|
||||
# Transactions already synced this run (MariaDB keeps them) nor poison the txn on Postgres
|
||||
frappe.db.savepoint("plaid_sync_txn")
|
||||
try:
|
||||
result += new_bank_transaction(transaction)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="plaid_sync_txn")
|
||||
raise
|
||||
|
||||
if result:
|
||||
last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date")
|
||||
|
||||
@@ -507,6 +507,7 @@ scheduler_events = {
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
"erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries",
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
|
||||
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
Normal file
62914
erpnext/locale/bg.po
Normal file
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user