mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-02 13:16:55 +00:00
Compare commits
3 Commits
chore/test
...
fix/operat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdecfcab9b | ||
|
|
7a9fd6937d | ||
|
|
95c0d49a0a |
220
.github/POSTGRES_COMPATIBILITY.md
vendored
220
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -1,220 +0,0 @@
|
||||
# PostgreSQL compatibility — review guide
|
||||
|
||||
ERPNext targets **both MariaDB and PostgreSQL from a single codebase**. The full server
|
||||
test suite passes on both, but the PostgreSQL CI job is **label-gated** (it does not run on
|
||||
every PR), so until it is required this guide is the always-on guard. Greptile loads it as
|
||||
review context (`.greptile/config.json`).
|
||||
|
||||
When reviewing a PR, flag any **new or changed query** (raw `frappe.db.sql`, `frappe.qb`,
|
||||
`frappe.get_all/get_list/get_value`, report SQL) that would **error on PostgreSQL** or
|
||||
**return different results on the two engines**.
|
||||
|
||||
## The one rule that governs everything
|
||||
|
||||
**MariaDB behaviour must not change; PostgreSQL is brought into line with MariaDB — never the
|
||||
reverse.** A "fix" that changes the value, row count, or ordering MariaDB produced is a
|
||||
regression, even if the new behaviour looks more correct. The only accepted MariaDB-output
|
||||
change is replacing a genuinely *undefined/arbitrary* result with a deterministic one (row
|
||||
count preserved) — and that should be called out explicitly.
|
||||
|
||||
There are two failure modes to watch for:
|
||||
1. **Hard breaks** — PostgreSQL raises an exception; MariaDB is green. Easy to catch in CI,
|
||||
but the gated job may not run.
|
||||
2. **Silent divergences** — both engines succeed but return *different* results. CI on one
|
||||
engine stays green; the bug only shows on a PostgreSQL site. These are the dangerous ones.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard breaks — would error on PostgreSQL
|
||||
|
||||
Flag a changed query that uses any of these:
|
||||
|
||||
- **Loose `GROUP BY`** — selecting/ordering a column that is neither in `GROUP BY` nor wrapped
|
||||
in an aggregate. MariaDB tolerates it; PostgreSQL errors (`must appear in the GROUP BY
|
||||
clause or be used in an aggregate function`). This **also covers an aggregate (`Sum`/`Count`/…)
|
||||
selected alongside bare columns with NO `.groupby()` at all** — MariaDB silently collapses
|
||||
every row into one arbitrary-valued row (often a *wrong-output* bug there too), PostgreSQL
|
||||
errors. Fix: add the bare column to `GROUP BY` **if it is functionally dependent on the group
|
||||
key**, otherwise wrap it in `Max()`/`Min()`. **See §3 — the row-count trap — before suggesting
|
||||
"add it to GROUP BY".**
|
||||
- **MySQL-only functions** — `TIMESTAMP(date,time)`, `TIMEDIFF`, `STR_TO_DATE`, `DATE_FORMAT`,
|
||||
`DATE_ADD/SUB`, `GROUP_CONCAT`, `PERIOD_DIFF`, SQL `IF(cond,a,b)`. Use the portable
|
||||
`frappe.query_builder.functions` equivalents (`CombineDatetime`, `DateDiff`, `Case`,
|
||||
`GroupConcat`, …) or a precomputed column (e.g. `posting_datetime`).
|
||||
- **`UPDATE … JOIN`** — not valid on PostgreSQL. Rewrite as `UPDATE … WHERE name IN (subquery)`.
|
||||
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
|
||||
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
|
||||
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
|
||||
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select
|
||||
**only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the
|
||||
MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead.
|
||||
- **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
|
||||
`Coalesce(...)`.
|
||||
- **Capital-cased identifiers** used as column/field names in `get_value(dt, dn, "Status")`,
|
||||
`get_all(dt, fields=["Account"])`, and similar — PostgreSQL quotes the identifier and matches
|
||||
it case-sensitively; a stored column named `status`/`account` won't match `"Status"`/`"Account"`
|
||||
(`column "Account" does not exist`). Use the exact stored (lower-case) fieldname.
|
||||
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
|
||||
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
|
||||
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
|
||||
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
|
||||
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
|
||||
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
|
||||
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
|
||||
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
|
||||
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
|
||||
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
|
||||
Use `VARCHAR` / `Cast_(x, "varchar")`.
|
||||
- **`.rlike()` / raw `RLIKE`** — frappe rewrites `REGEXP` → `~*` on PostgreSQL but does **not**
|
||||
translate `RLIKE` (no such PostgreSQL operator). Use `.regexp()` (or `.like()` for a simple prefix).
|
||||
- **`IfNull`/`Coalesce` of a typed column with a different-typed literal** — `IfNull(asset.disposal_date, 0)`
|
||||
renders `COALESCE("disposal_date", 0)`, coalescing a **DATE** with an **integer**. PostgreSQL requires
|
||||
`COALESCE` args to share a type (`DatatypeMismatch: COALESCE types date and integer cannot be matched`);
|
||||
MariaDB's `IFNULL` is permissive. The common shape is `IfNull(date_col, 0) != 0 / == 0` as a presence test —
|
||||
replace with `date_col.isnotnull()` / `date_col.isnull()` (identical, and valid on both). Otherwise coalesce
|
||||
to a **same-type** default (`Coalesce(date_col, '1900-01-01')`, `Coalesce(text_col, '')`).
|
||||
- **Division by a possibly-zero divisor** — `Sum(a) / Sum(b)`, `x / col`, etc. where the
|
||||
divisor can be `0`/empty. MariaDB returns `NULL` for division by zero; PostgreSQL raises
|
||||
`division by zero` and aborts the query. Wrap the divisor in `NullIf(divisor, 0)` — that
|
||||
yields `NULL` on both engines, matching MariaDB's value. (Only the *literal* `/ 0` is a parse
|
||||
constant; the trap is a divisor that is an aggregate or column the data can drive to zero.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Silent divergences — succeeds on both, returns different results
|
||||
|
||||
These don't error, so a one-engine CI stays green. Flag them:
|
||||
|
||||
- **Case sensitivity on text equality** — `==`, `.isin()`, `Strpos`/`Locate` on free-text
|
||||
columns are case-**sensitive** on PostgreSQL but case-**insensitive** under MariaDB's default
|
||||
collation. `Lower()` both sides. *(Not `.like()`/`["like", …]` — those already render as
|
||||
`ILIKE` on PostgreSQL; see §4.)*
|
||||
- **Case sensitivity in a doc-`name` lookup** — lower-casing a value then using it as a
|
||||
document name in `get_value`/`get_doc`/`exists` misses on PostgreSQL (names are
|
||||
case-sensitive). Keep original case for the identifier; lower-case only comparison operands.
|
||||
- **Empty string vs NULL** — PostgreSQL stores a blank link/data field as `NULL` on some paths
|
||||
while MariaDB keeps `''`; `Concat`/`Concat_ws` then diverge. Prefer the stored full value, or
|
||||
`Coalesce(col, '')` per argument.
|
||||
- **NULL ordering** — MariaDB sorts `NULL` first, PostgreSQL sorts it last. For
|
||||
`ORDER BY … LIMIT 1`/`[0]` on a nullable column, guard with `Coalesce`/`isnotnull()`.
|
||||
- **`ORDER BY … LIMIT 1` with no unique tiebreaker** — when rows tie on the ordered column the
|
||||
two engines may pick different rows. Add a `creation`/`name` tiebreaker **only if it does not
|
||||
change MariaDB's current pick** (see §4).
|
||||
- **Integer division** — `int / int` truncates on PostgreSQL but is decimal on MariaDB, e.g.
|
||||
`COUNT(...) / COUNT(...) * 100` → `0`, or `manufacturing_time_in_mins / 1440` flooring a
|
||||
lead-time to whole days. Force float: multiply by `100.0`, or make a literal a float
|
||||
(`/ 1440` → `/ 1440.0`), or cast an operand. (Only SQL-level `/` on integer **columns/literals**
|
||||
— Python `/` is already float.)
|
||||
- **`DISTINCT` list ordering** — `frappe.get_all(distinct=True, order_by=…)` /
|
||||
`SELECT DISTINCT … ORDER BY`: frappe's `db_query` **silently drops `ORDER BY` for distinct
|
||||
queries on PostgreSQL**, so the result is unordered there. Sort in Python instead — and use
|
||||
`key=str.casefold`, because bare `sorted()` is case-sensitive (ASCII) while MariaDB's
|
||||
collation is case-insensitive, so a plain sort reorders MariaDB's output.
|
||||
- **Engine-specific function rewrites** — e.g. a PostgreSQL `regexp_replace` branch
|
||||
reimplementing MariaDB's `CAST(SUBSTRING_INDEX(name,' ',-1) AS UNSIGNED)` (leading digits of
|
||||
the last whitespace token). Verify the rewrite matches MariaDB on edge cases (`"X - 3a"→3`,
|
||||
`"X - 1.5"→1`) by diffing both engines on literal rows.
|
||||
- **`UnixTimestamp(date)` / date→epoch** is timezone-dependent (midnight in the DB session TZ),
|
||||
so a strict `epoch <= now` bound is flaky on PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
**changes the MariaDB row count** (a regression). The classic traps are adding the **child/row
|
||||
primary key** or an **editable per-row field**. Instead **`Max()`/`Min()`-wrap** the offending
|
||||
column: the row count is preserved and the value goes from arbitrary (MariaDB's old loose pick)
|
||||
to deterministic.
|
||||
|
||||
**Judge functional dependence by the source table, not the column name:**
|
||||
- A column from a **master joined on the group key** (`t3.x` where `t1.key = t3.name`) is FD →
|
||||
safe to keep in `GROUP BY`.
|
||||
- A descriptive field on the **transaction** table (`t1.supplier_name`, `t1.territory`,
|
||||
`t1.item_name` — fetched/editable, can differ across historical rows for the same key) is
|
||||
**not** FD even though it looks master-derived → `Max()`-wrap it.
|
||||
|
||||
Conversely, do **not** suggest changing a `Max()`/`Min()`-wrapped column to `Sum()` (or vice
|
||||
versa) to make a number "more correct" — that changes the MariaDB value. The wrap reproduces
|
||||
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
|
||||
|
||||
These are auto-handled by the framework and are **not** breaks:
|
||||
|
||||
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
|
||||
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
|
||||
is a hard break, `bigint ILIKE`; see §1.)*
|
||||
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
|
||||
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
|
||||
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
|
||||
hard break (see §1).
|
||||
- **An `ORDER BY … LIMIT 1` tie where the two engines already agree**, or where adding a
|
||||
tiebreaker would *change* MariaDB's current pick — leave it; "fixing" it would either change
|
||||
MariaDB or has no observable effect.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transaction / runtime (not query-shape, still PostgreSQL-only)
|
||||
|
||||
- **Catch-and-continue inserts** — on PostgreSQL a failed `insert()` aborts the **whole
|
||||
transaction**, so code that swallows a duplicate and keeps going dies on the next statement
|
||||
with `InFailedSqlTransaction` (frappe dropped its blanket per-statement savepoint in
|
||||
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),
|
||||
(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 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.
|
||||
72
.github/helper/hydrate.sh
vendored
72
.github/helper/hydrate.sh
vendored
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Hydrate a test shard from the setup job's artifact.
|
||||
#
|
||||
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
|
||||
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
|
||||
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
|
||||
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
db_host="${DB_HOST:-127.0.0.1}"
|
||||
|
||||
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
|
||||
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' DB='${DB:-}' bash '$0'"
|
||||
fi
|
||||
|
||||
cd ~/frappe-bench
|
||||
|
||||
# Start the DB on the datadir baked into the artifact. It's already populated (the setup job
|
||||
# reinstalled into this very datadir), so there is NO restore — the server comes up on the
|
||||
# existing files. This is what replaces the per-shard SQL replay.
|
||||
bash ~/frappe-bench/start-db.sh
|
||||
|
||||
# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the
|
||||
# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard
|
||||
# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode
|
||||
# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis
|
||||
# instances directly: fast and deterministic.
|
||||
if [ "${DB:-mariadb}" = "postgres" ]; then
|
||||
# Start redis directly as daemons — reliable and persists across steps. Do NOT route it through
|
||||
# `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the
|
||||
# bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis
|
||||
# independent is what makes it survive. The web server (for PDF tests) is NOT started here — a
|
||||
# backgrounded server doesn't survive into the next step; it's started inside the Run Tests step.
|
||||
for conf in redis_cache redis_queue; do
|
||||
[ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes
|
||||
done
|
||||
else
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
fi
|
||||
|
||||
# Wait for redis, failing fast instead of silently burning minutes if it never comes up.
|
||||
cfg=~/frappe-bench/sites/common_site_config.json
|
||||
if [ -f "$cfg" ]; then
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
m = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if m:
|
||||
print(m.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; }
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Hydrated: DB up on baked datadir, redis up — ready for tests."
|
||||
357
.github/helper/install.sh
vendored
357
.github/helper/install.sh
vendored
@@ -4,356 +4,75 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
sudo apt update
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||
|
||||
pip install frappe-bench
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
db_host=${DB_HOST:-"127.0.0.1"}
|
||||
db_user_host=${DB_USER_HOST:-"localhost"}
|
||||
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
|
||||
bench_cache_dir=${BENCH_CACHE_DIR:-}
|
||||
|
||||
run_as_ci_user_if_needed() {
|
||||
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local missing_packages=()
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
missing_packages+=("pkg-config")
|
||||
fi
|
||||
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
|
||||
missing_packages+=("libmariadb-dev")
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
missing_packages+=("cron")
|
||||
fi
|
||||
|
||||
if [ "${#missing_packages[@]}" -gt 0 ]; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
local ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
if ! id "$ci_user" >/dev/null 2>&1; then
|
||||
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
local ci_dirs=(
|
||||
"$HOME"
|
||||
"$GITHUB_WORKSPACE"
|
||||
"$HOME/.cache"
|
||||
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
|
||||
"${npm_config_cache:-$HOME/.npm}"
|
||||
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
|
||||
"$HOME/.yarn"
|
||||
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
|
||||
"$(dirname "$wkhtmltox_deb")"
|
||||
)
|
||||
if [ -n "$bench_cache_dir" ]; then
|
||||
ci_dirs+=("$bench_cache_dir")
|
||||
fi
|
||||
|
||||
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
|
||||
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
|
||||
# cache (mounted volume or baked image layer) stays warm across runs.
|
||||
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
|
||||
export ERPNEXT_CI_NON_ROOT=1
|
||||
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
|
||||
}
|
||||
|
||||
run_as_ci_user_if_needed
|
||||
|
||||
run_ci_step() {
|
||||
local label=$1
|
||||
shift
|
||||
|
||||
echo "::group::${label}"
|
||||
date -u
|
||||
local exit_code=0
|
||||
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-1800}" "$@" || exit_code=$?
|
||||
date -u
|
||||
echo "::endgroup::"
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
|
||||
sudo apt-get update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt-get remove -y mysql-server mysql-client
|
||||
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
else
|
||||
apt_pid=
|
||||
pip_pid=
|
||||
fi
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
cd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
|
||||
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
frappe_sha=$(git -C frappe rev-parse HEAD)
|
||||
|
||||
get_bench_cache_archive() {
|
||||
if [ -z "$bench_cache_dir" ]; then
|
||||
return
|
||||
fi
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
mkdir -p "$bench_cache_dir"
|
||||
|
||||
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
|
||||
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
|
||||
# constantly-moving develop still hit the cache.
|
||||
local cache_key
|
||||
cache_key=$(
|
||||
{
|
||||
uname -m
|
||||
python --version
|
||||
node --version
|
||||
bench --version
|
||||
} | sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
|
||||
}
|
||||
|
||||
restore_warm_bench() {
|
||||
bench_cache_archive=$(get_bench_cache_archive)
|
||||
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
|
||||
|
||||
echo "Restoring base bench from ${bench_cache_archive}"
|
||||
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
|
||||
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
|
||||
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
|
||||
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
|
||||
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
|
||||
|
||||
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
|
||||
# rebuild only what changed. The editable install means the venv tracks the new code with
|
||||
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
|
||||
if ! (
|
||||
cd ~/frappe-bench/apps/frappe || exit 1
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
|
||||
# so a develop commit that bumped requirements doesn't leave a stale venv.
|
||||
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
|
||||
echo "frappe dependency refresh failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
save_warm_bench() {
|
||||
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
|
||||
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
|
||||
return
|
||||
fi
|
||||
|
||||
local tmp_archive
|
||||
tmp_archive="${bench_cache_archive}.${$}.tmp"
|
||||
|
||||
echo "Saving warm bench to ${bench_cache_archive}"
|
||||
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
|
||||
# restore path fall back to a default redis port that bench start never bound, so reinstall
|
||||
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
|
||||
# restore_warm_bench runs `bench build` to regenerate it.
|
||||
tar \
|
||||
--use-compress-program="zstd -T0 -3" \
|
||||
--exclude="frappe-bench/logs" \
|
||||
--exclude="frappe-bench/sites/assets" \
|
||||
-cf "$tmp_archive" \
|
||||
-C ~ frappe-bench
|
||||
mv "$tmp_archive" "$bench_cache_archive"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f "$wkhtmltox_deb" ]; then
|
||||
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt-get install -y "$wkhtmltox_deb"
|
||||
}
|
||||
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
else
|
||||
wkpid=
|
||||
fi
|
||||
|
||||
if ! restore_warm_bench; then
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
CI=Yes bench build --app frappe
|
||||
save_warm_bench
|
||||
fi
|
||||
|
||||
if [ -n "$wkpid" ]; then wait $wkpid; fi
|
||||
|
||||
mkdir -p ~/frappe-bench/sites/test_site
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
if [ "$db_host" != "127.0.0.1" ]; then
|
||||
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
else
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
for _ in {1..60}; do
|
||||
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
|
||||
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
|
||||
# 10.6, so it can't be disabled here — would need a server startup flag.)
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
|
||||
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
|
||||
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
|
||||
# reinstall then errors on table creation, unset it (off by default — zero risk).
|
||||
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
|
||||
fi
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
fi
|
||||
|
||||
if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
|
||||
# Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by
|
||||
# start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is
|
||||
# NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service
|
||||
# container.
|
||||
fi
|
||||
|
||||
|
||||
install_whktml() {
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
|
||||
}
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
run_ci_step "Get payments app" bench get-app payments --branch develop
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
|
||||
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
|
||||
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
|
||||
erpnext_get_app_args=()
|
||||
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
|
||||
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
|
||||
bench get-app payments --branch develop
|
||||
bench get-app erpnext "${GITHUB_WORKSPACE}"
|
||||
|
||||
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
|
||||
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
|
||||
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
wait $wkpid
|
||||
|
||||
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
|
||||
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
|
||||
# then slows the job rather than failing it.
|
||||
wait_for_redis() {
|
||||
local cfg=~/frappe-bench/sites/common_site_config.json
|
||||
[ -f "$cfg" ] || return 0
|
||||
local ports port
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
match = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
local up=0
|
||||
for _ in $(seq 1 120); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
|
||||
exec 3>&- 3<&-; up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
|
||||
# when redis never bound.
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
|
||||
done
|
||||
}
|
||||
wait_for_redis
|
||||
|
||||
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
|
||||
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
|
||||
# shards start the DB on the baked datadir instead of repeating the reinstall.
|
||||
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
|
||||
52
.github/helper/merge_po_files.py
vendored
52
.github/helper/merge_po_files.py
vendored
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overlay develop's .po translations onto hotfix's .po files.
|
||||
|
||||
Called by sync_hotfix_translations.sh before `bench update-po-files`.
|
||||
Merge rules:
|
||||
a. msgid absent from develop → keep hotfix's existing msgstr
|
||||
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
|
||||
c. msgid present in both → use develop's msgstr
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from babel.messages.pofile import read_po, write_po
|
||||
|
||||
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
|
||||
LOCALE = Path("./apps/erpnext/erpnext/locale/")
|
||||
|
||||
added = updated = 0
|
||||
|
||||
for src in sorted(DEVELOP.glob("*.po")):
|
||||
dst = LOCALE / src.name
|
||||
|
||||
with src.open("rb") as f:
|
||||
dev = read_po(f)
|
||||
|
||||
if not dst.exists():
|
||||
dev.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, dev)
|
||||
added += 1
|
||||
print(f" [new] {src.name}")
|
||||
continue
|
||||
|
||||
with dst.open("rb") as f:
|
||||
hf = read_po(f)
|
||||
|
||||
changes = 0
|
||||
for msg in hf:
|
||||
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
|
||||
msg.string = dev[msg.id].string
|
||||
changes += 1
|
||||
|
||||
if changes:
|
||||
hf.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, hf)
|
||||
updated += 1
|
||||
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
|
||||
else:
|
||||
print(f" [no-op] {src.name}")
|
||||
|
||||
print(f"\n{added} new language(s), {updated} updated.")
|
||||
241
.github/helper/postgres_compat.py
vendored
241
.github/helper/postgres_compat.py
vendored
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static guard against MySQL-only SQL that breaks on PostgreSQL.
|
||||
|
||||
The Postgres test job is label-gated, so it does not run on every PR. This pre-commit
|
||||
hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks
|
||||
that static analysis can catch reliably with a low false-positive rate.
|
||||
|
||||
It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY,
|
||||
case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division
|
||||
intent, division by a possibly-zero divisor, savepoint discipline) — those genuinely need
|
||||
the test suite or a human/Greptile reviewer. Run the full suite on a Postgres site for those.
|
||||
|
||||
Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a
|
||||
`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch).
|
||||
|
||||
Usage: postgres_compat.py <file.py> [<file.py> ...] (pre-commit passes staged files)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
|
||||
IGNORE = "pg-ok"
|
||||
|
||||
# Strings are only scanned for the patterns below when they have real SQL *structure*
|
||||
# (not just an English word like "select" or "from"), to keep false positives near zero.
|
||||
SQL_HINT = re.compile(
|
||||
r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM
|
||||
r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET
|
||||
r"|\bdelete\s+from\b"
|
||||
r"|\binsert\s+into\b"
|
||||
r"|\bshow\s+(?:index|tables|columns)\b"
|
||||
r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType`
|
||||
re.I,
|
||||
)
|
||||
|
||||
# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites
|
||||
# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like()
|
||||
# renders ILIKE — so those are NOT listed here; flagging them would be false positives.)
|
||||
SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I),
|
||||
"timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"),
|
||||
(re.compile(r"\btimediff\s*\(", re.I),
|
||||
"timediff() is MySQL-only -> compute the delta in Python"),
|
||||
(re.compile(r"\bstr_to_date\s*\(", re.I),
|
||||
"str_to_date() is MySQL-only -> parse in Python and pass a real date"),
|
||||
(re.compile(r"\bdate_format\s*\(", re.I),
|
||||
"date_format() is MySQL-only -> filter on a date range instead"),
|
||||
(re.compile(r"\bdate_(add|sub)\s*\(", re.I),
|
||||
"date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"),
|
||||
(re.compile(r"\bgroup_concat\s*\(", re.I),
|
||||
"group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"),
|
||||
(re.compile(r"\bperiod_diff\s*\(", re.I),
|
||||
"period_diff() is MySQL-only -> compute in Python"),
|
||||
(re.compile(r"\bshow\s+index\b", re.I),
|
||||
"SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"),
|
||||
(re.compile(r"\bshow\s+(tables|columns)\b", re.I),
|
||||
"SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"),
|
||||
(re.compile(r"\bas\s+'[^']+'", re.I),
|
||||
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
|
||||
(re.compile(r"\bif\s*\(", re.I),
|
||||
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
|
||||
(re.compile(r"\brlike\b", re.I),
|
||||
"RLIKE is MySQL-only -> frappe rewrites REGEXP->~* on Postgres but NOT RLIKE; use REGEXP / .regexp() / ~"),
|
||||
(re.compile(r"\bcast\s*\(.+?\bas\s+char\b", re.I | re.S), # .+? spans nested parens, e.g. CAST(ABS(x) AS CHAR)
|
||||
"CAST(... AS CHAR) is character(1) on Postgres and truncates -> CAST AS VARCHAR (frappe Cast_(x, 'varchar'))"),
|
||||
]
|
||||
|
||||
# UPDATE ... JOIN: both keywords in the same SQL string.
|
||||
UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I))
|
||||
|
||||
MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"}
|
||||
|
||||
SET_BOOL_FUNCS = {"set_value", "db_set"}
|
||||
|
||||
# query-builder cast helpers: pypika Cast / frappe Cast_. A "char" target type is character(1)
|
||||
# on Postgres (truncates); "varchar" is the full-length cast.
|
||||
CAST_FUNCS = {"Cast", "Cast_"}
|
||||
|
||||
# frappe.get_all / get_list: frappe's db_query SILENTLY drops ORDER BY for `distinct` queries on
|
||||
# Postgres (the ORDER BY column must appear in the SELECT-DISTINCT list), so `distinct=True` together
|
||||
# with a literal `order_by` is a no-op on PG and the result comes back unordered.
|
||||
DISTINCT_ORDER_FUNCS = {"get_all", "get_list"}
|
||||
|
||||
|
||||
def _docstring_ids(tree: ast.AST) -> set[int]:
|
||||
"""ids of Constant nodes that are docstrings (so prose describing the rules isn't flagged)."""
|
||||
ids: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
body = getattr(node, "body", None)
|
||||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
|
||||
ids.add(id(body[0].value))
|
||||
return ids
|
||||
|
||||
|
||||
class Visitor(ast.NodeVisitor):
|
||||
def __init__(self, lines: list[str], docstrings: set[int]):
|
||||
self.lines = lines
|
||||
self.docstrings = docstrings
|
||||
self.violations: list[tuple[int, str]] = []
|
||||
|
||||
def _ignored(self, node: ast.AST) -> bool:
|
||||
start = getattr(node, "lineno", 1)
|
||||
end = getattr(node, "end_lineno", start) or start
|
||||
# honour `# pg-ok` anywhere on the node's line span, the line just above (the enclosing
|
||||
# call, e.g. `frappe.db.sql( # pg-ok`), or the line just below (a multi-line call's `) # pg-ok`).
|
||||
lo = max(0, start - 2)
|
||||
return any(IGNORE in self.lines[i] for i in range(lo, min(end + 1, len(self.lines))))
|
||||
|
||||
def _flag(self, node: ast.AST, msg: str) -> None:
|
||||
if not self._ignored(node):
|
||||
self.violations.append((getattr(node, "lineno", 1), msg))
|
||||
|
||||
def _scan_sql(self, text: str, node: ast.AST) -> None:
|
||||
if not SQL_HINT.search(text):
|
||||
return
|
||||
for pattern, msg in SQL_PATTERNS:
|
||||
if pattern.search(text):
|
||||
self._flag(node, msg)
|
||||
if UPDATE_JOIN[0].search(text) and UPDATE_JOIN[1].search(text):
|
||||
self._flag(node, "UPDATE ... JOIN is MySQL-only -> use a correlated subquery (WHERE ... IN/EXISTS)")
|
||||
|
||||
def visit_Constant(self, node: ast.Constant) -> None:
|
||||
# plain string literals, incl. `"...".format()` and `"..." % (...)` templates
|
||||
if isinstance(node.value, str) and id(node) not in self.docstrings:
|
||||
self._scan_sql(node.value, node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
|
||||
# f-string: scan its STATIC text (interpolated values become a placeholder) so MySQL-isms
|
||||
# in dynamic SQL are caught, without flagging safe interpolation of identifiers.
|
||||
text = "".join(
|
||||
v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else " ? "
|
||||
for v in node.values
|
||||
)
|
||||
self._scan_sql(text, node)
|
||||
# don't recurse: child literal chunks would otherwise be re-scanned individually
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
fn = node.func
|
||||
name = fn.attr if isinstance(fn, ast.Attribute) else (fn.id if isinstance(fn, ast.Name) else "")
|
||||
|
||||
# row.get("Column_name") — MySQL SHOW INDEX result key
|
||||
if name == "get" and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value in MYSQL_RESULT_KEYS:
|
||||
self._flag(node, f'"{node.args[0].value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
|
||||
|
||||
# set_value(..., True) / db_set("field", True) on a Check (int) column.
|
||||
# Only the field *value* arg carries bool->smallint risk — NOT trailing flags like
|
||||
# update_modified. db_set(field, value, update_modified, ...) -> value at args[1] (or a dict
|
||||
# at args[0]); set_value(dt, dn, field, value, ...) -> value at args[3] (or a dict at args[2]).
|
||||
if name in SET_BOOL_FUNCS:
|
||||
value_idx, dict_idx = (1, 0) if name == "db_set" else (3, 2)
|
||||
dict_arg = (
|
||||
node.args[dict_idx]
|
||||
if len(node.args) > dict_idx and isinstance(node.args[dict_idx], ast.Dict)
|
||||
else None
|
||||
)
|
||||
if dict_arg is not None:
|
||||
for v in dict_arg.values:
|
||||
if isinstance(v, ast.Constant) and isinstance(v.value, bool):
|
||||
self._flag(node, f"{name}(...) sets an int/Check column with a bool in a dict -> pass 1/0 (Postgres rejects bool->smallint)")
|
||||
elif len(node.args) > value_idx:
|
||||
a = node.args[value_idx]
|
||||
if isinstance(a, ast.Constant) and isinstance(a.value, bool):
|
||||
self._flag(node, f"{name}(..., {a.value}) sets an int/Check column with a bool -> pass 1/0 (Postgres rejects bool->smallint)")
|
||||
|
||||
# frappe.get_all/get_list(..., distinct=True, order_by="<col>") -> ORDER BY is silently dropped
|
||||
# for distinct queries on Postgres, so the result is unordered there. Sort in python instead
|
||||
# (e.g. sorted(frappe.get_all(..., distinct=True), key=str.casefold)). An empty order_by="" (the
|
||||
# explicit "suppress the injected default" idiom) and a dynamic/variable order_by are not flagged.
|
||||
if name in DISTINCT_ORDER_FUNCS:
|
||||
has_distinct = any(
|
||||
kw.arg == "distinct" and isinstance(kw.value, ast.Constant) and kw.value.value
|
||||
for kw in node.keywords
|
||||
)
|
||||
order_kw = next((kw for kw in node.keywords if kw.arg == "order_by"), None)
|
||||
has_literal_order = (
|
||||
order_kw is not None
|
||||
and isinstance(order_kw.value, ast.Constant)
|
||||
and isinstance(order_kw.value.value, str)
|
||||
and order_kw.value.value.strip()
|
||||
)
|
||||
if has_distinct and has_literal_order:
|
||||
self._flag(node, f"{name}(distinct=True, order_by=...) -> frappe drops ORDER BY for distinct queries on Postgres; sort in python instead, e.g. sorted(..., key=str.casefold)")
|
||||
|
||||
# query-builder .rlike(...): pypika emits the MySQL-only RLIKE operator, which frappe does
|
||||
# NOT translate for Postgres (it rewrites only REGEXP -> ~*).
|
||||
if name == "rlike":
|
||||
self._flag(node, ".rlike() emits MySQL-only RLIKE (not translated on Postgres) -> use .regexp() (rewritten to ~*) or .like()")
|
||||
|
||||
# Cast(col, "char") / Cast_(col, "char"): on Postgres a bare CHAR is character(1) and truncates
|
||||
# (e.g. CAST(12 AS CHAR) -> '1'); use "varchar" for a full-length string cast.
|
||||
if name in CAST_FUNCS:
|
||||
for arg in (*node.args, *(kw.value for kw in node.keywords)):
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.strip().lower() == "char":
|
||||
self._flag(node, f"{name}(..., 'char') is character(1) on Postgres and truncates -> use 'varchar'")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Subscript(self, node: ast.Subscript) -> None:
|
||||
key = node.slice
|
||||
if isinstance(key, ast.Constant) and key.value in MYSQL_RESULT_KEYS:
|
||||
self._flag(node, f'"{key.value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def check_file(path: str) -> list[str]:
|
||||
try:
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal -- dev-only lint tool; `path` is a source file supplied by pre-commit, not user input
|
||||
src = open(path, encoding="utf-8").read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
try:
|
||||
tree = ast.parse(src, filename=path)
|
||||
except SyntaxError:
|
||||
return [] # check-ast hook reports real syntax errors
|
||||
v = Visitor(src.splitlines(), _docstring_ids(tree))
|
||||
v.visit(tree)
|
||||
return [f"{path}:{line}: [pg-compat] {msg}" for line, msg in sorted(set(v.violations))]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
out: list[str] = []
|
||||
for path in argv:
|
||||
if path.endswith(".py"):
|
||||
out.extend(check_file(path))
|
||||
if out:
|
||||
print("\n".join(out))
|
||||
print(
|
||||
f"\n{len(out)} PostgreSQL-incompatibility issue(s). Fix them, or add `# pg-ok` to a "
|
||||
"line that is intentionally MariaDB-only (e.g. inside an `if frappe.db.db_type == 'mariadb':` branch)."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
2
.github/helper/site_config_postgres.json
vendored
2
.github/helper/site_config_postgres.json
vendored
@@ -13,6 +13,6 @@
|
||||
"root_login": "postgres",
|
||||
"root_password": "travis",
|
||||
"host_name": "http://test_site:8000",
|
||||
"install_apps": ["payments", "erpnext"],
|
||||
"install_apps": ["erpnext"],
|
||||
"throttle_user_limit": 100
|
||||
}
|
||||
|
||||
79
.github/helper/start-db.sh
vendored
79
.github/helper/start-db.sh
vendored
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
|
||||
# packaged into the bench artifact, test shards start an already-loaded server instead of
|
||||
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
|
||||
#
|
||||
# CI_DB_DATADIR picks the path:
|
||||
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
|
||||
# doesn't wipe it; it's moved into the bench just before packaging)
|
||||
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
|
||||
#
|
||||
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
|
||||
# refused anyway). Mirrors install.sh's user switch.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' DB='${DB:-}' bash '$0'"
|
||||
fi
|
||||
|
||||
# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact,
|
||||
# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability
|
||||
# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql/<ver>/bin.
|
||||
if [ "${DB:-mariadb}" = "postgres" ]; then
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$PG_BIN" ] && export PATH="$PG_BIN:$PATH"
|
||||
PGDATA="${CI_DB_DATADIR:-$HOME/frappe-bench/pgdata}"
|
||||
if [ ! -d "$PGDATA/base" ]; then
|
||||
initdb -D "$PGDATA" -U postgres --auth-local=trust --auth-host=trust >/dev/null
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
fi
|
||||
pg_ctl -D "$PGDATA" -w -o "-p 5432 -c listen_addresses=127.0.0.1 -c unix_socket_directories=$PGDATA -c fsync=off -c synchronous_commit=off -c full_page_writes=off" start
|
||||
echo "PostgreSQL up in-runner (pgdata=$PGDATA)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- MariaDB ---
|
||||
DATADIR="${CI_DB_DATADIR:-$HOME/frappe-bench/mariadb-data}"
|
||||
SOCK="$DATADIR/mysqld.sock"
|
||||
fresh=0
|
||||
|
||||
if [ ! -d "$DATADIR/mysql" ]; then
|
||||
mkdir -p "$DATADIR"
|
||||
mariadb-install-db --no-defaults --datadir="$DATADIR" \
|
||||
--auth-root-authentication-method=normal --skip-test-db >/dev/null 2>&1
|
||||
fresh=1
|
||||
fi
|
||||
|
||||
# Throwaway-CI durability off; bind TCP 127.0.0.1:3306 so bench/install.sh connect as usual.
|
||||
mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADIR/mysqld.pid" \
|
||||
--port=3306 --bind-address=127.0.0.1 \
|
||||
--innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin \
|
||||
> "$HOME/mariadb.log" 2>&1 &
|
||||
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
if mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null; then up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail loudly instead of letting the loop fall through (exit 0 of the last `sleep`) into SQL that
|
||||
# would error with a vague socket-connection failure.
|
||||
[ "$up" = "1" ] || { echo "mariadbd did not come up on $SOCK"; cat "$HOME/mariadb.log" 2>/dev/null; exit 1; }
|
||||
|
||||
if [ "$fresh" = "1" ]; then
|
||||
# A fresh datadir has only a password-less root@localhost. Give it the password install.sh
|
||||
# uses, plus a TCP-reachable root@127.0.0.1, so the rest of install.sh works unchanged.
|
||||
mariadb --no-defaults --socket="$SOCK" -u root <<'SQL'
|
||||
ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
|
||||
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "MariaDB up in-container (datadir=$DATADIR, fresh=$fresh)"
|
||||
121
.github/helper/sync_hotfix_translations.sh
vendored
121
.github/helper/sync_hotfix_translations.sh
vendored
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Syncs Crowdin translations from develop to a hotfix branch.
|
||||
# Merge logic: see merge_po_files.py.
|
||||
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
|
||||
# (all set by Actions).
|
||||
|
||||
set -e
|
||||
|
||||
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
|
||||
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
echo "=== Setting up bench ==="
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
|
||||
cd ./frappe-bench || exit
|
||||
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
|
||||
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
|
||||
gh auth setup-git
|
||||
git fetch upstream "${HOTFIX_BRANCH}"
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
|
||||
else
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
|
||||
fi
|
||||
cd ../.. || exit
|
||||
|
||||
echo "=== Fetching develop's .po files ==="
|
||||
mkdir -p /tmp/develop-po
|
||||
git -C "${GITHUB_WORKSPACE}" fetch origin develop
|
||||
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
|
||||
| tar -xf - -C /tmp/develop-po/
|
||||
|
||||
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
|
||||
if [ "${po_count}" -eq 0 ]; then
|
||||
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted ${po_count} .po file(s) from develop."
|
||||
|
||||
echo "=== Merging and reconciling ==="
|
||||
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
|
||||
bench update-po-files --app "${APP_NAME}"
|
||||
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
|
||||
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
|
||||
echo "Translations are already up to date. No PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
git diff --name-only "${APP_NAME}/locale/"
|
||||
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
|
||||
|
||||
echo "=== Committing ==="
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
if ! git diff --staged --quiet -- "${file}"; then
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
else
|
||||
git restore --staged -- "${file}"
|
||||
fi
|
||||
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
|
||||
fi
|
||||
git push -u upstream sync_translations_${HOTFIX_BRANCH}
|
||||
|
||||
echo "=== Opening PR (if not already open) ==="
|
||||
existing_pr=$(gh pr list \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--state open \
|
||||
--json number \
|
||||
--jq 'length' \
|
||||
-R "${GITHUB_REPOSITORY}")
|
||||
|
||||
if [ "${existing_pr}" -gt 0 ]; then
|
||||
echo "PR already open — branch updated in place. No new PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
|
||||
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
|
||||
|
||||
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
|
||||
|
||||
| Case | Condition | Result |
|
||||
|------|-----------|--------|
|
||||
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
|
||||
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
|
||||
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
|
||||
|
||||
Generated by the \`sync-hotfix-translations\` workflow." \
|
||||
--label "translation" \
|
||||
--label "skip-release-notes" \
|
||||
--reviewer "${PR_REVIEWER}" \
|
||||
-R "${GITHUB_REPOSITORY}"
|
||||
70
.github/workflows/build-and-commit-assets.yml
vendored
70
.github/workflows/build-and-commit-assets.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Build and Upload Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- 'version-*'
|
||||
|
||||
concurrency:
|
||||
group: build-assets-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-assets:
|
||||
name: Build JS/CSS and upload to release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: frappe/frappe
|
||||
path: apps/frappe
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: apps/erpnext
|
||||
|
||||
- name: Create bench structure
|
||||
run: |
|
||||
mkdir -p sites
|
||||
printf "frappe\nerpnext\n" > sites/apps.txt
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: yarn
|
||||
cache-dependency-path: apps/frappe/yarn.lock
|
||||
|
||||
- name: Install frappe JS dependencies
|
||||
working-directory: apps/frappe
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install erpnext JS dependencies
|
||||
working-directory: apps/erpnext
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Link node_modules into public/
|
||||
working-directory: apps/frappe
|
||||
run: ln -s "$PWD/node_modules" frappe/public/node_modules
|
||||
|
||||
- name: Build assets (production)
|
||||
working-directory: apps/frappe
|
||||
run: yarn run production
|
||||
|
||||
- name: Package assets
|
||||
working-directory: apps/erpnext
|
||||
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
|
||||
|
||||
- name: Upload to rolling release
|
||||
working-directory: apps/erpnext
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="assets-${GITHUB_REF_NAME//\//-}"
|
||||
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
|
||||
gh release upload "$TAG" erpnext-assets.tar.gz --clobber
|
||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: pip
|
||||
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
uses: pre-commit/action@v3.0.0
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
|
||||
33
.github/workflows/patch.yml
vendored
33
.github/workflows/patch.yml
vendored
@@ -65,22 +65,6 @@ jobs:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
|
||||
# it from the GitHub release every run.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/erpnext-v14.sql.gz
|
||||
key: erpnext-v14-sql-gz
|
||||
|
||||
- name: Download erpnext v14 backup
|
||||
if: steps.cache-v14.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \
|
||||
-o ~/erpnext-v14.sql.gz \
|
||||
https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -129,20 +113,12 @@ jobs:
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
bench --site test_site --force restore ~/erpnext-v14.sql.gz
|
||||
wget https://frappe.io/files/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
|
||||
# Start every bench process except the background workers. If workers run during a
|
||||
# migrate, they pick up the orphan-link cleanup jobs it enqueues and race its schema
|
||||
# changes, which fails with MySQL 1412 "Table definition has changed". Redis and the
|
||||
# other services stay up; the queued jobs simply wait and are harmless here.
|
||||
function start_bench_without_workers() {
|
||||
local procs
|
||||
procs=$(awk -F: '/^[a-z_]+:/ && $1 !~ /worker/ {print $1}' ~/frappe-bench/Procfile)
|
||||
honcho start -f ~/frappe-bench/Procfile $procs &>> ~/frappe-bench/bench_start.log &
|
||||
}
|
||||
|
||||
function update_to_version() {
|
||||
version=$1
|
||||
@@ -158,11 +134,10 @@ jobs:
|
||||
|
||||
# Resetup env and install apps
|
||||
pgrep honcho | xargs kill
|
||||
sleep 10
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env --python python$2
|
||||
bench pip install -e ./apps/erpnext
|
||||
start_bench_without_workers
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
}
|
||||
@@ -179,7 +154,7 @@ jobs:
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env
|
||||
bench pip install -e ./apps/erpnext
|
||||
start_bench_without_workers
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Review translation PRs
|
||||
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "**/*.po"
|
||||
- "**/*.pot"
|
||||
|
||||
concurrency:
|
||||
group: po-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review-po-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
@@ -1,52 +0,0 @@
|
||||
# Runner — maintain this file on each hotfix branch, not on develop.
|
||||
#
|
||||
# Fires when main.pot changes on this branch (i.e. after a POT update PR
|
||||
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
|
||||
#
|
||||
# Uses github.ref_name so the file is identical across all hotfix branches
|
||||
# with no branch-specific edits required.
|
||||
|
||||
name: Run hotfix translation sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# One run at a time per branch. cancel-in-progress: false to avoid leaving
|
||||
# an orphaned remote branch from a mid-flight git push + gh pr create.
|
||||
concurrency:
|
||||
group: sync-hotfix-translations-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
name: Sync translations from develop into ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
HOTFIX_BRANCH: ${{ github.ref_name }}
|
||||
APP_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ env.HOTFIX_BRANCH }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.HOTFIX_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run sync script
|
||||
run: |
|
||||
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
PR_REVIEWER: diptanilsaha
|
||||
204
.github/workflows/server-tests-mariadb.yml
vendored
204
.github/workflows/server-tests-mariadb.yml
vendored
@@ -31,49 +31,47 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Shared across both jobs. Both run in the SAME CI image so the bench lives at the identical
|
||||
# path (/home/ci/frappe-bench) on the setup runner and the test shards — that's what makes the
|
||||
# packaged Python venv portable between them.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
ERPNEXT_CI_USER: ci
|
||||
PIP_CACHE_DIR: /home/ci/.cache/pip
|
||||
npm_config_cache: /home/ci/.cache/npm
|
||||
YARN_CACHE_FOLDER: /home/ci/.cache/yarn
|
||||
UV_CACHE_DIR: /home/ci/.cache/uv
|
||||
|
||||
jobs:
|
||||
# Build the bench (clone + pip + yarn + assets) and reinstall test_site ONCE, on a free
|
||||
# GitHub-hosted runner, then publish the whole bench (with a DB dump baked in) as an artifact.
|
||||
# The expensive, non-parallelisable work happens here exactly once instead of on every shard.
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
# Dedicated scale set (fat cpu request) so the build+reinstall runs at full speed, uncontended
|
||||
# by the many thin test shards. Same CI image + /home/ci path + 127.0.0.1 DB as the shards,
|
||||
# so the packaged bench (and its venv) transplants cleanly.
|
||||
runs-on: erpnext-arc-setup
|
||||
timeout-minutes: 40
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
name: Python Unit Tests
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -fq "${GITHUB_WORKSPACE}"
|
||||
@@ -82,17 +80,47 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# MariaDB runs in-container on a datadir OUTSIDE the bench, because install.sh's next step
|
||||
# does `rm -rf ~/frappe-bench`. After the reinstall, the datadir is moved into the bench so
|
||||
# it ships in the artifact — test shards then start an already-loaded server (no restore).
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
CI_DB_DATADIR: /home/ci/db-data
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
@@ -101,114 +129,26 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
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: |
|
||||
mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true
|
||||
for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done
|
||||
# Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship
|
||||
# an inconsistent datadir the shards would have to crash-recover.
|
||||
[ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; }
|
||||
mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data
|
||||
|
||||
# Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh)
|
||||
# into one artifact for the test shards to consume.
|
||||
# Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping
|
||||
# through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so
|
||||
# the shards just untar it locally. NOTE: this assumes one node — a shard on a different
|
||||
# node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume).
|
||||
- name: Stage bench on node (hostPath)
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh
|
||||
mkdir -p /opt/ci-bench-staging
|
||||
# self-clean: drop bench tars from runs older than 2h
|
||||
find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true
|
||||
# Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB).
|
||||
tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz"
|
||||
|
||||
# Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and
|
||||
# runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards.
|
||||
test:
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: erpnext-arc
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# Read the bench straight from the node-local hostPath the setup job staged it on — no
|
||||
# GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly.
|
||||
- name: Untar bench from node (hostPath)
|
||||
run: |
|
||||
tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci
|
||||
ls -ld /home/ci/frappe-bench
|
||||
|
||||
- name: Hydrate (start DB on baked datadir + bench start)
|
||||
run: bash /home/ci/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB_HOST: 127.0.0.1
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} \
|
||||
--build-number ${{ matrix.container }} \
|
||||
$coverage_flag
|
||||
EOF
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
path: /home/ci/frappe-bench/sites/coverage.xml
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: [test]
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
|
||||
194
.github/workflows/server-tests-postgres.yml
vendored
194
.github/workflows/server-tests-postgres.yml
vendored
@@ -1,12 +1,7 @@
|
||||
name: Server (Postgres)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:00 AM IST daily (21:30 UTC the previous day)
|
||||
- cron: "30 21 * * *"
|
||||
pull_request:
|
||||
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
@@ -14,7 +9,7 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
workflow_dispatch:
|
||||
types: [opened, labelled, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
@@ -23,31 +18,41 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
|
||||
# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
|
||||
# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
|
||||
# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
PYTHON_VERSION: '3.14'
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
test:
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
|
||||
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
|
||||
timeout-minutes: 40
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1]
|
||||
|
||||
name: Python Unit Tests
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13.3
|
||||
env:
|
||||
POSTGRES_PASSWORD: travis
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
@@ -66,133 +71,48 @@ jobs:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache deps (uv/pip/npm/yarn)
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/.cache/pip
|
||||
~/.npm
|
||||
~/.cache/yarn
|
||||
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-deps-
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
|
||||
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
|
||||
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
|
||||
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
|
||||
# box. The first run after a deps change populates it; every run after that is fast.
|
||||
- name: Cache warm bench (base build)
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/bench-cache
|
||||
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-warmbench-v2-
|
||||
|
||||
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
|
||||
# after the reinstall it's moved into the bench so it ships in the artifact.
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
env:
|
||||
DB: postgres
|
||||
CI_DB_DATADIR: /home/runner/pgdata
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
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)
|
||||
# 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
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
|
||||
tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
|
||||
|
||||
- name: Upload bench artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-pg
|
||||
path: bench.tar.gz
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Download bench artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bench-pg
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
|
||||
# The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
|
||||
# GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
|
||||
- name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
|
||||
# wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
|
||||
if ! command -v wkhtmltopdf >/dev/null; then
|
||||
wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt-get install -y -qq /tmp/wkhtmltox.deb
|
||||
fi
|
||||
|
||||
- name: Untar bench
|
||||
run: |
|
||||
tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
|
||||
ls -ld /home/runner/frappe-bench
|
||||
|
||||
- name: Hydrate (start Postgres on the baked datadir)
|
||||
run: bash /home/runner/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB: postgres
|
||||
DB_HOST: 127.0.0.1
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
# print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
|
||||
# not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
|
||||
# bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
|
||||
# satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
|
||||
( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
|
||||
for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
|
||||
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
|
||||
env:
|
||||
TYPE: server
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
40
.github/workflows/sync-hotfix-translations.yml
vendored
40
.github/workflows/sync-hotfix-translations.yml
vendored
@@ -1,40 +0,0 @@
|
||||
# Orchestrator — lives on develop only.
|
||||
#
|
||||
# Triggers on the weekly schedule and dispatches the runner workflow on each
|
||||
# hotfix branch listed in the matrix. To add or remove a branch, edit the
|
||||
# matrix below.
|
||||
#
|
||||
# POT-change triggers are handled by the runner on each hotfix branch
|
||||
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
|
||||
# from the branch that receives the push.
|
||||
|
||||
name: Sync translations to hotfix branches
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 10:00 UTC Monday
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN,
|
||||
# so no GITHUB_TOKEN permissions are required.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
trigger-runners:
|
||||
name: Trigger sync → ${{ matrix.hotfix_branch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
hotfix_branch:
|
||||
- version-16-hotfix
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Dispatch runner on ${{ matrix.hotfix_branch }}
|
||||
run: |
|
||||
gh workflow run run-hotfix-translation-sync.yml \
|
||||
--repo "${{ github.repository }}" \
|
||||
--ref "${{ matrix.hotfix_branch }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
File diff suppressed because one or more lines are too long
@@ -48,6 +48,7 @@ repos:
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
erpnext/public/js/controllers/.*|
|
||||
erpnext/templates/pages/order.js|
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
@@ -65,18 +66,6 @@ repos:
|
||||
- id: ruff-format
|
||||
name: "Run ruff formatter"
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: postgres-compat
|
||||
name: "PostgreSQL compatibility (static check)"
|
||||
description: "Flags MySQL-only SQL that breaks on Postgres; the label-gated PG test job is the backstop for semantic divergences."
|
||||
entry: .github/helper/postgres_compat.py
|
||||
language: script
|
||||
files: ^erpnext/.*\.py$
|
||||
# patches/ are historical, version-gated migrations (skipped on fresh Postgres installs);
|
||||
# out of scope for the always-on gate.
|
||||
exclude: ^erpnext/patches/
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
|
||||
@@ -9,18 +9,16 @@ export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
onlyExportComponents: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -14,52 +14,53 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"frappe-react-sdk": "^1.15.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.6",
|
||||
"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",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.62.1"
|
||||
"typescript-eslint": "^8.48.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const common_site_config = JSON.parse(
|
||||
readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8')
|
||||
) as { webserver_port: string | number };
|
||||
const common_site_config = require('../../../sites/common_site_config.json');
|
||||
const { webserver_port } = common_site_config;
|
||||
|
||||
export default {
|
||||
'^/(app|api|assets|files|private)': {
|
||||
target: `http://127.0.0.1:${webserver_port}`,
|
||||
ws: true,
|
||||
router: function (req) {
|
||||
const site_name = req.headers?.host?.split(':')[0];
|
||||
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
|
||||
router: function(req) {
|
||||
const site_name = req.headers.host.split(':')[0];
|
||||
return `http://${site_name}:${webserver_port}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
|
||||
import { TooltipProvider } from './components/ui/tooltip'
|
||||
import BankStatementImporter from '@/pages/BankStatementImporter'
|
||||
import { LucideProvider } from 'lucide-react'
|
||||
import { ThemeProvider } from './components/ui/theme-provider'
|
||||
|
||||
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
|
||||
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
|
||||
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
|
||||
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
@@ -44,6 +43,7 @@ function App() {
|
||||
>
|
||||
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
||||
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
||||
|
||||
<Routes>
|
||||
<Route index element={<BankReconciliation />} />
|
||||
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
||||
|
||||
@@ -1,42 +1,475 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import ActionLogDialog from './ActionLogDialog'
|
||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { slug } from '@/lib/frappe'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const ActionLog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Reconciliation History")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<ActionLogDialog onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Reconciliation History")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className='min-w-[90vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
||||
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ActionLogDialogContent />
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' onClick={() => setIsOpen(false)}>{_("Close")}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLog
|
||||
const ActionLogDialogContent = () => {
|
||||
|
||||
const actionLog = useAtomValue(bankRecActionLog)
|
||||
|
||||
return <div className='flex flex-col gap-2'>
|
||||
{actionLog.map((action) => (
|
||||
<div key={action.timestamp} className='flex flex-col gap-1'>
|
||||
<ActionGroupHeader action={action} />
|
||||
<div>
|
||||
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
|
||||
<div className='ms-5'>
|
||||
{action.items.map((item, index) => (
|
||||
<Row
|
||||
item={item}
|
||||
key={item.bankTransaction.name}
|
||||
index={index}
|
||||
action={action}
|
||||
isLast={index === action.items.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actionLog.length === 0 && <Empty>
|
||||
<EmptyMedia>
|
||||
<HistoryIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
|
||||
|
||||
const label = useMemo(() => {
|
||||
switch (action.type) {
|
||||
case 'match':
|
||||
return _("Matched")
|
||||
case 'payment':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Payment")
|
||||
}
|
||||
return _("Payment")
|
||||
|
||||
case 'transfer':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Transfer")
|
||||
}
|
||||
return _("Transfer")
|
||||
|
||||
case 'bank_entry':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Bank Entry")
|
||||
}
|
||||
return _("Bank Entry")
|
||||
|
||||
default:
|
||||
return _("Action")
|
||||
}
|
||||
}, [action])
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5'>
|
||||
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
|
||||
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
|
||||
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
|
||||
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
|
||||
<span className='flex items-center gap-2 text-sm'>
|
||||
{label} - {dayjs(action.timestamp).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (item.bankTransaction.bank_account) {
|
||||
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [item.bankTransaction.bank_account, banks])
|
||||
|
||||
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
|
||||
|
||||
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
|
||||
|
||||
return <div className='flex items-center gap-2 group'>
|
||||
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-p-base'>{item.bankTransaction.description}</p>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
|
||||
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
|
||||
<CalendarIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div>
|
||||
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end items-center gap-2'>
|
||||
<div className='text-end flex flex-col gap-2'>
|
||||
<a
|
||||
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 text-base'>
|
||||
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
|
||||
</a>
|
||||
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
|
||||
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-10 h-10 flex items-center justify-center'>
|
||||
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
|
||||
<WalletIcon className='w-4 h-4' />
|
||||
<JournalEntryAccountsTable item={item} bank={bank} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
const accounts = useMemo(() => {
|
||||
|
||||
const allAccounts = (item.voucher.doc as JournalEntry).accounts
|
||||
|
||||
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
|
||||
|
||||
}, [item, bank])
|
||||
|
||||
return <>
|
||||
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Debit")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.account}>
|
||||
<TableCell>{account.account}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
}</>
|
||||
}
|
||||
|
||||
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
|
||||
return <TransferDetails item={item} className={className} />
|
||||
}
|
||||
|
||||
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
|
||||
|
||||
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
|
||||
|
||||
return <div className='flex items-center gap-3'>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<UserIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<ReceiptTextIcon className='w-4 h-4' />
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{invoices.map((invoice) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Invoice No")}</TableHead>
|
||||
<TableHead>{_("Due Date")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Allocated")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
|
||||
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
|
||||
<TableCell>{formatDate(invoice.due_date)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
let transferAccount = ""
|
||||
|
||||
if (isWithdrawal) {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
|
||||
} else {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
|
||||
}
|
||||
|
||||
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
|
||||
|
||||
return transferBankAccount
|
||||
|
||||
}, [banks, item])
|
||||
|
||||
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
|
||||
<span className='text-sm'>{bank?.account}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ACTION_TYPE_MAP = {
|
||||
'bank_entry': _("Bank Entry"),
|
||||
'payment': _("Payment"),
|
||||
'transfer': _("Transfer"),
|
||||
'match': _("Match"),
|
||||
}
|
||||
|
||||
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
|
||||
const { mutate } = useSWRConfig()
|
||||
const actionLog = useSetAtom(bankRecActionLog)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const onUndo = () => {
|
||||
call({
|
||||
bank_transaction_id: item.bankTransaction.name,
|
||||
voucher_type: item.voucher.reference_doctype,
|
||||
voucher_id: item.voucher.reference_name,
|
||||
}).then(() => {
|
||||
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
|
||||
|
||||
if (selectedBank?.name === item.bankTransaction.bank_account) {
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
// Update the matching vouchers for the selected transaction
|
||||
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
actionLog((prev) => {
|
||||
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
|
||||
const action = prev.find((action) => action.timestamp === timestamp)
|
||||
|
||||
if (action) {
|
||||
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
|
||||
}
|
||||
// If the action is empty, remove the action from the array
|
||||
if (action && action.items.length === 0) {
|
||||
return prev.filter((a) => a.timestamp !== timestamp)
|
||||
} else {
|
||||
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
|
||||
setIsOpen(false)
|
||||
|
||||
}).catch((error) => {
|
||||
toast.error(_("There was an error while performing the action."), {
|
||||
duration: 5000,
|
||||
description: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
isIconButton
|
||||
theme='red'
|
||||
title={_("Cancel")}
|
||||
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
|
||||
<CircleXIcon className='w-8 h-8' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Cancel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent className='min-w-3xl'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<SelectedTransactionDetails transaction={item.bankTransaction} />
|
||||
<Table>
|
||||
<TableRow>
|
||||
<TableHead>{_("Action Type")}</TableHead>
|
||||
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Type")}</TableHead>
|
||||
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Name")}</TableHead>
|
||||
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Posting Date")}</TableHead>
|
||||
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
|
||||
</TableRow>
|
||||
{type === 'transfer' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Transfer Account")}</TableHead>
|
||||
<TableCell>
|
||||
<TransferDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'payment' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Payment Details")}</TableHead>
|
||||
<TableCell>
|
||||
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'bank_entry' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
|
||||
</TableRow>}
|
||||
</Table>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{_("Close")}
|
||||
</AlertDialogCancel>
|
||||
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
|
||||
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
|
||||
export default ActionLog
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody'))
|
||||
|
||||
const ActionLogDialogFallback = () => (
|
||||
<div className="flex flex-1 items-center justify-center min-h-[40vh]">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ActionLogDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
return (
|
||||
<DialogContent className='min-w-[90vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
||||
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Suspense fallback={<ActionLogDialogFallback />}>
|
||||
<ActionLogDialogBody />
|
||||
</Suspense>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' onClick={onClose}>{_("Close")}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLogDialog
|
||||
@@ -1,431 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { slug } from '@/lib/frappe'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const ActionLogDialogBody = () => {
|
||||
|
||||
const actionLog = useAtomValue(bankRecActionLog)
|
||||
|
||||
return <div className='flex flex-col gap-2'>
|
||||
{actionLog.map((action) => (
|
||||
<div key={action.timestamp} className='flex flex-col gap-1'>
|
||||
<ActionGroupHeader action={action} />
|
||||
<div>
|
||||
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
|
||||
<div className='ms-5'>
|
||||
{action.items.map((item, index) => (
|
||||
<Row
|
||||
item={item}
|
||||
key={item.bankTransaction.name}
|
||||
index={index}
|
||||
action={action}
|
||||
isLast={index === action.items.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actionLog.length === 0 && <Empty>
|
||||
<EmptyMedia>
|
||||
<HistoryIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
|
||||
|
||||
const label = useMemo(() => {
|
||||
switch (action.type) {
|
||||
case 'match':
|
||||
return _("Matched")
|
||||
case 'payment':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Payment")
|
||||
}
|
||||
return _("Payment")
|
||||
|
||||
case 'transfer':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Transfer")
|
||||
}
|
||||
return _("Transfer")
|
||||
|
||||
case 'bank_entry':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Bank Entry")
|
||||
}
|
||||
return _("Bank Entry")
|
||||
|
||||
default:
|
||||
return _("Action")
|
||||
}
|
||||
}, [action])
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5'>
|
||||
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
|
||||
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
|
||||
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
|
||||
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
|
||||
<span className='flex items-center gap-2 text-sm'>
|
||||
{label} - {dayjs(action.timestamp).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (item.bankTransaction.bank_account) {
|
||||
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [item.bankTransaction.bank_account, banks])
|
||||
|
||||
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
|
||||
|
||||
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
|
||||
|
||||
return <div className='flex items-center gap-2 group'>
|
||||
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-p-base'>{item.bankTransaction.description}</p>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
|
||||
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
|
||||
<CalendarIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div>
|
||||
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end items-center gap-2'>
|
||||
<div className='text-end flex flex-col gap-2'>
|
||||
<a
|
||||
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 text-base'>
|
||||
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
|
||||
</a>
|
||||
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
|
||||
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-10 h-10 flex items-center justify-center'>
|
||||
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
|
||||
<WalletIcon className='w-4 h-4' />
|
||||
<JournalEntryAccountsTable item={item} bank={bank} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
const accounts = useMemo(() => {
|
||||
|
||||
const allAccounts = (item.voucher.doc as JournalEntry).accounts
|
||||
|
||||
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
|
||||
|
||||
}, [item, bank])
|
||||
|
||||
return <>
|
||||
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Debit")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.account}>
|
||||
<TableCell>{account.account}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
}</>
|
||||
}
|
||||
|
||||
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
|
||||
return <TransferDetails item={item} className={className} />
|
||||
}
|
||||
|
||||
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
|
||||
|
||||
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
|
||||
|
||||
return <div className='flex items-center gap-3'>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<UserIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<ReceiptTextIcon className='w-4 h-4' />
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{invoices.map((invoice) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Invoice No")}</TableHead>
|
||||
<TableHead>{_("Due Date")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Allocated")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
|
||||
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
|
||||
<TableCell>{formatDate(invoice.due_date)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
let transferAccount = ""
|
||||
|
||||
if (isWithdrawal) {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
|
||||
} else {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
|
||||
}
|
||||
|
||||
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
|
||||
|
||||
return transferBankAccount
|
||||
|
||||
}, [banks, item])
|
||||
|
||||
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
|
||||
<span className='text-sm'>{bank?.account}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ACTION_TYPE_MAP = {
|
||||
'bank_entry': _("Bank Entry"),
|
||||
'payment': _("Payment"),
|
||||
'transfer': _("Transfer"),
|
||||
'match': _("Match"),
|
||||
}
|
||||
|
||||
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
|
||||
const { mutate } = useSWRConfig()
|
||||
const actionLog = useSetAtom(bankRecActionLog)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const onUndo = () => {
|
||||
call({
|
||||
bank_transaction_id: item.bankTransaction.name,
|
||||
voucher_type: item.voucher.reference_doctype,
|
||||
voucher_id: item.voucher.reference_name,
|
||||
}).then(() => {
|
||||
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
|
||||
|
||||
if (selectedBank?.name === item.bankTransaction.bank_account) {
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
// Update the matching vouchers for the selected transaction
|
||||
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
actionLog((prev) => {
|
||||
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
|
||||
const action = prev.find((action) => action.timestamp === timestamp)
|
||||
|
||||
if (action) {
|
||||
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
|
||||
}
|
||||
// If the action is empty, remove the action from the array
|
||||
if (action && action.items.length === 0) {
|
||||
return prev.filter((a) => a.timestamp !== timestamp)
|
||||
} else {
|
||||
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
|
||||
setIsOpen(false)
|
||||
|
||||
}).catch((error) => {
|
||||
toast.error(_("There was an error while performing the action."), {
|
||||
duration: 5000,
|
||||
description: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
isIconButton
|
||||
theme='red'
|
||||
title={_("Cancel")}
|
||||
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
|
||||
<CircleXIcon className='w-8 h-8' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Cancel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent className='min-w-3xl'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<SelectedTransactionDetails transaction={item.bankTransaction} />
|
||||
<Table>
|
||||
<TableRow>
|
||||
<TableHead>{_("Action Type")}</TableHead>
|
||||
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Type")}</TableHead>
|
||||
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Name")}</TableHead>
|
||||
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Posting Date")}</TableHead>
|
||||
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
|
||||
</TableRow>
|
||||
{type === 'transfer' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Transfer Account")}</TableHead>
|
||||
<TableCell>
|
||||
<TransferDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'payment' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Payment Details")}</TableHead>
|
||||
<TableCell>
|
||||
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'bank_entry' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
|
||||
</TableRow>}
|
||||
</Table>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{_("Close")}
|
||||
</AlertDialogCancel>
|
||||
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
|
||||
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
|
||||
export default ActionLogDialogBody
|
||||
@@ -250,7 +250,7 @@ const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { de
|
||||
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <div className="py-2"><ErrorBanner error={error} /></div>}
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className="py-4">
|
||||
<CurrencyFormField
|
||||
name="balance"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
@@ -25,7 +26,6 @@ import { Form } from "@/components/ui/form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { DateField } from "@/components/ui/form-elements"
|
||||
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankClearanceSummary = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard],
|
||||
[copyToClipboard, _],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
@@ -200,17 +200,17 @@ const BankClearanceSummaryView = () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
)
|
||||
|
||||
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -1,32 +1,831 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
|
||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
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 { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
const BankEntryModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<RecordBankEntryModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RecordBankEntryModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const RecordBankEntryModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <BankEntryForm
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkBankEntryForm
|
||||
selectedTransactions={selectedTransaction}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
account: string
|
||||
}>({
|
||||
defaultValues: {
|
||||
account: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onSubmit = (data: { account: string }) => {
|
||||
|
||||
call({
|
||||
bank_transactions: selectedTransactions.map(transaction => transaction.name),
|
||||
account: data.account
|
||||
}).then(({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: item.journal_entry.name,
|
||||
doc: item.journal_entry,
|
||||
posting_date: item.journal_entry.posting_date,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
account: data.account,
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(_("Bank Entries Created"), {
|
||||
duration: 4000,
|
||||
})
|
||||
|
||||
// Set this to the last selected transaction
|
||||
onReconcile(selectedTransactions[selectedTransactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<AccountFormField
|
||||
name='account'
|
||||
filterFunction={(acc) => {
|
||||
// Do not allow payable and receivable accounts
|
||||
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
|
||||
}}
|
||||
label={_('Account')}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
|
||||
entries: JournalEntry['accounts']
|
||||
}
|
||||
|
||||
|
||||
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const defaultAccounts = useMemo(() => {
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const accounts: Partial<JournalEntryAccount>[] = [
|
||||
{
|
||||
account: selectedBankAccount?.account ?? '',
|
||||
bank_account: selectedTransaction.bank_account,
|
||||
// Bank is debited if it's a deposit
|
||||
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
party_type: '',
|
||||
party: '',
|
||||
cost_center: ''
|
||||
}]
|
||||
|
||||
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
|
||||
if (!rule) {
|
||||
accounts.push(
|
||||
{
|
||||
account: '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Rule exists, so we need to check the type of rule
|
||||
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
|
||||
// Only a single account needs to be added
|
||||
accounts.push({
|
||||
account: rule.account ?? '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
})
|
||||
} else {
|
||||
// For multiple accounts, we need to loop over and add entries for each
|
||||
// The last row will just be the remaining amount
|
||||
let hasTotallyEmptyRowEarlier = false;
|
||||
|
||||
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
|
||||
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
|
||||
|
||||
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
|
||||
|
||||
const acc = rule.accounts?.[i]
|
||||
// If it's the last row, add the difference amount
|
||||
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
|
||||
|
||||
const differenceAmount = flt(totalDebits - totalCredits, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
|
||||
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
} 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;
|
||||
}
|
||||
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
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: computedDebit,
|
||||
credit: computedCredit,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
|
||||
}, [rule, selectedTransaction, selectedBankAccount])
|
||||
|
||||
const form = useForm<BankEntryFormData>({
|
||||
defaultValues: {
|
||||
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
|
||||
cheque_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
user_remark: selectedTransaction.description,
|
||||
entries: defaultAccounts,
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: BankEntryFormData) => {
|
||||
|
||||
createBankEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data
|
||||
}).then(async ({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
isBulk: false,
|
||||
timestamp: (new Date()).getTime(),
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: message.journal_entry.name,
|
||||
reference_no: message.journal_entry.cheque_no,
|
||||
reference_date: message.journal_entry.cheque_date,
|
||||
posting_date: message.journal_entry.posting_date,
|
||||
doc: message.journal_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Bank Entry Created"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
|
||||
const uploadPromises = files.map(f => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Journal Entry",
|
||||
docname: message.journal_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
|
||||
setUploadProgress((currentProgress) => {
|
||||
//If there are multiple files, we need to add the progress to the current progress
|
||||
return currentProgress + ((progress?.progress ?? 0) / files.length)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
setUploadProgress(0)
|
||||
setIsUploading(false)
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
toast.error(_("Error uploading attachments"), {
|
||||
duration: 4000,
|
||||
})
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
}).then(() => {
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='cheque_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference Date is required"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference is required"),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SmallTextField
|
||||
name='user_remark'
|
||||
label={_("Remarks")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
}
|
||||
|
||||
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
|
||||
|
||||
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const partyMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
const onPartyChange = (value: string, index: number) => {
|
||||
// Get the account for the party type
|
||||
if (value) {
|
||||
if (partyMapRef.current[value]) {
|
||||
setValue(`entries.${index}.account`, partyMapRef.current[value])
|
||||
} else {
|
||||
call.get('erpnext.accounts.party.get_party_account', {
|
||||
party: value,
|
||||
party_type: getValues(`entries.${index}.party_type`),
|
||||
company: company
|
||||
}).then((result: { message: string }) => {
|
||||
setValue(`entries.${index}.account`, result.message)
|
||||
partyMapRef.current[value] = result.message
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setValue(`entries.${index}.account`, '')
|
||||
}
|
||||
}
|
||||
|
||||
const { data: accounts } = useGetAccounts()
|
||||
|
||||
const onAccountChange = (value: string, index: number) => {
|
||||
// If it's an income or expense account, get the default cost center
|
||||
if (value) {
|
||||
const account = accounts?.find((acc) => acc.name === value)
|
||||
if (account && account.report_type === "Profit and Loss") {
|
||||
// Set the default company cost center
|
||||
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setValue(`entries.${index}.cost_center`, '')
|
||||
}
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: control,
|
||||
name: 'entries'
|
||||
})
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}, [company, append, getValues])
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([])
|
||||
|
||||
const onSelectRow = useCallback((index: number) => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.includes(index)) {
|
||||
return prev.filter(i => i !== index)
|
||||
}
|
||||
return [...prev, index]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.length === fields.length) {
|
||||
return []
|
||||
}
|
||||
return [...fields.map((_, index) => index)]
|
||||
})
|
||||
}, [fields])
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
remove(selectedRows)
|
||||
setSelectedRows([])
|
||||
}, [remove, selectedRows])
|
||||
|
||||
/**
|
||||
* When add difference is clicked, check if the last row has nothing filled in.
|
||||
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
|
||||
*/
|
||||
const onAddDifferenceClicked = () => {
|
||||
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const lastIndex = existingEntries.length - 1
|
||||
|
||||
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
setValue(`entries.${lastIndex}.debit`, debitAmount)
|
||||
setValue(`entries.${lastIndex}.credit`, creditAmount)
|
||||
} else {
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return <div className="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead><Checkbox
|
||||
disabled={fields.length === 0}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select all")}
|
||||
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
||||
onCheckedChange={onSelectAll} /></TableHead>
|
||||
<TableHead>{_("Party")}</TableHead>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead>{_("Cost Center")}</TableHead>
|
||||
<TableHead>{_("Remarks")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(index)}
|
||||
onCheckedChange={() => onSelectRow(index)}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="align-top">
|
||||
<div className="flex">
|
||||
<PartyTypeFormField
|
||||
name={`entries.${index}.party_type`}
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
inputProps={{
|
||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||
triggerProps: {
|
||||
className: 'rounded-e-none',
|
||||
tabIndex: -1
|
||||
},
|
||||
readOnly: index === 0,
|
||||
}} />
|
||||
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AccountFormField
|
||||
name={`entries.${index}.account`}
|
||||
label={_("Account")}
|
||||
rules={{
|
||||
required: _("Account is required"),
|
||||
onChange: (event) => {
|
||||
onAccountChange(event.target.value, index)
|
||||
}
|
||||
}}
|
||||
buttonClassName="min-w-64"
|
||||
readOnly={index === 0}
|
||||
isRequired
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<LinkFormField
|
||||
doctype="Cost Center"
|
||||
name={`entries.${index}.cost_center`}
|
||||
label={_("Cost Center")}
|
||||
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
||||
buttonClassName="min-w-48"
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<DataField
|
||||
name={`entries.${index}.user_remark`}
|
||||
label={_("Remarks")}
|
||||
readOnly={index === 0}
|
||||
inputProps={{
|
||||
placeholder: _("e.g. Bank Charges"),
|
||||
className: 'min-w-64',
|
||||
readOnly: index === 0
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.debit`}
|
||||
label={_("Debit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
style={index === 0 ? !isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {} : {}}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.credit`}
|
||||
style={index === 0 && isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {}}
|
||||
label={_("Credit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div>
|
||||
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
||||
</div>
|
||||
{selectedRows.length > 0 && <div>
|
||||
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
<Summary currency={currency} addRow={onAddDifferenceClicked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `entries.${index}.party_type`
|
||||
})
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
className: 'rounded-s-none border-s-0 min-w-64'
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange: (event) => {
|
||||
onChange(event.target.value, index)
|
||||
},
|
||||
}}
|
||||
hideLabel
|
||||
readOnly={readOnly}
|
||||
buttonClassName="rounded-s-none border-s-0 min-w-64"
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
|
||||
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const entries = useWatch({ control, name: 'entries' })
|
||||
|
||||
const { total, totalCredits, totalDebits } = useMemo(() => {
|
||||
// Do a total debits - total credits
|
||||
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
|
||||
}, [entries])
|
||||
|
||||
const onAddRow = useCallback(() => {
|
||||
addRow()
|
||||
}, [addRow])
|
||||
|
||||
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
|
||||
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-2 items-end">
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Debit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Credit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
|
||||
</div>
|
||||
{total !== 0 && <div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Difference")}</TextComponent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
|
||||
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Add a row with the difference amount")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default BankEntryModal
|
||||
|
||||
@@ -1,787 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import _ from "@/lib/translate"
|
||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
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"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
const RecordBankEntryModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <BankEntryForm
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkBankEntryForm
|
||||
selectedTransactions={selectedTransaction}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
account: string
|
||||
}>({
|
||||
defaultValues: {
|
||||
account: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onSubmit = (data: { account: string }) => {
|
||||
|
||||
call({
|
||||
bank_transactions: selectedTransactions.map(transaction => transaction.name),
|
||||
account: data.account
|
||||
}).then(({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: item.journal_entry.name,
|
||||
doc: item.journal_entry,
|
||||
posting_date: item.journal_entry.posting_date,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
account: data.account,
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(_("Bank Entries Created"), {
|
||||
duration: 4000,
|
||||
})
|
||||
|
||||
// Set this to the last selected transaction
|
||||
onReconcile(selectedTransactions[selectedTransactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<AccountFormField
|
||||
name='account'
|
||||
filterFunction={(acc) => {
|
||||
// Do not allow payable and receivable accounts
|
||||
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
|
||||
}}
|
||||
label={_('Account')}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
|
||||
entries: JournalEntry['accounts']
|
||||
}
|
||||
|
||||
|
||||
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const defaultAccounts = useMemo(() => {
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const accounts: Partial<JournalEntryAccount>[] = [
|
||||
{
|
||||
account: selectedBankAccount?.account ?? '',
|
||||
bank_account: selectedTransaction.bank_account,
|
||||
// Bank is debited if it's a deposit
|
||||
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
party_type: '',
|
||||
party: '',
|
||||
cost_center: ''
|
||||
}]
|
||||
|
||||
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
|
||||
if (!rule) {
|
||||
accounts.push(
|
||||
{
|
||||
account: '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Rule exists, so we need to check the type of rule
|
||||
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
|
||||
// Only a single account needs to be added
|
||||
accounts.push({
|
||||
account: rule.account ?? '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
})
|
||||
} else {
|
||||
// For multiple accounts, we need to loop over and add entries for each
|
||||
// The last row will just be the remaining amount
|
||||
let hasTotallyEmptyRowEarlier = false;
|
||||
|
||||
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
|
||||
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
|
||||
|
||||
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
|
||||
|
||||
const acc = rule.accounts?.[i]
|
||||
// If it's the last row, add the difference amount
|
||||
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
|
||||
|
||||
const differenceAmount = flt(totalDebits - totalCredits, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
|
||||
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
} else {
|
||||
|
||||
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: computedDebit,
|
||||
credit: computedCredit,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
|
||||
}, [rule, selectedTransaction, selectedBankAccount])
|
||||
|
||||
const form = useForm<BankEntryFormData>({
|
||||
defaultValues: {
|
||||
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
|
||||
cheque_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
user_remark: selectedTransaction.description,
|
||||
entries: defaultAccounts,
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: BankEntryFormData) => {
|
||||
|
||||
createBankEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data
|
||||
}).then(async ({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
isBulk: false,
|
||||
timestamp: (new Date()).getTime(),
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: message.journal_entry.name,
|
||||
reference_no: message.journal_entry.cheque_no,
|
||||
reference_date: message.journal_entry.cheque_date,
|
||||
posting_date: message.journal_entry.posting_date,
|
||||
doc: message.journal_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Bank Entry Created"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
startTracking(files.length)
|
||||
|
||||
const uploadPromises = files.map((f, fileIndex) => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Journal Entry",
|
||||
docname: message.journal_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
updateFileProgress(fileIndex, progress?.progress ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
toast.error(_("Error uploading attachments"), {
|
||||
duration: 4000,
|
||||
})
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
}).then(() => {
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='cheque_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference Date is required"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference is required"),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SmallTextField
|
||||
name='user_remark'
|
||||
label={_("Remarks")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
}
|
||||
|
||||
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
|
||||
|
||||
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const partyMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
const onPartyChange = (value: string, index: number) => {
|
||||
// Get the account for the party type
|
||||
if (value) {
|
||||
if (partyMapRef.current[value]) {
|
||||
setValue(`entries.${index}.account`, partyMapRef.current[value])
|
||||
} else {
|
||||
call.get('erpnext.accounts.party.get_party_account', {
|
||||
party: value,
|
||||
party_type: getValues(`entries.${index}.party_type`),
|
||||
company: company
|
||||
}).then((result: { message: string }) => {
|
||||
setValue(`entries.${index}.account`, result.message)
|
||||
partyMapRef.current[value] = result.message
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setValue(`entries.${index}.account`, '')
|
||||
}
|
||||
}
|
||||
|
||||
const { data: accounts } = useGetAccounts()
|
||||
|
||||
const onAccountChange = (value: string, index: number) => {
|
||||
// If it's an income or expense account, get the default cost center
|
||||
if (value) {
|
||||
const account = accounts?.find((acc) => acc.name === value)
|
||||
if (account && account.report_type === "Profit and Loss") {
|
||||
// Set the default company cost center
|
||||
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setValue(`entries.${index}.cost_center`, '')
|
||||
}
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: control,
|
||||
name: 'entries'
|
||||
})
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}, [company, append, getValues])
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([])
|
||||
|
||||
const onSelectRow = useCallback((index: number) => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.includes(index)) {
|
||||
return prev.filter(i => i !== index)
|
||||
}
|
||||
return [...prev, index]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.length === fields.length) {
|
||||
return []
|
||||
}
|
||||
return [...fields.map((_, index) => index)]
|
||||
})
|
||||
}, [fields])
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
// Do not remove the first row
|
||||
remove(selectedRows.filter(index => index !== 0))
|
||||
setSelectedRows([])
|
||||
}, [remove, selectedRows])
|
||||
|
||||
/**
|
||||
* When add difference is clicked, check if the last row has nothing filled in.
|
||||
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
|
||||
*/
|
||||
const onAddDifferenceClicked = () => {
|
||||
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const lastIndex = existingEntries.length - 1
|
||||
|
||||
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
setValue(`entries.${lastIndex}.debit`, debitAmount)
|
||||
setValue(`entries.${lastIndex}.credit`, creditAmount)
|
||||
} else {
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return <div className="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead><Checkbox
|
||||
disabled={fields.length === 0}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select all")}
|
||||
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
||||
onCheckedChange={onSelectAll} /></TableHead>
|
||||
<TableHead>{_("Party")}</TableHead>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead>{_("Cost Center")}</TableHead>
|
||||
<TableHead>{_("Remarks")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(index)}
|
||||
onCheckedChange={() => onSelectRow(index)}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="align-top">
|
||||
<div className="flex">
|
||||
<PartyTypeFormField
|
||||
name={`entries.${index}.party_type`}
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
inputProps={{
|
||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||
triggerProps: {
|
||||
className: 'rounded-e-none',
|
||||
tabIndex: -1
|
||||
},
|
||||
readOnly: index === 0,
|
||||
}} />
|
||||
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AccountFormField
|
||||
name={`entries.${index}.account`}
|
||||
label={_("Account")}
|
||||
rules={{
|
||||
required: _("Account is required"),
|
||||
onChange: (event) => {
|
||||
onAccountChange(event.target.value, index)
|
||||
}
|
||||
}}
|
||||
buttonClassName="min-w-64"
|
||||
readOnly={index === 0}
|
||||
isRequired
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<LinkFormField
|
||||
doctype="Cost Center"
|
||||
name={`entries.${index}.cost_center`}
|
||||
label={_("Cost Center")}
|
||||
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
||||
buttonClassName="min-w-48"
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<DataField
|
||||
name={`entries.${index}.user_remark`}
|
||||
label={_("Remarks")}
|
||||
readOnly={index === 0}
|
||||
inputProps={{
|
||||
placeholder: _("e.g. Bank Charges"),
|
||||
className: 'min-w-64',
|
||||
readOnly: index === 0
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.debit`}
|
||||
label={_("Debit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
style={index === 0 ? !isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {} : {}}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.credit`}
|
||||
style={index === 0 && isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {}}
|
||||
label={_("Credit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div>
|
||||
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
||||
</div>
|
||||
{selectedRows.length > 0 && <div>
|
||||
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
<Summary currency={currency} addRow={onAddDifferenceClicked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `entries.${index}.party_type`
|
||||
})
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
className: 'rounded-s-none border-s-0 min-w-64'
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange: (event) => {
|
||||
onChange(event.target.value, index)
|
||||
},
|
||||
}}
|
||||
hideLabel
|
||||
readOnly={readOnly}
|
||||
buttonClassName="rounded-s-none border-s-0 min-w-64"
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
|
||||
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const entries = useWatch({ control, name: 'entries' })
|
||||
|
||||
const { total, totalCredits, totalDebits } = useMemo(() => {
|
||||
// Do a total debits - total credits
|
||||
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
|
||||
}, [entries])
|
||||
|
||||
const onAddRow = useCallback(() => {
|
||||
addRow()
|
||||
}, [addRow])
|
||||
|
||||
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
|
||||
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-2 items-end">
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Debit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Credit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
|
||||
</div>
|
||||
{total !== 0 && <div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Difference")}</TextComponent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
|
||||
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Add a row with the difference amount")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default RecordBankEntryModalContent
|
||||
@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
@@ -18,7 +19,6 @@ import _ from "@/lib/translate"
|
||||
import { toast } from "sonner"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankReconciliationStatement = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard],
|
||||
[copyToClipboard, _],
|
||||
)
|
||||
|
||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
],
|
||||
[onCopy],
|
||||
[_, onCopy],
|
||||
)
|
||||
|
||||
const statementRows = useMemo(() => {
|
||||
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
|
||||
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
|
||||
}, [data])
|
||||
|
||||
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -22,7 +23,6 @@ import { useCallback, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankTransactions = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[accountCurrency, onUndo],
|
||||
[_, accountCurrency, onUndo],
|
||||
)
|
||||
|
||||
const [search, setSearch] = useDebounceValue('', 250)
|
||||
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
|
||||
|
||||
}, [data, search, amountFilter, typeFilter, status])
|
||||
|
||||
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
|
||||
@@ -1,52 +1,125 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAtom } from "jotai"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
|
||||
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useMemo } from "react"
|
||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody'))
|
||||
|
||||
const BankTransactionUnreconcileModalFallback = () => (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const BankTransactionUnreconcileModal = () => {
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
|
||||
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<BankTransactionUnreconcileModalContent />
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
if (!unreconcileModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
|
||||
<BankTransactionUnreconcileModalBody />
|
||||
</Suspense>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModal
|
||||
const BankTransactionUnreconcileModalContent = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const { data: transaction, error } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
|
||||
|
||||
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
|
||||
|
||||
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
call({
|
||||
transaction_name: unreconcileModal
|
||||
}).then(() => {
|
||||
// Mutate the transactions list, unreconciled transactions list and account closing balance
|
||||
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
|
||||
toast.success(_("Transaction Unreconciled"))
|
||||
setBankRecUnreconcileModal('')
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const vouchersWhichWillBeCancelled = useMemo(() => {
|
||||
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
|
||||
}, [transaction])
|
||||
|
||||
return <div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
{unreconcileError && <ErrorBanner error={unreconcileError} />}
|
||||
{transaction && <SelectedTransactionDetails transaction={transaction} />}
|
||||
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Amount")}</TableHead>
|
||||
<TableHead>{_("Reconciliation Type")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transaction?.payment_entries?.map((voucher) => {
|
||||
return <TableRow key={voucher.name}>
|
||||
<TableCell>
|
||||
<a className="underline underline-offset-4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
|
||||
>
|
||||
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
|
||||
<TableCell>{voucher.reconciliation_type === 'Voucher Created' ?
|
||||
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
|
||||
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}</TableCell>
|
||||
</TableRow>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="py-4">
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <span>The following documents will be <strong>cancelled</strong>:</span>}
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <ol className="ms-6 list-disc [&>li]:mt-2">
|
||||
{vouchersWhichWillBeCancelled?.map((voucher) => {
|
||||
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
|
||||
})}
|
||||
</ol>}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading}>
|
||||
{_("Unreconcile")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModal
|
||||
@@ -1,109 +0,0 @@
|
||||
import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from "@/components/ui/alert-dialog"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useMemo } from "react"
|
||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModalBody = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const { data: transaction, error, isLoading } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
|
||||
|
||||
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
|
||||
|
||||
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
call({
|
||||
transaction_name: unreconcileModal
|
||||
}).then(() => {
|
||||
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
|
||||
toast.success(_("Transaction Unreconciled"))
|
||||
setBankRecUnreconcileModal('')
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const vouchersWhichWillBeCancelled = useMemo(() => {
|
||||
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
|
||||
}, [transaction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
{unreconcileError && <ErrorBanner error={unreconcileError} />}
|
||||
{transaction && <SelectedTransactionDetails transaction={transaction} />}
|
||||
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Amount")}</TableHead>
|
||||
<TableHead>{_("Reconciliation Type")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transaction?.payment_entries?.map((voucher) => {
|
||||
return (
|
||||
<TableRow key={voucher.name}>
|
||||
<TableCell>
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
|
||||
>
|
||||
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
|
||||
<TableCell>
|
||||
{voucher.reconciliation_type === 'Voucher Created' ?
|
||||
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
|
||||
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="py-4">
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||
<span>The following documents will be <strong>cancelled</strong>:</span>
|
||||
)}
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||
<ol className="ms-6 list-disc [&>li]:mt-2">
|
||||
{vouchersWhichWillBeCancelled?.map((voucher) => {
|
||||
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading || isLoading}>
|
||||
{_("Unreconcile")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModalBody
|
||||
@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
@@ -17,7 +18,6 @@ import { PartyPopper } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const IncorrectlyClearedEntries = () => {
|
||||
const companyID = useCurrentCompany()
|
||||
@@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
})
|
||||
})
|
||||
},
|
||||
[clearClearingDate, mutate],
|
||||
[clearClearingDate, mutate, _],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
@@ -174,25 +174,25 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[accountCurrency, onClearClick],
|
||||
[_, accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
|
||||
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
}} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<MarkdownRenderer content={entriesContent} />
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
||||
import { Button } from "@/components/ui/button"
|
||||
import CurrencyInput from 'react-currency-input-field'
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import _ from "@/lib/translate"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import TransferModal from "./TransferModal"
|
||||
import BankEntryModal from "./BankEntryModal"
|
||||
import RecordPaymentModal from "./RecordPaymentModal"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import MatchFilters from "./MatchFilters"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
@@ -69,59 +69,6 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
||||
</>
|
||||
}
|
||||
|
||||
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
|
||||
function VirtualizedListBody<T>({
|
||||
items,
|
||||
height,
|
||||
getItemKey,
|
||||
children,
|
||||
estimateSize = 74,
|
||||
}: {
|
||||
items: T[]
|
||||
height: number
|
||||
getItemKey: (item: T, index: number) => string | number
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
estimateSize?: number
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => estimateSize,
|
||||
overscan: 8,
|
||||
getItemKey: (index) => String(getItemKey(items[index], index)),
|
||||
})
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto contain-strict"
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: rowVirtualizer.getTotalSize() }}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
{children(items[virtualRow.index], virtualRow.index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -187,7 +134,6 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
const listHeight = contentHeight - 72
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
@@ -276,14 +222,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
|
||||
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
|
||||
|
||||
<VirtualizedListBody
|
||||
items={results}
|
||||
height={listHeight}
|
||||
estimateSize={74}
|
||||
getItemKey={(transaction) => transaction.name}
|
||||
>
|
||||
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
|
||||
</VirtualizedListBody>
|
||||
<Virtuoso
|
||||
data={results}
|
||||
itemContent={(_index, transaction) => (
|
||||
<UnreconciledTransactionItem transaction={transaction} />
|
||||
)}
|
||||
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
|
||||
totalCount={results?.length}
|
||||
/>
|
||||
|
||||
</div>
|
||||
}
|
||||
@@ -613,8 +559,11 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getActionIcon = () => {
|
||||
if (!rule) return null
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return <Landmark />
|
||||
@@ -628,7 +577,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const getActionStyles = () => {
|
||||
if (!rule) return {}
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return {
|
||||
@@ -662,7 +610,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const handleActionClick = () => {
|
||||
if (!rule) return
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
setRecordJournalEntryModalOpen(true)
|
||||
@@ -677,7 +624,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const getActionDescription = () => {
|
||||
if (!rule) return ""
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return _("Create a journal entry for expenses, income or split transactions")
|
||||
@@ -690,7 +636,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys('alt+r', () => {
|
||||
useHotkeys('meta+r', () => {
|
||||
//
|
||||
handleActionClick()
|
||||
}, {
|
||||
enabled: true,
|
||||
@@ -700,10 +647,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
|
||||
const styles = getActionStyles()
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
||||
<CardHeader className="pb-0">
|
||||
@@ -778,9 +721,6 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
const voucherList = vouchers?.message ?? []
|
||||
const listHeight = contentHeight - 120
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
@@ -807,7 +747,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
{voucherList.length === 0 && <Empty className="my-4">
|
||||
{vouchers?.message.length === 0 && <Empty className="my-4">
|
||||
<EmptyMedia>
|
||||
<ReceiptIcon />
|
||||
</EmptyMedia>
|
||||
@@ -816,14 +756,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
<VirtualizedListBody
|
||||
items={voucherList}
|
||||
height={listHeight}
|
||||
estimateSize={121}
|
||||
getItemKey={(voucher) => voucher.name}
|
||||
>
|
||||
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
|
||||
</VirtualizedListBody>
|
||||
<Virtuoso
|
||||
data={vouchers?.message}
|
||||
itemContent={(index, voucher) => (
|
||||
<VoucherItem voucher={voucher} index={index} />
|
||||
)}
|
||||
style={{ height: contentHeight }}
|
||||
totalCount={vouchers?.message.length}
|
||||
/>
|
||||
</div >
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { today } from "@/lib/date"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
@@ -446,10 +445,11 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
|
||||
// If it's a string and cannot be a number, then show it as a formula
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
|
||||
let calculatedValue = "";
|
||||
|
||||
try {
|
||||
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
|
||||
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
|
||||
@@ -1,32 +1,555 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { bankRecTransferModalAtom } from './bankRecAtoms'
|
||||
|
||||
const TransferModalContent = lazy(() => import('./TransferModalContent'))
|
||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { H4 } from '@/components/ui/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Form } from '@/components/ui/form'
|
||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { useContext, useMemo, useState } from 'react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const TransferModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<TransferModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferModal
|
||||
const TransferModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <InternalTransferForm
|
||||
selectedBankAccount={selectedBankAccount}
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkInternalTransferForm transactions={selectedTransaction} />
|
||||
|
||||
}
|
||||
|
||||
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
bank_account: string
|
||||
}>()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const onSubmit = (data: { bank_account: string }) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_names: transactions.map((transaction) => transaction.name),
|
||||
bank_account: data.bank_account
|
||||
}).then(({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: item.payment_entry.name,
|
||||
posting_date: item.payment_entry.posting_date,
|
||||
doc: item.payment_entry,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
bank_account: data.bank_account,
|
||||
}
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
})
|
||||
onReconcile(transactions[transactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const onAccountChange = (account: string) => {
|
||||
form.setValue('bank_account', account)
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
|
||||
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
|
||||
|
||||
console.log("This is here", transactions)
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</Form>
|
||||
}
|
||||
|
||||
interface InternalTransferFormFields extends PaymentEntry {
|
||||
mirror_transaction_name?: string
|
||||
}
|
||||
|
||||
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const form = useForm<InternalTransferFormFields>({
|
||||
defaultValues: {
|
||||
payment_type: 'Internal Transfer',
|
||||
company: selectedTransaction?.company,
|
||||
// If the transaction is a withdrawal, set the paid from to the selected bank account
|
||||
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// If the transaction is a deposit, set the paid to to the selected bank account
|
||||
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// Set the amount to the amount of the selected transaction
|
||||
paid_amount: selectedTransaction.unallocated_amount,
|
||||
received_amount: selectedTransaction.unallocated_amount,
|
||||
reference_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: InternalTransferFormFields) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data,
|
||||
custom_remarks: data.remarks ? true : false,
|
||||
// Pass this to reconcile both at the same time
|
||||
mirror_transaction_name: data.mirror_transaction_name
|
||||
}).then(async ({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: false,
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: message.payment_entry.name,
|
||||
reference_no: message.payment_entry.reference_no,
|
||||
reference_date: message.payment_entry.reference_date,
|
||||
posting_date: message.payment_entry.posting_date,
|
||||
doc: message.payment_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
|
||||
const uploadPromises = files.map(f => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Payment Entry",
|
||||
docname: message.payment_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
|
||||
setUploadProgress((currentProgress) => {
|
||||
//If there are multiple files, we need to add the progress to the current progress
|
||||
return currentProgress + ((progress?.progress ?? 0) / files.length)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
setUploadProgress(0)
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}).then(() => {
|
||||
setUploadProgress(0)
|
||||
setIsUploading(false)
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
const onAccountChange = (account: string, is_mirror: boolean = false) => {
|
||||
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
|
||||
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
|
||||
form.setValue('paid_to', account)
|
||||
} else {
|
||||
form.setValue('paid_from', account)
|
||||
}
|
||||
|
||||
if (!is_mirror) {
|
||||
// Reset the mirror transaction name
|
||||
form.setValue('mirror_transaction_name', '')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='reference_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
|
||||
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
|
||||
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-2'>
|
||||
<div className='flex items-end justify-between gap-4'>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_from"
|
||||
label={_("Paid From")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
readOnly={isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pb-2'>
|
||||
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_to"
|
||||
label={_("Paid To")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
isRequired
|
||||
readOnly={!isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
|
||||
|
||||
<SmallTextField
|
||||
name='remarks'
|
||||
label={_("Custom Remarks")}
|
||||
formDescription={_("This will be auto-populated if not set.")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
|
||||
|
||||
return <div className='grid grid-cols-4 gap-4'>
|
||||
{banks.map((bank) => (
|
||||
<div
|
||||
className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
role='button'
|
||||
key={bank.account}
|
||||
onClick={() => onAccountChange(bank.account ?? '')}
|
||||
>
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
|
||||
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
|
||||
|
||||
const { data } = useFrappeGetCall('frappe.client.get_value', {
|
||||
doctype: 'Company',
|
||||
filters: company,
|
||||
fieldname: 'default_cash_account'
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
const account = data?.message?.default_cash_account
|
||||
|
||||
if (account) {
|
||||
return <div className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
role='button'
|
||||
onClick={() => setSelectedAccount(account ?? '')}
|
||||
>
|
||||
<div className='flex items-center justify-center h-10 w-10'>
|
||||
<Banknote size='24px' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>Cash</span>
|
||||
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
|
||||
|
||||
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
|
||||
|
||||
const mirrorTransactionName = watch('mirror_transaction_name')
|
||||
const paid_from = watch('paid_from')
|
||||
const paid_to = watch('paid_to')
|
||||
|
||||
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
|
||||
transaction_id: transaction.name
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
// Get bank accounts to find the logo
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (data?.message?.bank_account && banks) {
|
||||
return banks.find(bank => bank.name === data.message.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [data?.message?.bank_account, banks])
|
||||
|
||||
const selectTransaction = () => {
|
||||
if (data?.message) {
|
||||
setValue('mirror_transaction_name', data.message.name)
|
||||
onAccountChange(data.message.account, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.message) {
|
||||
|
||||
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
|
||||
|
||||
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
|
||||
const currency = data.message.currency
|
||||
|
||||
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
|
||||
|
||||
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
|
||||
|
||||
return (<div className='pb-2'>
|
||||
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
|
||||
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
|
||||
<div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className={cn("flex items-center gap-2 shrink-0",
|
||||
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
|
||||
)}>
|
||||
<BadgeCheck className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
|
||||
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
|
||||
<div className="flex items-center gap-2">
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
||||
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
||||
)}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||
<div className='pt-1'>
|
||||
<Button
|
||||
onClick={selectTransaction}
|
||||
theme={isSuggested ? "green" : "violet"}
|
||||
size="md"
|
||||
type='button'
|
||||
>
|
||||
{isSuggested ? <CheckCircle /> : <CheckIcon />}
|
||||
{isSuggested ? _("Accepted") : _("Use Suggestion")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default TransferModal
|
||||
@@ -1,530 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { DialogFooter, DialogClose } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { H4 } from '@/components/ui/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Form } from '@/components/ui/form'
|
||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { useContext, useMemo, useState } from 'react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
const TransferModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <InternalTransferForm
|
||||
selectedBankAccount={selectedBankAccount}
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkInternalTransferForm transactions={selectedTransaction} />
|
||||
|
||||
}
|
||||
|
||||
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
bank_account: string
|
||||
}>()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const onSubmit = (data: { bank_account: string }) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_names: transactions.map((transaction) => transaction.name),
|
||||
bank_account: data.bank_account
|
||||
}).then(({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: item.payment_entry.name,
|
||||
posting_date: item.payment_entry.posting_date,
|
||||
doc: item.payment_entry,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
bank_account: data.bank_account,
|
||||
}
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
})
|
||||
onReconcile(transactions[transactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const onAccountChange = (account: string) => {
|
||||
form.setValue('bank_account', account)
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
|
||||
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</Form>
|
||||
}
|
||||
|
||||
interface InternalTransferFormFields extends PaymentEntry {
|
||||
mirror_transaction_name?: string
|
||||
}
|
||||
|
||||
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const form = useForm<InternalTransferFormFields>({
|
||||
defaultValues: {
|
||||
payment_type: 'Internal Transfer',
|
||||
company: selectedTransaction?.company,
|
||||
// If the transaction is a withdrawal, set the paid from to the selected bank account
|
||||
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// If the transaction is a deposit, set the paid to to the selected bank account
|
||||
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// Set the amount to the amount of the selected transaction
|
||||
paid_amount: selectedTransaction.unallocated_amount,
|
||||
received_amount: selectedTransaction.unallocated_amount,
|
||||
reference_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: InternalTransferFormFields) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data,
|
||||
custom_remarks: data.remarks ? true : false,
|
||||
// Pass this to reconcile both at the same time
|
||||
mirror_transaction_name: data.mirror_transaction_name
|
||||
}).then(async ({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: false,
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: message.payment_entry.name,
|
||||
reference_no: message.payment_entry.reference_no,
|
||||
reference_date: message.payment_entry.reference_date,
|
||||
posting_date: message.payment_entry.posting_date,
|
||||
doc: message.payment_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
startTracking(files.length)
|
||||
|
||||
const uploadPromises = files.map((f, fileIndex) => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Payment Entry",
|
||||
docname: message.payment_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
updateFileProgress(fileIndex, progress?.progress ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
const onAccountChange = (account: string, is_mirror: boolean = false) => {
|
||||
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
|
||||
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
|
||||
form.setValue('paid_to', account)
|
||||
} else {
|
||||
form.setValue('paid_from', account)
|
||||
}
|
||||
|
||||
if (!is_mirror) {
|
||||
// Reset the mirror transaction name
|
||||
form.setValue('mirror_transaction_name', '')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='reference_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
|
||||
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
|
||||
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-2'>
|
||||
<div className='flex items-end justify-between gap-4'>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_from"
|
||||
label={_("Paid From")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
readOnly={isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pb-2'>
|
||||
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_to"
|
||||
label={_("Paid To")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
isRequired
|
||||
readOnly={!isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
|
||||
|
||||
<SmallTextField
|
||||
name='remarks'
|
||||
label={_("Custom Remarks")}
|
||||
formDescription={_("This will be auto-populated if not set.")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
|
||||
|
||||
return <div className='grid grid-cols-4 gap-4'>
|
||||
{banks.map((bank) => (
|
||||
<button
|
||||
className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
type='button'
|
||||
key={bank.account}
|
||||
onClick={() => onAccountChange(bank.account ?? '')}
|
||||
>
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
|
||||
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
|
||||
|
||||
const { data } = useFrappeGetCall('frappe.client.get_value', {
|
||||
doctype: 'Company',
|
||||
filters: company,
|
||||
fieldname: 'default_cash_account'
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
const account = data?.message?.default_cash_account
|
||||
|
||||
if (account) {
|
||||
return <button className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
type='button'
|
||||
onClick={() => setSelectedAccount(account ?? '')}
|
||||
>
|
||||
<div className='flex items-center justify-center h-10 w-10'>
|
||||
<Banknote size='24px' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>Cash</span>
|
||||
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
|
||||
|
||||
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
|
||||
|
||||
const mirrorTransactionName = watch('mirror_transaction_name')
|
||||
const paid_from = watch('paid_from')
|
||||
const paid_to = watch('paid_to')
|
||||
|
||||
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
|
||||
transaction_id: transaction.name
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
// Get bank accounts to find the logo
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (data?.message?.bank_account && banks) {
|
||||
return banks.find(bank => bank.name === data.message.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [data?.message?.bank_account, banks])
|
||||
|
||||
const selectTransaction = () => {
|
||||
if (data?.message) {
|
||||
setValue('mirror_transaction_name', data.message.name)
|
||||
onAccountChange(data.message.account, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.message) {
|
||||
|
||||
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
|
||||
|
||||
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
|
||||
const currency = data.message.currency
|
||||
|
||||
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
|
||||
|
||||
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
|
||||
|
||||
return (<div className='pb-2'>
|
||||
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
|
||||
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
|
||||
<div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className={cn("flex items-center gap-2 shrink-0",
|
||||
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
|
||||
)}>
|
||||
<BadgeCheck className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
|
||||
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
|
||||
<div className="flex items-center gap-2">
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
||||
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
||||
)}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||
<div className='pt-1'>
|
||||
<Button
|
||||
onClick={selectTransaction}
|
||||
theme={isSuggested ? "green" : "violet"}
|
||||
size="md"
|
||||
type='button'
|
||||
>
|
||||
{isSuggested ? <CheckCircle /> : <CheckIcon />}
|
||||
{isSuggested ? _("Accepted") : _("Use Suggestion")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default TransferModalContent
|
||||
@@ -1,8 +1,11 @@
|
||||
import CSVRawDataPreview from './CSVRawDataPreview'
|
||||
import StatementDetails from './StatementDetails'
|
||||
import _ from '@/lib/translate'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
|
||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
@@ -10,7 +13,7 @@ const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsRespo
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<CSVRawDataPreview data={data.message} mutate={mutate} />
|
||||
<CSVRawDataPreview data={data.message} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,104 +1,151 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import RawTableGrid from "../RawTableGrid"
|
||||
import {
|
||||
applyColumnMappingChange,
|
||||
ColumnMapsTo,
|
||||
GetStatementDetailsResponse,
|
||||
useSetHeaderIndex,
|
||||
useUpdateColumnMapping,
|
||||
} from "../import_utils"
|
||||
import { GetStatementDetailsResponse } from "../import_utils"
|
||||
import { useMemo } from "react"
|
||||
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
||||
|
||||
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
|
||||
|
||||
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
|
||||
(columns ?? []).map((c) => ({
|
||||
index: c.index,
|
||||
maps_to: c.maps_to,
|
||||
header_text: c.header_text,
|
||||
variable: c.variable,
|
||||
}))
|
||||
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
|
||||
|
||||
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
|
||||
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
|
||||
|
||||
const CSVRawDataPreview = ({
|
||||
data,
|
||||
mutate,
|
||||
}: {
|
||||
data: GetStatementDetailsResponse
|
||||
mutate: () => void
|
||||
}) => {
|
||||
const isCompleted = data.doc.status === "Completed"
|
||||
const col_map: Record<string, number> = {}
|
||||
|
||||
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
|
||||
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
|
||||
headerToState(data.doc.detected_header_index),
|
||||
)
|
||||
data.doc.column_mapping?.forEach(col => {
|
||||
if (col.maps_to && col.maps_to !== "Do not import") {
|
||||
col_map[col.maps_to] = col.index;
|
||||
}
|
||||
})
|
||||
|
||||
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
|
||||
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
|
||||
return col_map
|
||||
|
||||
const mappingRef = useRef(mapping)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||
const validColumns = Object.values(column_mapping)
|
||||
|
||||
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
|
||||
mapping.forEach((c) => {
|
||||
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
|
||||
})
|
||||
|
||||
const commitMapping = (next: Mapping[]) => {
|
||||
mappingRef.current = next
|
||||
setMapping(next)
|
||||
}
|
||||
|
||||
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
|
||||
const scheduleSaveMapping = () => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
|
||||
.then(() => mutate())
|
||||
.catch(() => toast.error(_("Could not save the column mapping.")))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||
if (isCompleted) return
|
||||
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
|
||||
scheduleSaveMapping()
|
||||
}
|
||||
|
||||
const onSetHeader = (rowIndex: number | null) => {
|
||||
if (isCompleted) return
|
||||
setHeaderIndex(rowIndex)
|
||||
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
|
||||
.then((res) => {
|
||||
// The backend re-derives the mapping for the new header; sync local state.
|
||||
const doc = res?.message?.doc
|
||||
if (doc) {
|
||||
commitMapping(toMapping(doc.column_mapping))
|
||||
setHeaderIndex(headerToState(doc.detected_header_index))
|
||||
}
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_("Could not update the header row.")))
|
||||
}
|
||||
// Reverse the column mapping to get a map of column index to variable name
|
||||
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
|
||||
|
||||
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
|
||||
return (
|
||||
<RawTableGrid
|
||||
rows={data.raw_data}
|
||||
columnMapping={columnMappingRecord}
|
||||
headerIndex={headerIndex}
|
||||
editable={!isCompleted}
|
||||
disabled={isCompleted || savingMapping || savingHeader}
|
||||
onChangeMapping={onChangeMapping}
|
||||
onSetHeader={onSetHeader}
|
||||
/>
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{data.raw_data.map((row, index) => {
|
||||
|
||||
const isHeaderRow = index === data.doc.detected_header_index;
|
||||
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
|
||||
|
||||
return <TableRow key={index}
|
||||
title={isHeaderRow ? "Header Row" : ""}
|
||||
className={cn({
|
||||
// "bg-yellow-100": isHeaderRow,
|
||||
// "hover:bg-yellow-100": isHeaderRow,
|
||||
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
|
||||
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
|
||||
})}>
|
||||
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
|
||||
{index + 1}
|
||||
</TableHead> :
|
||||
<TableCell className="text-center px-1 py-0.5">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
}
|
||||
{row.map((cell, cellIndex) => {
|
||||
|
||||
const isValidColumn = validColumns.includes(cellIndex);
|
||||
const columnType = columnIndexMap[cellIndex];
|
||||
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
|
||||
|
||||
if (isHeaderRow) {
|
||||
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
|
||||
)}>
|
||||
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
|
||||
"justify-end": isAmountColumn && isValidColumn
|
||||
})}>
|
||||
{columnType && <Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_(columnType)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
{cell}
|
||||
</div>
|
||||
</TableHead>
|
||||
} else {
|
||||
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
{
|
||||
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
|
||||
"text-ink-gray-5": !isValidColumn && isTransactionRow,
|
||||
}
|
||||
)} >
|
||||
<div className={cn("min-h-5 flex items-center text-xs px-1", {
|
||||
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
|
||||
})} title={cell}>
|
||||
{cell}
|
||||
</div>
|
||||
</TableCell>
|
||||
}
|
||||
}
|
||||
|
||||
)}
|
||||
</TableRow>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table >
|
||||
)
|
||||
}
|
||||
|
||||
export default CSVRawDataPreview
|
||||
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
|
||||
if (!columnType) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (columnType === 'Amount') {
|
||||
return <DollarSignIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Withdrawal') {
|
||||
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Deposit') {
|
||||
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Balance') {
|
||||
return <BanknoteIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Date') {
|
||||
return <CalendarIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Description') {
|
||||
return <FileTextIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Reference') {
|
||||
return <ReceiptIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Transaction Type') {
|
||||
return <ListIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Debit/Credit') {
|
||||
return <ArrowUpDownIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default CSVRawDataPreview
|
||||
@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
@@ -142,16 +142,11 @@ const StatementDetails = ({ data }: Props) => {
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BankLogo bank={bank} />
|
||||
<span className="text-sm">{bank?.account_name}</span>
|
||||
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Statement File")}</TableHead>
|
||||
<TableCell>
|
||||
@@ -163,11 +158,7 @@ const StatementDetails = ({ data }: Props) => {
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Transaction Dates")}</TableHead>
|
||||
{data.doc.start_date && data.doc.end_date ? (
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
) : (
|
||||
<TableCell>-</TableCell>
|
||||
)}
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Number of Transactions")}</TableHead>
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Bbox = [number, number, number, number]
|
||||
|
||||
const MIN_SIZE = 8 // PDF points
|
||||
|
||||
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
|
||||
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
|
||||
let [x0, top, x1, bottom] = bbox
|
||||
if (x1 < x0) [x0, x1] = [x1, x0]
|
||||
if (bottom < top) [top, bottom] = [bottom, top]
|
||||
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
|
||||
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
|
||||
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
|
||||
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
|
||||
return [x0, top, x1, bottom]
|
||||
}
|
||||
|
||||
const HANDLES = [
|
||||
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
|
||||
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
bbox: Bbox
|
||||
pageWidth: number
|
||||
pageHeight: number
|
||||
color: { border: string; bg: string; swatch: string }
|
||||
label: string
|
||||
included: boolean
|
||||
disabled?: boolean
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
onCommit: (bbox: Bbox) => void
|
||||
}
|
||||
|
||||
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
|
||||
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
|
||||
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
|
||||
const [draft, setDraft] = useState<Bbox>(bbox)
|
||||
const draftRef = useRef<Bbox>(bbox)
|
||||
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
|
||||
|
||||
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
|
||||
useEffect(() => {
|
||||
setDraft(bbox)
|
||||
draftRef.current = bbox
|
||||
}, [bbox])
|
||||
|
||||
const apply = (next: Bbox) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
|
||||
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
|
||||
let [x0, top, x1, bottom] = drag.current.start
|
||||
const m = drag.current.mode
|
||||
if (m === 'move') {
|
||||
x0 += dx
|
||||
x1 += dx
|
||||
top += dy
|
||||
bottom += dy
|
||||
} else {
|
||||
if (m.includes('w')) x0 += dx
|
||||
if (m.includes('e')) x1 += dx
|
||||
if (m.includes('n')) top += dy
|
||||
if (m.includes('s')) bottom += dy
|
||||
}
|
||||
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
|
||||
}
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
drag.current = null
|
||||
onCommit(draftRef.current)
|
||||
}
|
||||
|
||||
const [x0, top, x1, bottom] = draft
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute touch-none border-2',
|
||||
color.border,
|
||||
included ? color.bg : 'opacity-40',
|
||||
disabled ? 'pointer-events-none' : 'cursor-move',
|
||||
)}
|
||||
style={{
|
||||
left: `${(x0 / pageWidth) * 100}%`,
|
||||
top: `${(top / pageHeight) * 100}%`,
|
||||
width: `${((x1 - x0) / pageWidth) * 100}%`,
|
||||
height: `${((bottom - top) / pageHeight) * 100}%`,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
|
||||
{label}
|
||||
</span>
|
||||
{!disabled &&
|
||||
HANDLES.map((handle) => (
|
||||
<span
|
||||
key={handle.id}
|
||||
data-handle={handle.id}
|
||||
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BBoxOverlay
|
||||
@@ -1,23 +0,0 @@
|
||||
import StatementDetails from '../CSV/StatementDetails'
|
||||
import PDFTableEditor from './PDFTableEditor'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
type Props = {
|
||||
data: { message: GetStatementDetailsResponse }
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
const PDFImport = ({ data, mutate }: Props) => {
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<PDFTableEditor data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFImport
|
||||
@@ -1,362 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, FileTextIcon, Loader2Icon, TableIcon } from 'lucide-react'
|
||||
import _ from '@/lib/translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { H3, Paragraph } from '@/components/ui/typography'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import RawTableGrid from '../RawTableGrid'
|
||||
import BBoxOverlay from './BBoxOverlay'
|
||||
import {
|
||||
applyColumnMappingChange,
|
||||
ColumnMapsTo,
|
||||
GetStatementDetailsResponse,
|
||||
PDFTable,
|
||||
useReextractPDFTable,
|
||||
useSetPDFTableHeader,
|
||||
useUpdatePDFTables,
|
||||
} from '../import_utils'
|
||||
|
||||
type Props = {
|
||||
data: GetStatementDetailsResponse
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
// Distinct overlay colours per table on a page.
|
||||
const OVERLAY_COLORS = [
|
||||
{ border: 'border-blue-500', bg: 'bg-blue-500/10', swatch: 'bg-blue-500' },
|
||||
{ border: 'border-purple-500', bg: 'bg-purple-500/10', swatch: 'bg-purple-500' },
|
||||
{ border: 'border-amber-500', bg: 'bg-amber-500/10', swatch: 'bg-amber-500' },
|
||||
{ border: 'border-teal-500', bg: 'bg-teal-500/10', swatch: 'bg-teal-500' },
|
||||
]
|
||||
|
||||
const columnMappingRecord = (table: PDFTable): Record<number, ColumnMapsTo> => {
|
||||
const map: Record<number, ColumnMapsTo> = {}
|
||||
table.column_mapping?.forEach((col) => {
|
||||
map[col.index] = col.maps_to
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
const PDFTableEditor = ({ data, mutate }: Props) => {
|
||||
const isCompleted = data.doc.status === 'Completed'
|
||||
|
||||
const [tables, setTables] = useState<PDFTable[]>(() => data.pdf_tables ?? [])
|
||||
const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf')
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
|
||||
|
||||
const toggleCollapsed = (tableIndex: number) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(tableIndex)) {
|
||||
next.delete(tableIndex)
|
||||
} else {
|
||||
next.add(tableIndex)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const { call, loading, error } = useUpdatePDFTables()
|
||||
const { call: reextract, loading: reextracting } = useReextractPDFTable()
|
||||
const { call: setHeaderCall, loading: settingHeader } = useSetPDFTableHeader()
|
||||
const busy = loading || reextracting || settingHeader
|
||||
|
||||
// Persist edits automatically (debounced) so the transaction preview updates in realtime.
|
||||
const tablesRef = useRef(tables)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const reextractTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const scheduleSave = () => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
call({ statement_import_id: data.doc.name, tables: tablesRef.current })
|
||||
.then(() => mutate())
|
||||
.catch(() => toast.error(_('Could not save the table settings.')))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// After a bbox change, re-extract that table's rows from the new region (debounced).
|
||||
// The target is read inside the timeout so it always reflects the committed bbox.
|
||||
const scheduleReextract = (tableIndex: number) => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(reextractTimer.current)
|
||||
reextractTimer.current = setTimeout(() => {
|
||||
const target = tablesRef.current[tableIndex]
|
||||
reextract({
|
||||
statement_import_id: data.doc.name,
|
||||
page: target.page,
|
||||
table_index: target.table_index,
|
||||
bbox: target.bbox,
|
||||
})
|
||||
.then((res) => {
|
||||
commitTables(res?.message?.pdf_tables ?? [])
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_('Could not re-extract the table.')))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimeout(saveTimer.current)
|
||||
clearTimeout(reextractTimer.current)
|
||||
}, [])
|
||||
|
||||
const pages = useMemo(() => Array.from(new Set(tables.map((t) => t.page))).sort((a, b) => a - b), [tables])
|
||||
const currentPage = pages[pageIndex]
|
||||
// Keep the table's position in the flat array so edits target the right one.
|
||||
const pageTables = useMemo(
|
||||
() => tables.map((table, index) => ({ table, index })).filter((t) => t.table.page === currentPage),
|
||||
[tables, currentPage],
|
||||
)
|
||||
|
||||
// Keep tablesRef in sync synchronously so the debounced save/re-extract never read stale state.
|
||||
const commitTables = (next: PDFTable[]) => {
|
||||
tablesRef.current = next
|
||||
setTables(next)
|
||||
}
|
||||
|
||||
const updateTable = (tableIndex: number, updater: (table: PDFTable) => PDFTable) => {
|
||||
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? updater(t) : t)))
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const onChangeMapping = (tableIndex: number, columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||
updateTable(tableIndex, (table) => ({
|
||||
...table,
|
||||
column_mapping: applyColumnMappingChange(table.column_mapping, columnIndex, mapsTo),
|
||||
}))
|
||||
}
|
||||
|
||||
const onToggleIncluded = (tableIndex: number, included: boolean) =>
|
||||
updateTable(tableIndex, (table) => ({ ...table, included }))
|
||||
|
||||
const onBboxCommit = (tableIndex: number, bbox: [number, number, number, number]) => {
|
||||
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, bbox } : t)))
|
||||
scheduleReextract(tableIndex)
|
||||
}
|
||||
|
||||
// Set/clear the header row of a table; the backend re-derives the column mapping.
|
||||
const onSetHeader = (tableIndex: number, headerIndex: number | null) => {
|
||||
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, header_index: headerIndex } : t)))
|
||||
const target = tablesRef.current[tableIndex]
|
||||
setHeaderCall({
|
||||
statement_import_id: data.doc.name,
|
||||
page: target.page,
|
||||
table_index: target.table_index,
|
||||
header_index: headerIndex ?? -1,
|
||||
})
|
||||
.then((res) => {
|
||||
commitTables(res?.message?.pdf_tables ?? [])
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_('Could not update the header row.')))
|
||||
}
|
||||
|
||||
if (tables.length === 0) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||
{_('No tables were extracted from this PDF.')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<H3 className="text-base border-0 p-0">{_('Detected Tables')}</H3>
|
||||
<Paragraph className="text-p-sm">
|
||||
{_('Review each page. In the Table view, map each column, click a row number to set/clear the header row, and exclude anything that is not transactions (ads, summaries).')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'pdf' | 'table')}>
|
||||
<TabsList variant="subtle">
|
||||
<TabsTrigger value="pdf"><FileTextIcon />{_('PDF')}</TabsTrigger>
|
||||
<TabsTrigger value="table"><TableIcon />{_('Table')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{busy && (
|
||||
<span className="flex items-center gap-1 pe-1 text-xs text-ink-gray-5">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{reextracting ? _('Re-extracting') : _('Saving')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
disabled={pageIndex === 0}
|
||||
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="min-w-24 text-center text-sm text-ink-gray-7">
|
||||
{_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
disabled={pageIndex >= pages.length - 1}
|
||||
onClick={() => setPageIndex((i) => Math.min(pages.length - 1, i + 1))}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'pdf' ? (
|
||||
<PageView
|
||||
pageTables={pageTables}
|
||||
disabled={isCompleted}
|
||||
onToggleIncluded={onToggleIncluded}
|
||||
onBboxCommit={onBboxCommit}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const isCollapsed = collapsed.has(index)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn('flex flex-col rounded border border-outline-gray-2', !table.included && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<span className="ps-1 text-sm font-medium text-ink-gray-8">
|
||||
{_('Table {0}', [(position + 1).toString()])}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<IncludeToggle
|
||||
id={`tbl-${index}`}
|
||||
checked={table.included}
|
||||
disabled={isCompleted}
|
||||
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" isIconButton onClick={() => toggleCollapsed(index)}>
|
||||
<ChevronDownIcon className={cn('transition-transform', isCollapsed && '-rotate-90')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-auto border-t border-outline-gray-2">
|
||||
<RawTableGrid
|
||||
rows={table.rows}
|
||||
columnMapping={columnMappingRecord(table)}
|
||||
headerIndex={table.header_index}
|
||||
editable
|
||||
disabled={isCompleted}
|
||||
onChangeMapping={(columnIndex, mapsTo) => onChangeMapping(index, columnIndex, mapsTo)}
|
||||
onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PageViewProps = {
|
||||
pageTables: { table: PDFTable; index: number }[]
|
||||
disabled: boolean
|
||||
onToggleIncluded: (tableIndex: number, included: boolean) => void
|
||||
onBboxCommit: (tableIndex: number, bbox: [number, number, number, number]) => void
|
||||
}
|
||||
|
||||
const PageView = ({ pageTables, disabled, onToggleIncluded, onBboxCommit }: PageViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const pageImage = pageTables[0]?.table.page_image
|
||||
const pageWidth = pageTables[0]?.table.page_width ?? 1
|
||||
const pageHeight = pageTables[0]?.table.page_height ?? 1
|
||||
|
||||
if (!pageImage) {
|
||||
return (
|
||||
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||
{_('No page image is available for this page.')}
|
||||
</Paragraph>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!disabled && (
|
||||
<Paragraph className="text-xs text-ink-gray-5">
|
||||
{_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div ref={containerRef} className="relative w-full overflow-auto rounded border border-outline-gray-2 bg-surface-gray-1">
|
||||
<img src={pageImage} alt={_('Page preview')} className="w-full" />
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||
return (
|
||||
<BBoxOverlay
|
||||
key={index}
|
||||
bbox={table.bbox}
|
||||
pageWidth={pageWidth}
|
||||
pageHeight={pageHeight}
|
||||
color={color}
|
||||
label={_('Table {0}', [(position + 1).toString()])}
|
||||
included={table.included}
|
||||
disabled={disabled}
|
||||
containerRef={containerRef}
|
||||
onCommit={(bbox) => onBboxCommit(index, bbox)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between rounded border border-outline-gray-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-3 rounded-sm', color.swatch)} />
|
||||
<span className="text-xs">{_('Table {0}', [(position + 1).toString()])}</span>
|
||||
</div>
|
||||
<IncludeToggle
|
||||
id={`pdf-tbl-${index}`}
|
||||
checked={table.included}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IncludeToggle = ({
|
||||
id,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={id} className="text-xs text-ink-gray-6">{_('Include')}</Label>
|
||||
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default PDFTableEditor
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ArrowDownRightIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpRightIcon,
|
||||
BanknoteIcon,
|
||||
CalendarIcon,
|
||||
DollarSignIcon,
|
||||
FileTextIcon,
|
||||
ListIcon,
|
||||
ReceiptIcon,
|
||||
} from 'lucide-react'
|
||||
import _ from '@/lib/translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { COLUMN_MAPS_TO_OPTIONS, ColumnMapsTo } from './import_utils'
|
||||
|
||||
const AMOUNT_COLUMNS: ColumnMapsTo[] = ['Amount', 'Withdrawal', 'Deposit', 'Balance']
|
||||
const DATE_LIKE = /\d{1,4}[/\-.\s]\d{1,2}[/\-.\s]\d{1,4}|\d{1,2}[\s-][a-z]{3}/i
|
||||
|
||||
type Props = {
|
||||
rows: string[][]
|
||||
/** Column index -> mapped field */
|
||||
columnMapping: Record<number, ColumnMapsTo>
|
||||
headerIndex: number | null
|
||||
editable?: boolean
|
||||
disabled?: boolean
|
||||
onChangeMapping?: (columnIndex: number, mapsTo: ColumnMapsTo) => void
|
||||
/** Set the header row (or null to mark the table as having no header). */
|
||||
onSetHeader?: (rowIndex: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview of extracted rows with CSV-style colour coding: the header row is highlighted,
|
||||
* detected transaction rows are green, and mapped columns are emphasised. When `editable`, a
|
||||
* compact row of column -> field dropdowns sits at the top, and row numbers can be clicked to
|
||||
* set/clear the header row.
|
||||
*/
|
||||
const RawTableGrid = ({ rows, columnMapping, headerIndex, editable, disabled, onChangeMapping, onSetHeader }: Props) => {
|
||||
// Tabular (XLSX) cells can be numbers/dates, not strings - coerce so .trim()/render are safe.
|
||||
const stringRows = useMemo(
|
||||
() => rows.map((row) => row.map((cell) => (cell == null ? '' : String(cell)))),
|
||||
[rows],
|
||||
)
|
||||
const numColumns = useMemo(() => stringRows.reduce((max, row) => Math.max(max, row.length), 0), [stringRows])
|
||||
|
||||
const validColumns = useMemo(
|
||||
() => Object.entries(columnMapping).filter(([, m]) => m && m !== 'Do not import').map(([i]) => Number(i)),
|
||||
[columnMapping],
|
||||
)
|
||||
const dateColumn = useMemo(() => Object.entries(columnMapping).find(([, m]) => m === 'Date')?.[0], [columnMapping])
|
||||
const amountColumns = useMemo(
|
||||
() => Object.entries(columnMapping).filter(([, m]) => ['Amount', 'Withdrawal', 'Deposit'].includes(m)).map(([i]) => Number(i)),
|
||||
[columnMapping],
|
||||
)
|
||||
|
||||
// Approximate the backend's transaction-row detection so the highlighting tracks edits live.
|
||||
const transactionRows = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
if (dateColumn === undefined) return set
|
||||
const dateIdx = Number(dateColumn)
|
||||
stringRows.forEach((row, index) => {
|
||||
if (index === headerIndex) return
|
||||
const dateCell = (row[dateIdx] ?? '').trim()
|
||||
if (!dateCell || !DATE_LIKE.test(dateCell)) return
|
||||
if (amountColumns.some((c) => (row[c] ?? '').trim() !== '')) set.add(index)
|
||||
})
|
||||
return set
|
||||
}, [stringRows, headerIndex, dateColumn, amountColumns])
|
||||
|
||||
return (
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{editable && (
|
||||
<TableRow className="border-b border-outline-gray-2 bg-surface-white hover:bg-surface-white">
|
||||
<TableHead className="w-8 p-1" />
|
||||
{Array.from({ length: numColumns }).map((_unused, columnIndex) => (
|
||||
<TableHead key={columnIndex} className="p-1 align-top">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={columnMapping[columnIndex] ?? 'Do not import'}
|
||||
onValueChange={(value) => onChangeMapping?.(columnIndex, value as ColumnMapsTo)}
|
||||
>
|
||||
<SelectTrigger variant="outline" inputSize="sm" className="h-7 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_MAPS_TO_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ColumnHeaderIcon columnType={option} />
|
||||
{_(option)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{stringRows.map((row, index) => {
|
||||
const isHeaderRow = index === headerIndex
|
||||
const isTransactionRow = transactionRows.has(index)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={cn({
|
||||
'bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700': isTransactionRow,
|
||||
'bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400': isHeaderRow,
|
||||
'text-ink-gray-5/70': !isTransactionRow && !isHeaderRow,
|
||||
})}
|
||||
>
|
||||
{editable && onSetHeader ? (
|
||||
<TableCell className="h-px w-8 p-0 text-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSetHeader(isHeaderRow ? null : index)}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center px-1 text-ink-gray-6 hover:bg-surface-gray-3',
|
||||
isHeaderRow && 'font-semibold text-ink-gray-8',
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isHeaderRow
|
||||
? _('This is the header row. Click to mark the table as having no header.')
|
||||
: _('Click to set this as the header row.')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell className="w-8 px-1 py-0.5 text-center text-ink-gray-6">{index + 1}</TableCell>
|
||||
)}
|
||||
|
||||
{Array.from({ length: numColumns }).map((_unused, cellIndex) => {
|
||||
const columnType = columnMapping[cellIndex]
|
||||
const isValidColumn = validColumns.includes(cellIndex)
|
||||
const isAmountColumn = AMOUNT_COLUMNS.includes(columnType)
|
||||
const cellText = row[cellIndex] ?? ''
|
||||
|
||||
// Read-only header row: icon + label.
|
||||
if (isHeaderRow) {
|
||||
return (
|
||||
<TableCell key={cellIndex} className="max-w-[200px] overflow-hidden text-ellipsis py-1">
|
||||
<div className="flex items-center gap-1 px-1 text-xs font-medium text-ink-gray-8">
|
||||
{columnType && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{_(columnType)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{cellText}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cellIndex}
|
||||
className={cn('max-w-[200px] overflow-hidden text-ellipsis py-0.5', {
|
||||
'bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400': isValidColumn && isTransactionRow,
|
||||
'text-ink-gray-5': !isValidColumn && isTransactionRow,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('min-h-5 flex items-center px-1 text-xs', {
|
||||
'justify-end': isAmountColumn && isValidColumn && isTransactionRow,
|
||||
})}
|
||||
title={cellText}
|
||||
>
|
||||
{cellText}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: ColumnMapsTo }) => {
|
||||
switch (columnType) {
|
||||
case 'Amount':
|
||||
return <DollarSignIcon className="size-4" />
|
||||
case 'Withdrawal':
|
||||
return <ArrowUpRightIcon className="size-4 text-ink-red-3" />
|
||||
case 'Deposit':
|
||||
return <ArrowDownRightIcon className="size-4 text-ink-green-3" />
|
||||
case 'Balance':
|
||||
return <BanknoteIcon className="size-4" />
|
||||
case 'Date':
|
||||
return <CalendarIcon className="size-4" />
|
||||
case 'Description':
|
||||
return <FileTextIcon className="size-4" />
|
||||
case 'Reference':
|
||||
return <ReceiptIcon className="size-4" />
|
||||
case 'Transaction Type':
|
||||
return <ListIcon className="size-4" />
|
||||
case 'Debit/Credit':
|
||||
return <ArrowUpDownIcon className="size-4" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default RawTableGrid
|
||||
@@ -1,97 +1,6 @@
|
||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
|
||||
export type ColumnMapsTo =
|
||||
| "Do not import"
|
||||
| "Date"
|
||||
| "Withdrawal"
|
||||
| "Deposit"
|
||||
| "Amount"
|
||||
| "Description"
|
||||
| "Reference"
|
||||
| "Transaction Type"
|
||||
| "Debit/Credit"
|
||||
| "Balance"
|
||||
| "Included Fee"
|
||||
| "Excluded Fee"
|
||||
| "Party Name/Account Holder"
|
||||
| "Party Account No."
|
||||
| "Party IBAN"
|
||||
|
||||
export type ColumnMappingEntry = {
|
||||
index: number
|
||||
maps_to: ColumnMapsTo | string
|
||||
header_text?: string
|
||||
variable?: string
|
||||
}
|
||||
|
||||
/** Apply a column mapping change, clearing the same mapping from any other column. */
|
||||
export function applyColumnMappingChange<T extends ColumnMappingEntry>(
|
||||
columns: T[],
|
||||
columnIndex: number,
|
||||
mapsTo: ColumnMapsTo,
|
||||
): T[] {
|
||||
const previous = columns.find((c) => c.index === columnIndex)
|
||||
const cleared =
|
||||
mapsTo === "Do not import"
|
||||
? columns
|
||||
: columns.map((c) =>
|
||||
c.index !== columnIndex && c.maps_to === mapsTo
|
||||
? { ...c, maps_to: "Do not import" as ColumnMapsTo }
|
||||
: c,
|
||||
)
|
||||
|
||||
return [
|
||||
...cleared.filter((c) => c.index !== columnIndex),
|
||||
{
|
||||
index: columnIndex,
|
||||
maps_to: mapsTo,
|
||||
header_text: previous?.header_text ?? "",
|
||||
variable: previous?.variable ?? `column_${columnIndex}`,
|
||||
} as T,
|
||||
].sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export const COLUMN_MAPS_TO_OPTIONS: ColumnMapsTo[] = [
|
||||
"Do not import",
|
||||
"Date",
|
||||
"Description",
|
||||
"Reference",
|
||||
"Withdrawal",
|
||||
"Deposit",
|
||||
"Amount",
|
||||
"Balance",
|
||||
"Debit/Credit",
|
||||
"Transaction Type",
|
||||
"Included Fee",
|
||||
"Excluded Fee",
|
||||
"Party Name/Account Holder",
|
||||
"Party Account No.",
|
||||
"Party IBAN",
|
||||
]
|
||||
|
||||
export interface PDFTableColumn {
|
||||
index: number
|
||||
header_text: string
|
||||
variable?: string
|
||||
maps_to: ColumnMapsTo
|
||||
}
|
||||
|
||||
export interface PDFTable {
|
||||
page: number
|
||||
table_index: number
|
||||
bbox: [number, number, number, number]
|
||||
page_width: number
|
||||
page_height: number
|
||||
page_image: string | null
|
||||
render_scale: number | null
|
||||
rows: string[][]
|
||||
header_index: number | null
|
||||
column_mapping: PDFTableColumn[]
|
||||
date_format?: string
|
||||
amount_format?: string
|
||||
included: boolean
|
||||
}
|
||||
|
||||
export interface GetStatementDetailsResponse {
|
||||
doc: BankStatementImportLog,
|
||||
@@ -121,7 +30,6 @@ export interface GetStatementDetailsResponse {
|
||||
date_format: string,
|
||||
raw_data: Array<Array<string>>,
|
||||
currency: string,
|
||||
pdf_tables?: PDFTable[],
|
||||
}
|
||||
|
||||
export const useGetStatementDetails = (id: string) => {
|
||||
@@ -131,24 +39,4 @@ export const useGetStatementDetails = (id: string) => {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export const useUpdatePDFTables = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_pdf_tables")
|
||||
}
|
||||
|
||||
export const useReextractPDFTable = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.reextract_pdf_table")
|
||||
}
|
||||
|
||||
export const useSetPDFTableHeader = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_pdf_table_header")
|
||||
}
|
||||
|
||||
export const useUpdateColumnMapping = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_column_mapping")
|
||||
}
|
||||
|
||||
export const useSetHeaderIndex = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_header_index")
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
|
||||
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import _ from '@/lib/translate'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
|
||||
const Shortcuts = [
|
||||
{
|
||||
@@ -32,7 +32,7 @@ const Shortcuts = [
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <ZapIcon />,
|
||||
label: _("Accept Matching Rule"),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Preferences = () => {
|
||||
|
||||
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
||||
|
||||
const onUpdate = <K extends keyof AccountsSettings>(field: K, value: AccountsSettings[K]) => {
|
||||
const onUpdate = (field: keyof AccountsSettings, value: any) => {
|
||||
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
||||
[field]: value
|
||||
}), {
|
||||
|
||||
@@ -1,42 +1,95 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
SettingsDialog,
|
||||
SettingsPanel,
|
||||
SettingsPanels,
|
||||
SettingsTabGroup,
|
||||
SettingsTabItem,
|
||||
SettingsTabs,
|
||||
} from '@/components/ui/settings-dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Preferences } from './Preferences'
|
||||
import MatchingRules from './MatchingRules'
|
||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import SettingsDialogContent from './SettingsDialogContent'
|
||||
|
||||
const Settings = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Settings")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<SettingsDialogContent onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Settings")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
|
||||
<SettingsTabs>
|
||||
<SettingsTabGroup header={_("Settings")}>
|
||||
<SettingsTabItem
|
||||
icon={<SlidersVerticalIcon />}
|
||||
label={_("Preferences")}
|
||||
value="preferences"
|
||||
/>
|
||||
<SettingsTabItem
|
||||
icon={<ZapIcon />}
|
||||
label={_("Matching Rules")}
|
||||
value="rules"
|
||||
/>
|
||||
{/* <SettingsTabItem
|
||||
icon={<LandmarkIcon />}
|
||||
label={_("Bank Accounts")}
|
||||
value="bank-accounts"
|
||||
/>
|
||||
<SettingsTabItem
|
||||
icon={<ListIcon />}
|
||||
label={_("Masters")}
|
||||
value="masters"
|
||||
/> */}
|
||||
<SettingsTabItem
|
||||
icon={<KeyboardIcon />}
|
||||
label={_("Keyboard Shortcuts")}
|
||||
value="keyboard-shortcuts"
|
||||
/>
|
||||
</SettingsTabGroup>
|
||||
</SettingsTabs>
|
||||
|
||||
<SettingsPanels>
|
||||
<SettingsPanel value="preferences">
|
||||
<Preferences />
|
||||
</SettingsPanel>
|
||||
<SettingsPanel value="rules">
|
||||
<MatchingRules />
|
||||
</SettingsPanel>
|
||||
<SettingsPanel value="bank-accounts" />
|
||||
<SettingsPanel value="masters" />
|
||||
<SettingsPanel value="keyboard-shortcuts">
|
||||
<KeyboardShortcuts />
|
||||
</SettingsPanel>
|
||||
</SettingsPanels>
|
||||
</SettingsDialog>
|
||||
</Dialog >
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
SettingsDialog,
|
||||
SettingsPanels,
|
||||
SettingsTabGroup,
|
||||
SettingsTabItem,
|
||||
SettingsTabs,
|
||||
} from '@/components/ui/settings-dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { KeyboardIcon, Loader2Icon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const SettingsPanelsContent = lazy(() => import('./SettingsPanelsContent'))
|
||||
|
||||
const SettingsPanelsFallback = () => (
|
||||
<div className="flex flex-1 items-center justify-center min-h-full">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
|
||||
return (
|
||||
<SettingsDialog defaultValue="preferences" onClose={onClose}>
|
||||
<SettingsTabs>
|
||||
<SettingsTabGroup header={_("Settings")}>
|
||||
<SettingsTabItem
|
||||
icon={<SlidersVerticalIcon />}
|
||||
label={_("Preferences")}
|
||||
value="preferences"
|
||||
/>
|
||||
<SettingsTabItem
|
||||
icon={<ZapIcon />}
|
||||
label={_("Matching Rules")}
|
||||
value="rules"
|
||||
/>
|
||||
<SettingsTabItem
|
||||
icon={<KeyboardIcon />}
|
||||
label={_("Keyboard Shortcuts")}
|
||||
value="keyboard-shortcuts"
|
||||
/>
|
||||
</SettingsTabGroup>
|
||||
</SettingsTabs>
|
||||
|
||||
<SettingsPanels>
|
||||
<Suspense fallback={<SettingsPanelsFallback />}>
|
||||
<SettingsPanelsContent />
|
||||
</Suspense>
|
||||
</SettingsPanels>
|
||||
</SettingsDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsDialogContent
|
||||
@@ -1,24 +0,0 @@
|
||||
import { SettingsPanel } from '@/components/ui/settings-dialog'
|
||||
import { Preferences } from './Preferences'
|
||||
import MatchingRules from './MatchingRules'
|
||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
||||
|
||||
const SettingsPanelsContent = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel value="preferences">
|
||||
<Preferences />
|
||||
</SettingsPanel>
|
||||
<SettingsPanel value="rules">
|
||||
<MatchingRules />
|
||||
</SettingsPanel>
|
||||
<SettingsPanel value="bank-accounts" />
|
||||
<SettingsPanel value="masters" />
|
||||
<SettingsPanel value="keyboard-shortcuts">
|
||||
<KeyboardShortcuts />
|
||||
</SettingsPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPanelsContent
|
||||
@@ -170,7 +170,7 @@ function AlertDialogCancel({
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} theme={theme} asChild>
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ParsedErrorMessage {
|
||||
}
|
||||
|
||||
const parseHeading = (message?: ParsedErrorMessage) => {
|
||||
if (message?.title === 'Message' || message?.title === 'Error') return _("There was an error.")
|
||||
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
|
||||
return message?.title
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ export const FileTypeIcon = ({
|
||||
const getTextColor = () => {
|
||||
switch (fileType.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return 'text-ink-red-3'
|
||||
return 'text-red-700'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'text-[#1A5CBD]'
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
|
||||
export const ModalContentFallback = () => (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
@@ -151,7 +151,7 @@ function SettingsTabItem({
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 shrink-0 truncate text-sm leading-4 duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||
icon && "ms-2"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
|
||||
/** Tracks per-file upload progress (0–1) and exposes their average. */
|
||||
export function useMultiFileUploadProgress() {
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const fileProgressesRef = useRef<number[]>([])
|
||||
|
||||
const startTracking = useCallback((fileCount: number) => {
|
||||
if (fileCount <= 0) {
|
||||
return
|
||||
}
|
||||
fileProgressesRef.current = new Array(fileCount).fill(0)
|
||||
setUploadProgress(0)
|
||||
}, [])
|
||||
|
||||
const updateFileProgress = useCallback((fileIndex: number, progress: number) => {
|
||||
if (fileIndex < 0 || fileIndex >= fileProgressesRef.current.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fileProgressesRef.current.length === 0) {
|
||||
return
|
||||
}
|
||||
fileProgressesRef.current[fileIndex] = progress
|
||||
const total =
|
||||
fileProgressesRef.current.reduce((sum, p) => sum + p, 0) /
|
||||
fileProgressesRef.current.length
|
||||
setUploadProgress(total)
|
||||
}, [])
|
||||
|
||||
const resetProgress = useCallback(() => {
|
||||
fileProgressesRef.current = []
|
||||
setUploadProgress(0)
|
||||
}, [])
|
||||
|
||||
return { uploadProgress, startTracking, updateFileProgress, resetProgress }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Parser } from 'safe-expr-eval'
|
||||
|
||||
const parser = new Parser()
|
||||
|
||||
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
|
||||
|
||||
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
|
||||
const trimmed = expression.trim()
|
||||
if (!trimmed) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
|
||||
return Number(trimmed)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
|
||||
if (typeof result !== 'number' || !Number.isFinite(result)) {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,6 @@ export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage
|
||||
}
|
||||
})
|
||||
|
||||
// @ts-expect-error - some errors have _error_message
|
||||
if (error?._error_message) {
|
||||
eMessages.push({
|
||||
// @ts-expect-error - some errors have _error_message
|
||||
message: error?._error_message,
|
||||
title: "Error",
|
||||
indicator: "red"
|
||||
})
|
||||
}
|
||||
|
||||
if (eMessages.length === 0) {
|
||||
// Get the message from the exception by removing the exc_type
|
||||
const indexOfFirstColon = error?.exception?.indexOf(':')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { in_list } from "./checks";
|
||||
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
|
||||
import { getSystemDefault } from "./frappe";
|
||||
import _ from "@/lib/translate";
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
|
||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
||||
import BankReconciliationStatement from "@/components/features/BankReconciliation/BankReconciliationStatement"
|
||||
import BankTransactions from "@/components/features/BankReconciliation/BankTransactionList"
|
||||
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
||||
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
||||
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
|
||||
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
||||
import Settings from "@/components/features/Settings/Settings"
|
||||
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
@@ -18,10 +22,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
|
||||
|
||||
const BankReconciliationStatement = lazy(() => import('@/components/features/BankReconciliation/BankReconciliationStatement'))
|
||||
const BankTransactions = lazy(() => import('@/components/features/BankReconciliation/BankTransactionList'))
|
||||
const BankClearanceSummary = lazy(() => import('@/components/features/BankReconciliation/BankClearanceSummary'))
|
||||
const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankReconciliation/IncorrectlyClearedEntries'))
|
||||
|
||||
const BankReconciliation = () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -122,24 +122,18 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
|
||||
<TabsContent value="Match and Reconcile">
|
||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||
</TabsContent>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { H3, Paragraph } from "@/components/ui/typography"
|
||||
@@ -17,7 +16,7 @@ import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList, useFrappeUpdateDoc } from "frappe-react-sdk"
|
||||
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList } from "frappe-react-sdk"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { ListIcon, Loader2Icon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
@@ -31,15 +30,11 @@ const BankStatementImporter = () => {
|
||||
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const { upload, error, loading } = useFrappeFileUpload()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
|
||||
const { updateDoc, error: updateError } = useFrappeUpdateDoc()
|
||||
|
||||
const isPdf = files[0]?.name?.toLowerCase().endsWith(".pdf") ?? false
|
||||
|
||||
const onUpload = () => {
|
||||
|
||||
@@ -49,18 +44,12 @@ const BankStatementImporter = () => {
|
||||
|
||||
const id = `new-bank-statement-import-log-${Date.now()}`
|
||||
|
||||
// For protected PDFs, persist the password on the Bank Account so it is reused for
|
||||
// every statement of this account (and is available before the import doc is created).
|
||||
const ensurePassword = isPdf && password
|
||||
? updateDoc("Bank Account", selectedBankAccount.name, { statement_password: password })
|
||||
: Promise.resolve()
|
||||
|
||||
ensurePassword.then(() => upload(files[0], {
|
||||
upload(files[0], {
|
||||
isPrivate: true,
|
||||
doctype: "Bank Statement Import Log",
|
||||
docname: id,
|
||||
fieldname: 'file'
|
||||
})).then((file) => {
|
||||
}).then((file) => {
|
||||
return createDoc("Bank Statement Import Log",
|
||||
// @ts-expect-error - not filling everything else
|
||||
{
|
||||
@@ -78,7 +67,6 @@ const BankStatementImporter = () => {
|
||||
<div className="w-[52%]">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
{createError && <ErrorBanner error={createError} />}
|
||||
{updateError && <ErrorBanner error={updateError} />}
|
||||
<div className="py-2 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
|
||||
@@ -101,7 +89,7 @@ const BankStatementImporter = () => {
|
||||
data-slot="form-description"
|
||||
className={cn("text-ink-gray-5 text-xs")}
|
||||
>
|
||||
{_("Upload your bank statement file to start the import process. We support CSV, XLSX and PDF files.")}
|
||||
{_("Upload your bank statement file to start the import process. We support CSV, and XLSX files.")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -117,27 +105,10 @@ const BankStatementImporter = () => {
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/pdf': ['.pdf'],
|
||||
// 'application/xml': ['.xml'],
|
||||
}}
|
||||
multiple={false}
|
||||
/>
|
||||
|
||||
{isPdf && <div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pdf-password">{_("PDF Password")}</Label>
|
||||
<Input
|
||||
id="pdf-password"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={_("Only if the PDF is password protected")}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<p data-slot="form-description" className={cn("text-ink-gray-5 text-p-sm")}>
|
||||
{_("Leave blank to use the password already saved for this bank account (if any). It is stored encrypted and reused for future statements.")}
|
||||
</p>
|
||||
</div>}
|
||||
</div>}
|
||||
<div className="flex justify-end px-4">
|
||||
<Button
|
||||
@@ -166,10 +137,9 @@ const StatementInstructions = () => {
|
||||
<DialogContent className="min-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
|
||||
<DialogDescription>{_("We support uploading CSV, XLSX, XLS and PDF files. Please make sure the file contains the correct columns.")}</DialogDescription>
|
||||
<DialogDescription>{_("We support uploading CSV, XLSX and XLS files. Please make sure the file contains the correct columns.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
|
||||
<Paragraph className="text-sm text-ink-gray-6">{_("For PDF statements, we auto-detect the tables on each page. You can then confirm each detected table, map its columns, and exclude anything that is not transactions (e.g. ads or summaries). Password-protected PDFs are supported - the password is saved on the bank account and reused.")}</Paragraph>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -261,13 +231,7 @@ const StatementImportLog = () => {
|
||||
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
|
||||
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
|
||||
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
|
||||
<TableCell>
|
||||
{item.start_date && item.end_date ? (
|
||||
<span>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</TableCell>
|
||||
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
|
||||
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
|
||||
<TableCell><a
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
||||
import _ from '@/lib/translate'
|
||||
import { HomeIcon, Loader2Icon } from 'lucide-react'
|
||||
import { HomeIcon } from 'lucide-react'
|
||||
import { Link, Outlet } from 'react-router'
|
||||
|
||||
const BankStatementImporterContainer = () => {
|
||||
@@ -30,13 +29,7 @@ const BankStatementImporterContainer = () => {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="flex flex-1 items-center justify-center p-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy } from 'react'
|
||||
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
|
||||
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
@@ -8,14 +8,11 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { Link, useParams } from 'react-router'
|
||||
|
||||
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||
const PDFImport = lazy(() => import('@/components/features/BankStatementImporter/PDF/PDFImport'))
|
||||
|
||||
const ViewBankStatementImportLog = () => {
|
||||
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
const { data, isLoading, error, mutate } = useGetStatementDetails(id ?? "")
|
||||
const { data, isLoading, error } = useGetStatementDetails(id ?? "")
|
||||
|
||||
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
|
||||
})
|
||||
@@ -43,13 +40,7 @@ const ViewBankStatementImportLog = () => {
|
||||
<ErrorBanner error={error} />
|
||||
</div>
|
||||
}
|
||||
const isPdf = data.message.doc.file?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
if (isPdf) {
|
||||
return <PDFImport data={data} mutate={mutate} />
|
||||
}
|
||||
|
||||
return <CSVImport data={data} mutate={mutate} />
|
||||
return <CSVImport data={data} />
|
||||
}
|
||||
|
||||
export default ViewBankStatementImportLog
|
||||
@@ -38,8 +38,6 @@ export interface BankAccount{
|
||||
branch_code?: string
|
||||
/** Bank Account No : Data */
|
||||
bank_account_no?: string
|
||||
/** Statement PDF Password : Password - Password used to open password-protected PDF statements for this account. Stored encrypted. */
|
||||
statement_password?: string
|
||||
/** Is Credit Card : Check */
|
||||
is_credit_card?: 0 | 1
|
||||
/** Integration ID : Data */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
|
||||
|
||||
export interface BankStatementImportLog {
|
||||
export interface BankStatementImportLog{
|
||||
name: string
|
||||
creation: string
|
||||
modified: string
|
||||
@@ -38,7 +38,7 @@ export interface BankStatementImportLog {
|
||||
/** Detected Date Format : Data */
|
||||
detected_date_format?: string
|
||||
/** Detected Amount Format : Select */
|
||||
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has \"CR\"/\"DR\" values" | "Amount column has positive/negative values" | "Transaction type column has \"CR\"/\"DR\" values" | "Transaction type column has \"Deposit\"/\"Withdrawal\" values" | "Transaction type column has \"C\"/\"D\" values"
|
||||
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has "CR"/"DR" values" | "Amount column has positive/negative values" | "Transaction type column has "CR"/"DR" values" | "Transaction type column has "Deposit"/"Withdrawal" values" | "Transaction type column has "C"/"D" values"
|
||||
/** Detected Header Index : Int */
|
||||
detected_header_index?: number
|
||||
/** Detected Transaction Starting Index : Int */
|
||||
@@ -47,6 +47,4 @@ export interface BankStatementImportLog {
|
||||
detected_transaction_ending_index?: number
|
||||
/** Column Mapping : Table - Bank Statement Import Log Column Map */
|
||||
column_mapping?: BankStatementImportLogColumnMap[]
|
||||
/** PDF Tables : JSON - Per-table extraction data for PDF statements */
|
||||
pdf_tables?: string
|
||||
}
|
||||
@@ -21,35 +21,5 @@ export default defineConfig({
|
||||
outDir: '../erpnext/public/banking',
|
||||
emptyOutDir: true,
|
||||
target: 'es2015',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) {
|
||||
return
|
||||
}
|
||||
if (id.includes('react-dom') || id.includes('/react/')) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
if (id.includes('frappe-react-sdk')) {
|
||||
return 'vendor-frappe'
|
||||
}
|
||||
if (id.includes('@tanstack')) {
|
||||
return 'vendor-tanstack'
|
||||
}
|
||||
if (id.includes('fuse.js')) {
|
||||
return 'vendor-fuse'
|
||||
}
|
||||
if (id.includes('radix-ui') || id.includes('@radix-ui')) {
|
||||
return 'vendor-radix'
|
||||
}
|
||||
if (id.includes('jotai')) {
|
||||
return 'vendor-jotai'
|
||||
}
|
||||
if (id.includes('lucide-react')) {
|
||||
return 'vendor-lucide'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
2659
banking/yarn.lock
2659
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ import inspect
|
||||
from typing import TypeVar
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "17.0.0-dev"
|
||||
|
||||
@@ -153,8 +155,6 @@ def allow_regional(fn):
|
||||
|
||||
|
||||
def check_app_permission():
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
if frappe.session.user == "Administrator":
|
||||
return True
|
||||
|
||||
@@ -175,16 +175,9 @@ def normalize_ctx_input(T: type) -> callable:
|
||||
- Casting the result to the specified type T
|
||||
"""
|
||||
|
||||
from frappe.model.document import Document
|
||||
|
||||
def decorator(func: callable):
|
||||
# conserve annotations for frappe.utils.typing_validations
|
||||
@functools.wraps(
|
||||
func,
|
||||
assigned=(
|
||||
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
|
||||
),
|
||||
)
|
||||
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
|
||||
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
|
||||
if isinstance(ctx, Document):
|
||||
ctx = T(**ctx.as_dict())
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||
get_outstanding_reference_documents,
|
||||
get_payment_entry,
|
||||
)
|
||||
from erpnext.utilities.bulk_transaction import transaction_processing
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_payment_entries(
|
||||
grouped_invoices: str | list | None = None,
|
||||
ungrouped_invoices: str | list | None = None,
|
||||
):
|
||||
"""Create draft Payment Entries from AP report invoice selection."""
|
||||
frappe.has_permission("Payment Entry", "create", throw=True)
|
||||
|
||||
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
|
||||
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
|
||||
|
||||
if not grouped_invoices and not ungrouped_invoices:
|
||||
frappe.throw(_("No Purchase Invoices selected"))
|
||||
|
||||
if ungrouped_invoices:
|
||||
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
|
||||
transaction_processing(data, "Purchase Invoice", "Payment Entry")
|
||||
|
||||
if grouped_invoices:
|
||||
groups = {}
|
||||
for d in grouped_invoices:
|
||||
key = (d["supplier"], d["party_account"])
|
||||
groups.setdefault(
|
||||
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
|
||||
)["vouchers"].append(d["voucher_no"])
|
||||
|
||||
frappe.msgprint(
|
||||
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
|
||||
)
|
||||
frappe.enqueue(
|
||||
make_grouped_payment_entries,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
groups=list(groups.values()),
|
||||
)
|
||||
|
||||
|
||||
def make_grouped_payment_entries(groups):
|
||||
created, failed = 0, 0
|
||||
|
||||
for group in groups:
|
||||
supplier = group["supplier"]
|
||||
try:
|
||||
frappe.db.savepoint("bulk_pe")
|
||||
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
|
||||
if not pe:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
failed += 1
|
||||
frappe.log_error(
|
||||
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
|
||||
message=_(
|
||||
"No outstanding invoices found for the selected vouchers in account {0}"
|
||||
).format(group["party_account"]),
|
||||
)
|
||||
continue
|
||||
|
||||
pe.flags.ignore_validate = True
|
||||
pe.set_title_field()
|
||||
pe.insert(ignore_mandatory=True)
|
||||
created += 1
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
failed += 1
|
||||
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
|
||||
|
||||
message = _("Created {0} draft Grouped Payment Entries").format(created)
|
||||
|
||||
if failed:
|
||||
message += " — " + _("{0} skipped (see Error Log)").format(failed)
|
||||
|
||||
frappe.publish_realtime(
|
||||
"msgprint",
|
||||
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
|
||||
user=frappe.session.user,
|
||||
after_commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_grouped_payment_entry(supplier, party_account, names):
|
||||
pe = get_payment_entry("Purchase Invoice", names[0])
|
||||
pe.set("references", [])
|
||||
|
||||
refs = get_outstanding_reference_documents(
|
||||
{
|
||||
"party_type": "Supplier",
|
||||
"party": supplier,
|
||||
"party_account": party_account,
|
||||
"company": pe.company,
|
||||
"vouchers": [frappe._dict(voucher_type="Purchase Invoice", voucher_no=n) for n in names],
|
||||
}
|
||||
)
|
||||
|
||||
for r in refs:
|
||||
if r.voucher_type != "Purchase Invoice":
|
||||
continue
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
"reference_doctype": r.voucher_type,
|
||||
"reference_name": r.voucher_no,
|
||||
"bill_no": r.get("bill_no"),
|
||||
"due_date": r.get("due_date"),
|
||||
"payment_term": r.get("payment_term"),
|
||||
"total_amount": r.invoice_amount,
|
||||
"outstanding_amount": r.outstanding_amount,
|
||||
"allocated_amount": r.outstanding_amount,
|
||||
"exchange_rate": r.get("exchange_rate") or 1,
|
||||
},
|
||||
)
|
||||
|
||||
if not pe.references:
|
||||
return None
|
||||
|
||||
# received_amount is in paid_to account currency; convert to paid_from account currency for paid_amount
|
||||
pe.received_amount = sum(r.allocated_amount for r in pe.references)
|
||||
pe.paid_amount = flt(pe.received_amount * pe.target_exchange_rate, pe.precision("paid_amount"))
|
||||
pe.set_amounts()
|
||||
return pe
|
||||
@@ -1,7 +1,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.email import sendmail_to_system_managers
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
add_months,
|
||||
@@ -54,24 +53,20 @@ def validate_service_stop_date(doc):
|
||||
|
||||
|
||||
def build_conditions(process_type, account, company):
|
||||
if process_type == "Income":
|
||||
item = frappe.qb.DocType("Sales Invoice Item")
|
||||
parent = frappe.qb.DocType("Sales Invoice")
|
||||
deferred_account = item.deferred_revenue_account
|
||||
else:
|
||||
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
parent = frappe.qb.DocType("Purchase Invoice")
|
||||
deferred_account = item.deferred_expense_account
|
||||
conditions = ""
|
||||
deferred_account = (
|
||||
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
|
||||
)
|
||||
|
||||
if account:
|
||||
return deferred_account == account
|
||||
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
|
||||
elif company:
|
||||
return parent.company == company
|
||||
conditions += f"AND p.company = {frappe.db.escape(company)}"
|
||||
|
||||
return None
|
||||
return conditions
|
||||
|
||||
|
||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
|
||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||
|
||||
if not start_date:
|
||||
@@ -80,25 +75,17 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
||||
end_date = add_days(today(), -1)
|
||||
|
||||
# check for the purchase invoice for which GL entries has to be done
|
||||
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
parent = frappe.qb.DocType("Purchase Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.inner_join(parent)
|
||||
.on(item.parent == parent.name)
|
||||
.select(item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(item.service_start_date <= end_date)
|
||||
& (item.service_end_date >= start_date)
|
||||
& (item.enable_deferred_expense == 1)
|
||||
& (item.docstatus == 1)
|
||||
& (IfNull(item.amount, 0) > 0)
|
||||
)
|
||||
)
|
||||
if conditions is not None:
|
||||
query = query.where(conditions)
|
||||
invoices = query.run(pluck=True)
|
||||
invoices = frappe.db.sql_list(
|
||||
f"""
|
||||
select distinct item.parent
|
||||
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
|
||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
||||
and item.enable_deferred_expense = 1 and item.parent=p.name
|
||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
||||
{conditions}
|
||||
""",
|
||||
(end_date, start_date),
|
||||
) # nosec
|
||||
|
||||
# For each invoice, book deferred expense
|
||||
for invoice in invoices:
|
||||
@@ -109,7 +96,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
||||
send_mail(deferred_process)
|
||||
|
||||
|
||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
|
||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||
|
||||
if not start_date:
|
||||
@@ -118,25 +105,17 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
|
||||
end_date = add_days(today(), -1)
|
||||
|
||||
# check for the sales invoice for which GL entries has to be done
|
||||
item = frappe.qb.DocType("Sales Invoice Item")
|
||||
parent = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.inner_join(parent)
|
||||
.on(item.parent == parent.name)
|
||||
.select(item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(item.service_start_date <= end_date)
|
||||
& (item.service_end_date >= start_date)
|
||||
& (item.enable_deferred_revenue == 1)
|
||||
& (item.docstatus == 1)
|
||||
& (IfNull(item.amount, 0) > 0)
|
||||
)
|
||||
)
|
||||
if conditions is not None:
|
||||
query = query.where(conditions)
|
||||
invoices = query.run(pluck=True)
|
||||
invoices = frappe.db.sql_list(
|
||||
f"""
|
||||
select distinct item.parent
|
||||
from `tabSales Invoice Item` item, `tabSales Invoice` p
|
||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
||||
and item.enable_deferred_revenue = 1 and item.parent=p.name
|
||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
||||
{conditions}
|
||||
""",
|
||||
(end_date, start_date),
|
||||
) # nosec
|
||||
|
||||
for invoice in invoices:
|
||||
doc = frappe.get_doc("Sales Invoice", invoice)
|
||||
@@ -157,39 +136,26 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
|
||||
)
|
||||
|
||||
if not prev_posting_date:
|
||||
prev_gl_entry = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"company": doc.company,
|
||||
"account": item.get(deferred_account),
|
||||
"voucher_type": doc.doctype,
|
||||
"voucher_no": doc.name,
|
||||
"voucher_detail_no": item.name,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["name", "posting_date"],
|
||||
order_by="posting_date desc",
|
||||
limit=1,
|
||||
prev_gl_entry = frappe.db.sql(
|
||||
"""
|
||||
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
|
||||
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
||||
and is_cancelled = 0
|
||||
order by posting_date desc limit 1
|
||||
""",
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
prev_gl_via_je = (
|
||||
frappe.qb.from_(je)
|
||||
.inner_join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name, je.posting_date)
|
||||
.where(
|
||||
(je.company == doc.company)
|
||||
& (jea.account == item.get(deferred_account))
|
||||
& (jea.reference_type == doc.doctype)
|
||||
& (jea.reference_name == doc.name)
|
||||
& (jea.reference_detail_no == item.name)
|
||||
& (jea.docstatus < 2)
|
||||
)
|
||||
.orderby(je.posting_date, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run(as_dict=True)
|
||||
prev_gl_via_je = frappe.db.sql(
|
||||
"""
|
||||
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
|
||||
WHERE p.name = c.parent and p.company=%s and c.account=%s
|
||||
and c.reference_type=%s and c.reference_name=%s
|
||||
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
|
||||
""",
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if prev_gl_via_je:
|
||||
@@ -311,47 +277,26 @@ def get_already_booked_amount(doc, item):
|
||||
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
|
||||
deferred_account = "deferred_expense_account"
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries_details = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(
|
||||
Sum(gle[total_credit_debit]).as_("total_credit"),
|
||||
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||
gle.voucher_detail_no,
|
||||
)
|
||||
.where(
|
||||
(gle.company == doc.company)
|
||||
& (gle.account == item.get(deferred_account))
|
||||
& (gle.voucher_type == doc.doctype)
|
||||
& (gle.voucher_no == doc.name)
|
||||
& (gle.voucher_detail_no == item.name)
|
||||
& (gle.is_cancelled == 0)
|
||||
)
|
||||
.groupby(gle.voucher_detail_no)
|
||||
.run(as_dict=True)
|
||||
gl_entries_details = frappe.db.sql(
|
||||
"""
|
||||
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
|
||||
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
||||
and is_cancelled = 0
|
||||
group by voucher_detail_no
|
||||
""".format(total_credit_debit, total_credit_debit_currency),
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
journal_entry_details = (
|
||||
frappe.qb.from_(je)
|
||||
.inner_join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(
|
||||
Sum(jea[total_credit_debit]).as_("total_credit"),
|
||||
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||
jea.reference_detail_no,
|
||||
)
|
||||
.where(
|
||||
(je.company == doc.company)
|
||||
& (jea.account == item.get(deferred_account))
|
||||
& (jea.reference_type == doc.doctype)
|
||||
& (jea.reference_name == doc.name)
|
||||
& (jea.reference_detail_no == item.name)
|
||||
& (je.docstatus < 2)
|
||||
)
|
||||
.groupby(jea.reference_detail_no)
|
||||
.run(as_dict=True)
|
||||
journal_entry_details = frappe.db.sql(
|
||||
"""
|
||||
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
|
||||
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
|
||||
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
|
||||
and p.docstatus < 2 group by reference_detail_no
|
||||
""".format(total_credit_debit, total_credit_debit_currency),
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0
|
||||
@@ -582,7 +527,6 @@ def make_gl_entries(
|
||||
frappe.db.commit()
|
||||
except Exception as e:
|
||||
if frappe.in_test:
|
||||
frappe.db.rollback()
|
||||
doc.log_error(f"Error while processing deferred accounting for Invoice {doc.name}")
|
||||
raise e
|
||||
else:
|
||||
|
||||
@@ -175,19 +175,16 @@ class Account(NestedSet):
|
||||
if cint(self.is_group):
|
||||
db_value = self.get_doc_before_save()
|
||||
if db_value:
|
||||
Account = frappe.qb.DocType("Account")
|
||||
query = frappe.qb.update(Account).where((Account.lft > self.lft) & (Account.rgt < self.rgt))
|
||||
|
||||
updated = False
|
||||
if self.report_type != db_value.report_type:
|
||||
query = query.set(Account.report_type, self.report_type)
|
||||
updated = True
|
||||
frappe.db.sql(
|
||||
"update `tabAccount` set report_type=%s where lft > %s and rgt < %s",
|
||||
(self.report_type, self.lft, self.rgt),
|
||||
)
|
||||
if self.root_type != db_value.root_type:
|
||||
query = query.set(Account.root_type, self.root_type)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
query.run()
|
||||
frappe.db.sql(
|
||||
"update `tabAccount` set root_type=%s where lft > %s and rgt < %s",
|
||||
(self.root_type, self.lft, self.rgt),
|
||||
)
|
||||
|
||||
if self.root_type and not self.report_type:
|
||||
self.report_type = (
|
||||
@@ -234,7 +231,7 @@ class Account(NestedSet):
|
||||
if not frappe.db.get_value(
|
||||
"Account", {"account_name": self.account_name, "company": ancestors[0]}, "name"
|
||||
):
|
||||
frappe.throw(_("Please add the account to root level Company - {0}").format(ancestors[0]))
|
||||
frappe.throw(_("Please add the account to root level Company - {}").format(ancestors[0]))
|
||||
elif self.parent_account:
|
||||
descendants = get_descendants_of("Company", self.company)
|
||||
if not descendants:
|
||||
@@ -452,7 +449,11 @@ class Account(NestedSet):
|
||||
return frappe.db.get_value("GL Entry", {"account": self.name})
|
||||
|
||||
def check_if_child_exists(self):
|
||||
return frappe.db.exists("Account", {"parent_account": self.name, "docstatus": ["!=", 2]})
|
||||
return frappe.db.sql(
|
||||
"""select name from `tabAccount` where parent_account = %s
|
||||
and docstatus != 2""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def validate_mandatory(self):
|
||||
if not self.root_type:
|
||||
@@ -472,24 +473,14 @@ class Account(NestedSet):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
Account = frappe.qb.DocType("Account")
|
||||
|
||||
search_field_obj = getattr(Account, searchfield)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Account)
|
||||
.select(Account.name)
|
||||
.where(Account.is_group == 1)
|
||||
.where(Account.docstatus != 2)
|
||||
.where(Account.company == filters["company"])
|
||||
.where(search_field_obj.like(f"%{txt}%"))
|
||||
.order_by(Account.name)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
return frappe.db.sql(
|
||||
"""select name from tabAccount
|
||||
where is_group = 1 and docstatus != 2 and company = {}
|
||||
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
|
||||
(filters["company"], "%%%s%%" % txt, page_len, start),
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
return query.run(as_list=1)
|
||||
|
||||
|
||||
def get_account_currency(account):
|
||||
"""Helper function to get account currency"""
|
||||
@@ -530,7 +521,6 @@ def update_account_number(
|
||||
):
|
||||
_ensure_idle_system()
|
||||
account = frappe.get_cached_doc("Account", name)
|
||||
account.check_permission("write")
|
||||
if not account:
|
||||
return
|
||||
|
||||
@@ -592,12 +582,10 @@ def update_account_number(
|
||||
@frappe.whitelist()
|
||||
def merge_account(old: str, new: str):
|
||||
_ensure_idle_system()
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
|
||||
new_account.check_permission("write")
|
||||
old_account.check_permission("write")
|
||||
|
||||
if not new_account:
|
||||
throw(_("Account {0} does not exist").format(new))
|
||||
|
||||
@@ -671,7 +659,7 @@ def _ensure_idle_system():
|
||||
if last_gl_update > add_to_date(None, minutes=-5):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Last GL Entry update was done {0}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
|
||||
"Last GL Entry update was done {}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
|
||||
).format(pretty_date(last_gl_update)),
|
||||
title=_("System In Use"),
|
||||
)
|
||||
|
||||
@@ -236,9 +236,9 @@ frappe.treeview_settings["Account"] = {
|
||||
function () {
|
||||
let root_company = treeview.page.fields_dict.root_company.get_value();
|
||||
if (root_company) {
|
||||
frappe.throw(
|
||||
__("Please add the account to root level Company - {0}", [root_company])
|
||||
);
|
||||
frappe.throw(__("Please add the account to root level Company - {0}"), [
|
||||
root_company,
|
||||
]);
|
||||
} else {
|
||||
treeview.new_node();
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ def get_charts_for_country(country: str, with_standard: bool = False):
|
||||
|
||||
def _get_chart_name(content):
|
||||
if content:
|
||||
content = frappe.parse_json(content)
|
||||
content = json.loads(content)
|
||||
if (
|
||||
content and content.get("disabled", "No") == "No"
|
||||
) or frappe.local.flags.allow_unverified_charts:
|
||||
|
||||
@@ -37,10 +37,6 @@
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
@@ -227,6 +223,10 @@
|
||||
"Stock Received But Not Billed": {
|
||||
"account_type": "Stock Received But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
}
|
||||
},
|
||||
"Duties and Taxes": {
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
{
|
||||
"country_code": "nz",
|
||||
"name": "New Zealand - Chart of Accounts with Account Numbers",
|
||||
"disabled": "No",
|
||||
"tree": {
|
||||
"Application of Funds (Assets)": {
|
||||
"Current Assets": {
|
||||
"Bank Accounts": {
|
||||
"Business Transaction Account": {
|
||||
"account_number": "11011",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Business Savings Account": {
|
||||
"account_number": "11012",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"account_number": "11010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Cash on Hand": {
|
||||
"account_number": "11020",
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Accounts Receivable": {
|
||||
"Debtors": {
|
||||
"account_number": "11210",
|
||||
"account_type": "Receivable"
|
||||
},
|
||||
"Provision for Doubtful Debts": {
|
||||
"account_number": "11220"
|
||||
},
|
||||
"account_number": "11200",
|
||||
"is_group": 1
|
||||
},
|
||||
"Inventory": {
|
||||
"Stock on Hand": {
|
||||
"account_number": "11311",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Work In Progress": {
|
||||
"account_number": "11312",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"account_number": "11310",
|
||||
"account_type": "Stock",
|
||||
"is_group": 1
|
||||
},
|
||||
"Prepayments": {
|
||||
"Prepayments": {
|
||||
"account_number": "11411"
|
||||
},
|
||||
"Supplier Advances": {
|
||||
"account_number": "11412"
|
||||
},
|
||||
"Deferred Expense": {
|
||||
"account_number": "11413"
|
||||
},
|
||||
"account_number": "11410",
|
||||
"is_group": 1
|
||||
},
|
||||
"GST Receivable": {
|
||||
"account_number": "11510",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Receivable": {
|
||||
"account_number": "11520",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "11000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Fixed Assets": {
|
||||
"Plant & Equipment": {
|
||||
"Plant & Equipment": {
|
||||
"account_number": "16011",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Plant & Equipment": {
|
||||
"account_number": "16012",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Motor Vehicles": {
|
||||
"Motor Vehicles": {
|
||||
"account_number": "16021",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Motor Vehicles": {
|
||||
"account_number": "16022",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Office Equipment": {
|
||||
"Office Equipment": {
|
||||
"account_number": "16031",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Office Equipment": {
|
||||
"account_number": "16032",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16030",
|
||||
"is_group": 1
|
||||
},
|
||||
"Buildings": {
|
||||
"Buildings": {
|
||||
"account_number": "16041",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Buildings": {
|
||||
"account_number": "16042",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16040",
|
||||
"is_group": 1
|
||||
},
|
||||
"Computer Equipment": {
|
||||
"Computer Equipment": {
|
||||
"account_number": "16051",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Computer Equipment": {
|
||||
"account_number": "16052",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16050",
|
||||
"is_group": 1
|
||||
},
|
||||
"Capital Work in Progress": {
|
||||
"account_number": "16090",
|
||||
"account_type": "Capital Work in Progress"
|
||||
},
|
||||
"account_number": "16000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "10000",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Source of Funds (Liabilities)": {
|
||||
"Current Liabilities": {
|
||||
"Accounts Payable": {
|
||||
"Creditors": {
|
||||
"account_number": "21010",
|
||||
"account_type": "Payable"
|
||||
},
|
||||
"account_number": "21000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Goods Received Not Invoiced": {
|
||||
"account_number": "21100",
|
||||
"account_type": "Stock Received But Not Billed"
|
||||
},
|
||||
"Asset Received Not Invoiced": {
|
||||
"account_number": "21110",
|
||||
"account_type": "Asset Received But Not Billed"
|
||||
},
|
||||
"Service Received Not Invoiced": {
|
||||
"account_number": "21120",
|
||||
"account_type": "Service Received But Not Billed"
|
||||
},
|
||||
"Accrued Expenses": {
|
||||
"account_number": "21200"
|
||||
},
|
||||
"Wages Payable": {
|
||||
"account_number": "21300"
|
||||
},
|
||||
"PAYE Payable": {
|
||||
"account_number": "22010"
|
||||
},
|
||||
"KiwiSaver Payable": {
|
||||
"account_number": "22020"
|
||||
},
|
||||
"ACC Payable": {
|
||||
"account_number": "22030"
|
||||
},
|
||||
"Credit Cards": {
|
||||
"Business Credit Card": {
|
||||
"account_number": "22110"
|
||||
},
|
||||
"account_number": "22100",
|
||||
"is_group": 1
|
||||
},
|
||||
"Customer Advances": {
|
||||
"account_number": "22200"
|
||||
},
|
||||
"Deferred Revenue": {
|
||||
"account_number": "22210"
|
||||
},
|
||||
"Provisional Account": {
|
||||
"account_number": "22220"
|
||||
},
|
||||
"Tax Liabilities": {
|
||||
"GST Payable": {
|
||||
"account_number": "22310",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"GST Suspense": {
|
||||
"account_number": "22320",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"FBT Payable": {
|
||||
"account_number": "22330",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Payable": {
|
||||
"account_number": "22340",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "22300",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "21500",
|
||||
"is_group": 1
|
||||
},
|
||||
"Non-Current Liabilities": {
|
||||
"Bank Loans": {
|
||||
"Bank Loan": {
|
||||
"account_number": "25011"
|
||||
},
|
||||
"account_number": "25010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Lease Liabilities": {
|
||||
"Lease Liability": {
|
||||
"account_number": "25021"
|
||||
},
|
||||
"account_number": "25020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Shareholder Loans": {
|
||||
"Shareholder Loan": {
|
||||
"account_number": "25031"
|
||||
},
|
||||
"account_number": "25030",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "25000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "20000",
|
||||
"root_type": "Liability"
|
||||
},
|
||||
"Equity": {
|
||||
"Share Capital": {
|
||||
"account_number": "31010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Drawings": {
|
||||
"account_number": "31020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Current Year Earnings": {
|
||||
"account_number": "35010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Retained Earnings": {
|
||||
"account_number": "35020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"account_number": "30000",
|
||||
"root_type": "Equity"
|
||||
},
|
||||
"Income": {
|
||||
"Sales": {
|
||||
"account_number": "41010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Other Income": {
|
||||
"Interest Income": {
|
||||
"account_number": "47010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Rounding Gain/Loss": {
|
||||
"account_number": "47020",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Foreign Exchange Gain": {
|
||||
"account_number": "47030",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"account_number": "47000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "40000",
|
||||
"root_type": "Income"
|
||||
},
|
||||
"Expenses": {
|
||||
"Cost of Goods Sold": {
|
||||
"Purchases": {
|
||||
"account_number": "51010",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
},
|
||||
"Freight Inwards": {
|
||||
"account_number": "51020",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Duty and Landing Costs": {
|
||||
"account_number": "51030",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "51040",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Stock Write Off": {
|
||||
"account_number": "51050",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"account_number": "51000",
|
||||
"account_type": "Cost of Goods Sold",
|
||||
"is_group": 1
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"Wages & Salaries": {
|
||||
"account_number": "61010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"KiwiSaver Employer Contribution": {
|
||||
"account_number": "61020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"ACC Levies": {
|
||||
"account_number": "61030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rent": {
|
||||
"account_number": "65010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Power": {
|
||||
"account_number": "65020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Telephone": {
|
||||
"account_number": "66010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Insurance": {
|
||||
"account_number": "64010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Accounting Fees": {
|
||||
"account_number": "64020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Legal Fees": {
|
||||
"account_number": "64030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Advertising and Marketing": {
|
||||
"account_number": "65030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Repairs and Maintenance": {
|
||||
"account_number": "65040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Freight and Courier": {
|
||||
"account_number": "65050",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Operating Costs": {
|
||||
"account_number": "65060",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "60000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Depreciation and Amortisation": {
|
||||
"Depreciation - Plant & Equipment": {
|
||||
"account_number": "62010",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Motor Vehicles": {
|
||||
"account_number": "62020",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Office Equipment": {
|
||||
"account_number": "62030",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Computer Equipment": {
|
||||
"account_number": "62040",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"account_number": "62000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Finance Costs": {
|
||||
"Bank Charges": {
|
||||
"account_number": "67010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Interest Expense": {
|
||||
"account_number": "67020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rounding Off": {
|
||||
"account_number": "67030",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Payment Discounts": {
|
||||
"account_number": "67040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "67000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Income Tax Expense": {
|
||||
"account_number": "81010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Foreign Exchange": {
|
||||
"Exchange Gain/Loss": {
|
||||
"account_number": "82010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Unrealized Exchange Gain/Loss": {
|
||||
"account_number": "82020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "82000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Bad Debts": {
|
||||
"account_number": "83010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "83020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Gain/Loss on Asset Disposal": {
|
||||
"account_number": "83030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Expenses Included In Asset Valuation": {
|
||||
"account_number": "84010",
|
||||
"account_type": "Expenses Included In Asset Valuation"
|
||||
},
|
||||
"account_number": "50000",
|
||||
"root_type": "Expense"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,17 +570,6 @@
|
||||
"account_number": "5000",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5001",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5010",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
}
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"account_number": "5100",
|
||||
"is_group": 1,
|
||||
|
||||
@@ -198,9 +198,21 @@ def add_dimension_to_budget_doctype(df, doc):
|
||||
def delete_accounting_dimension(doc):
|
||||
doclist = get_doctypes_with_dimensions()
|
||||
|
||||
frappe.db.delete("Custom Field", filters={"fieldname": doc.fieldname, "dt": ["in", doclist]})
|
||||
frappe.db.sql(
|
||||
"""
|
||||
DELETE FROM `tabCustom Field`
|
||||
WHERE fieldname = {}
|
||||
AND dt IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
|
||||
tuple([doc.fieldname, *doclist]),
|
||||
)
|
||||
|
||||
frappe.db.delete("Property Setter", filters={"field_name": doc.fieldname, "doc_type": ["in", doclist]})
|
||||
frappe.db.sql(
|
||||
"""
|
||||
DELETE FROM `tabProperty Setter`
|
||||
WHERE field_name = {}
|
||||
AND doc_type IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
|
||||
tuple([doc.fieldname, *doclist]),
|
||||
)
|
||||
|
||||
budget_against_property = frappe.get_doc("Property Setter", "Budget-budget_against-options")
|
||||
value_list = budget_against_property.value.split("\n")[3:]
|
||||
@@ -224,7 +236,7 @@ def disable_dimension(doc: str):
|
||||
|
||||
|
||||
def toggle_disabling(doc):
|
||||
doc = frappe.parse_json(doc)
|
||||
doc = json.loads(doc)
|
||||
|
||||
if doc.get("disabled"):
|
||||
df = {"read_only": 1}
|
||||
@@ -261,27 +273,13 @@ def get_accounting_dimensions(as_list=True):
|
||||
|
||||
|
||||
def get_checks_for_pl_and_bs_accounts():
|
||||
AccountingDimension = frappe.qb.DocType("Accounting Dimension")
|
||||
AccountingDimensionDetail = frappe.qb.DocType("Accounting Dimension Detail")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(AccountingDimension)
|
||||
.join(AccountingDimensionDetail)
|
||||
.on(AccountingDimension.name == AccountingDimensionDetail.parent)
|
||||
.select(
|
||||
AccountingDimension.label,
|
||||
AccountingDimension.disabled,
|
||||
AccountingDimension.fieldname,
|
||||
AccountingDimensionDetail.default_dimension,
|
||||
AccountingDimensionDetail.company,
|
||||
AccountingDimensionDetail.mandatory_for_pl,
|
||||
AccountingDimensionDetail.mandatory_for_bs,
|
||||
)
|
||||
.where(AccountingDimension.disabled == 0)
|
||||
return frappe.db.sql(
|
||||
"""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs
|
||||
FROM `tabAccounting Dimension`p ,`tabAccounting Dimension Detail` c
|
||||
WHERE p.name = c.parent AND p.disabled = 0""",
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def get_dimension_with_children(doctype, dimensions):
|
||||
if isinstance(dimensions, str):
|
||||
|
||||
@@ -43,19 +43,18 @@ class AccountingDimensionFilter(Document):
|
||||
self.validate_applicable_accounts()
|
||||
|
||||
def validate_applicable_accounts(self):
|
||||
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
|
||||
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ApplicableOnAccount)
|
||||
.join(AccountingDimensionFilter)
|
||||
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
|
||||
.select(ApplicableOnAccount.applicable_on_account.as_("account"))
|
||||
.where(AccountingDimensionFilter.name != self.name)
|
||||
.where(AccountingDimensionFilter.accounting_dimension == self.accounting_dimension)
|
||||
accounts = frappe.db.sql(
|
||||
"""
|
||||
SELECT a.applicable_on_account as account
|
||||
FROM `tabApplicable On Account` a, `tabAccounting Dimension Filter` d
|
||||
WHERE d.name = a.parent
|
||||
and d.name != %s
|
||||
and d.accounting_dimension = %s
|
||||
""",
|
||||
(self.name, self.accounting_dimension),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
accounts = query.run(as_dict=1)
|
||||
account_list = [d.account for d in accounts]
|
||||
|
||||
for account in self.get("accounts"):
|
||||
@@ -70,28 +69,22 @@ class AccountingDimensionFilter(Document):
|
||||
|
||||
|
||||
def get_dimension_filter_map():
|
||||
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
|
||||
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
|
||||
AllowedDimension = frappe.qb.DocType("Allowed Dimension")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(AccountingDimensionFilter)
|
||||
.join(ApplicableOnAccount)
|
||||
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
|
||||
.left_join(AllowedDimension)
|
||||
.on(AllowedDimension.parent == AccountingDimensionFilter.name)
|
||||
.select(
|
||||
ApplicableOnAccount.applicable_on_account,
|
||||
AllowedDimension.dimension_value,
|
||||
AccountingDimensionFilter.accounting_dimension,
|
||||
AccountingDimensionFilter.allow_or_restrict,
|
||||
AccountingDimensionFilter.fieldname,
|
||||
ApplicableOnAccount.is_mandatory,
|
||||
)
|
||||
.where(AccountingDimensionFilter.disabled == 0)
|
||||
filters = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
|
||||
p.allow_or_restrict, p.fieldname, a.is_mandatory
|
||||
FROM
|
||||
`tabApplicable On Account` a,
|
||||
`tabAccounting Dimension Filter` p
|
||||
LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
|
||||
WHERE
|
||||
p.name = a.parent
|
||||
AND p.disabled = 0
|
||||
""",
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
filters = query.run(as_dict=1)
|
||||
dimension_filter_map = {}
|
||||
|
||||
for f in filters:
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -37,20 +36,8 @@ class AccountingPeriod(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
self.validate_overlap()
|
||||
|
||||
def validate_dates(self):
|
||||
if getdate(self.start_date) > getdate(self.end_date):
|
||||
frappe.throw(_("Start Date cannot be after End Date"))
|
||||
|
||||
if getdate(self.end_date) > getdate(nowdate()):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Accounting Period cannot be created for a future date. End Date {0} is after today."
|
||||
).format(frappe.bold(frappe.format(self.end_date, "Date")))
|
||||
)
|
||||
|
||||
def before_insert(self):
|
||||
self.bootstrap_doctypes_for_closing()
|
||||
|
||||
@@ -59,19 +46,23 @@ class AccountingPeriod(Document):
|
||||
self.name = " - ".join([self.period_name, company_abbr])
|
||||
|
||||
def validate_overlap(self):
|
||||
AccountingPeriod = frappe.qb.DocType("Accounting Period")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(AccountingPeriod)
|
||||
.select(AccountingPeriod.name)
|
||||
.where(AccountingPeriod.start_date <= self.end_date)
|
||||
.where(AccountingPeriod.end_date >= self.start_date)
|
||||
.where(AccountingPeriod.name != self.name)
|
||||
.where(AccountingPeriod.company == self.company)
|
||||
existing_accounting_period = frappe.db.sql(
|
||||
"""select name from `tabAccounting Period`
|
||||
where (
|
||||
(%(start_date)s between start_date and end_date)
|
||||
or (%(end_date)s between start_date and end_date)
|
||||
or (start_date between %(start_date)s and %(end_date)s)
|
||||
or (end_date between %(start_date)s and %(end_date)s)
|
||||
) and name!=%(name)s and company=%(company)s""",
|
||||
{
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
"name": self.name,
|
||||
"company": self.company,
|
||||
},
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
existing_accounting_period = query.run(as_dict=True)
|
||||
|
||||
if len(existing_accounting_period) > 0:
|
||||
frappe.throw(
|
||||
_("Accounting Period overlaps with {0}").format(existing_accounting_period[0].get("name")),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
from frappe.utils import add_months, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_period.accounting_period import (
|
||||
ClosedAccountingPeriod,
|
||||
@@ -93,7 +93,7 @@ def create_accounting_period(**args):
|
||||
|
||||
accounting_period = frappe.new_doc("Accounting Period")
|
||||
accounting_period.start_date = args.start_date or nowdate()
|
||||
accounting_period.end_date = args.end_date or nowdate()
|
||||
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
|
||||
accounting_period.company = args.company or "_Test Company"
|
||||
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
|
||||
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})
|
||||
|
||||
@@ -10,9 +10,6 @@ frappe.ui.form.on("Accounts Settings", {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
|
||||
|
||||
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
|
||||
},
|
||||
enable_immutable_ledger: function (frm) {
|
||||
if (!frm.doc.enable_immutable_ledger) {
|
||||
@@ -52,16 +49,3 @@ function toggle_tax_settings(frm, field_name) {
|
||||
frm.set_value(other_field, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function get_transactions(frm) {
|
||||
const transactions = [
|
||||
{ label: __("Journal Entry"), doctype: "Journal Entry" },
|
||||
{ label: __("Payment Entry"), doctype: "Payment Entry" },
|
||||
{ label: __("Purchase Invoice"), doctype: "Purchase Invoice" },
|
||||
{ label: __("Purchase Order"), doctype: "Purchase Order" },
|
||||
{ label: __("Purchase Receipt"), doctype: "Purchase Receipt" },
|
||||
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
|
||||
];
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
"confirm_before_resetting_posting_date",
|
||||
"preview_mode",
|
||||
"analytics_section",
|
||||
"enable_discounts_and_margin",
|
||||
"enable_accounting_dimensions",
|
||||
"column_break_vtnr",
|
||||
"enable_discounts_and_margin",
|
||||
"journals_section",
|
||||
"merge_similar_account_heads",
|
||||
"deferred_accounting_settings_section",
|
||||
@@ -44,6 +44,7 @@
|
||||
"print_settings",
|
||||
"show_inclusive_tax_in_print",
|
||||
"show_taxes_as_table_in_print",
|
||||
"column_break_12",
|
||||
"show_payment_schedule_in_print",
|
||||
"item_price_settings_section",
|
||||
"maintain_same_internal_transaction_rate",
|
||||
@@ -59,41 +60,39 @@
|
||||
"payments_tab",
|
||||
"section_break_jpd0",
|
||||
"auto_reconcile_payments",
|
||||
"exchange_gain_loss_posting_date",
|
||||
"auto_reconciliation_job_trigger",
|
||||
"reconciliation_queue_size",
|
||||
"column_break_resa",
|
||||
"exchange_gain_loss_posting_date",
|
||||
"repost_section",
|
||||
"column_break_mfor",
|
||||
"repost_allowed_types",
|
||||
"payment_options_section",
|
||||
"fetch_payment_schedule_in_payment_request",
|
||||
"enable_loyalty_point_program",
|
||||
"column_break_ctam",
|
||||
"fetch_payment_schedule_in_payment_request",
|
||||
"invoicing_settings_tab",
|
||||
"accounts_transactions_settings_section",
|
||||
"make_payment_via_journal_entry",
|
||||
"over_billing_allowance",
|
||||
"credit_controller",
|
||||
"role_allowed_to_over_bill",
|
||||
"column_break_11",
|
||||
"role_allowed_to_over_bill",
|
||||
"credit_controller",
|
||||
"make_payment_via_journal_entry",
|
||||
"assets_tab",
|
||||
"asset_settings_section",
|
||||
"book_asset_depreciation_entry_automatically",
|
||||
"calculate_depr_using_total_days",
|
||||
"role_to_notify_on_depreciation_failure",
|
||||
"column_break_gjcc",
|
||||
"book_asset_depreciation_entry_automatically",
|
||||
"role_to_notify_on_depreciation_failure",
|
||||
"closing_settings_tab",
|
||||
"period_closing_settings_section",
|
||||
"ignore_account_closing_balance",
|
||||
"use_legacy_controller_for_pcv",
|
||||
"pcv_job_timeout",
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"receivable_payable_remarks_length",
|
||||
"column_break_lvjk",
|
||||
"receivable_payable_remarks_length",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
@@ -105,15 +104,13 @@
|
||||
"show_balance_in_coa",
|
||||
"banking_section",
|
||||
"enable_party_matching",
|
||||
"automatically_run_rules_on_unreconciled_transactions",
|
||||
"enable_fuzzy_matching",
|
||||
"transfer_match_days",
|
||||
"automatically_run_rules_on_unreconciled_transactions",
|
||||
"payment_request_section",
|
||||
"create_pr_in_draft_status",
|
||||
"budget_section",
|
||||
"use_legacy_budget_controller",
|
||||
"document_naming_tab",
|
||||
"transaction_naming_html"
|
||||
"use_legacy_budget_controller"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -121,14 +118,14 @@
|
||||
"description": "Address used to determine Tax Category in transactions",
|
||||
"fieldname": "determine_address_tax_category_from",
|
||||
"fieldtype": "Select",
|
||||
"label": "Determine Address Tax Category from",
|
||||
"label": "Determine Address Tax Category From",
|
||||
"options": "Billing Address\nShipping Address"
|
||||
},
|
||||
{
|
||||
"fieldname": "credit_controller",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Role allowed to bypass credit limit",
|
||||
"label": "Role allowed to bypass Credit Limit",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
@@ -136,7 +133,7 @@
|
||||
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
|
||||
"fieldname": "check_supplier_invoice_uniqueness",
|
||||
"fieldtype": "Check",
|
||||
"label": "Check Supplier invoice number uniqueness"
|
||||
"label": "Check Supplier Invoice Number Uniqueness"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -147,29 +144,27 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#4-unlink-payment-on-cancellation-of-invoice",
|
||||
"fieldname": "unlink_payment_on_cancellation_of_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Unlink Payment on cancellation of invoice"
|
||||
"label": "Unlink Payment on Cancellation of Invoice"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#8-unlink-advance-payment-on-cancellation-of-order",
|
||||
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Unlink Advance Payment on cancellation of order"
|
||||
"label": "Unlink Advance Payment on Cancellation of Order"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "book_asset_depreciation_entry_automatically",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Asset Depreciation entry automatically"
|
||||
"label": "Book Asset Depreciation Entry Automatically"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "add_taxes_from_item_tax_template",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically add Taxes and Charges from Item Tax Template"
|
||||
"label": "Automatically Add Taxes and Charges from Item Tax Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "print_settings",
|
||||
@@ -180,13 +175,17 @@
|
||||
"default": "0",
|
||||
"fieldname": "show_inclusive_tax_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show inclusive tax in print"
|
||||
"label": "Show Inclusive Tax in Print"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_payment_schedule_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Payment Schedule in print"
|
||||
"label": "Show Payment Schedule in Print"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency_exchange_section",
|
||||
@@ -212,7 +211,7 @@
|
||||
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
||||
"fieldname": "automatically_fetch_payment_terms",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically fetch Payment Terms from Order/Quotation"
|
||||
"label": "Automatically Fetch Payment Terms from Order/Quotation"
|
||||
},
|
||||
{
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
@@ -224,7 +223,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "automatically_process_deferred_accounting_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically process deferred Accounting entry"
|
||||
"label": "Automatically Process Deferred Accounting Entry"
|
||||
},
|
||||
{
|
||||
"fieldname": "deferred_accounting_settings_section",
|
||||
@@ -240,7 +239,7 @@
|
||||
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
|
||||
"fieldname": "book_deferred_entries_via_journal_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book deferred entries via Journal Entry"
|
||||
"label": "Book Deferred Entries Via Journal Entry"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -248,37 +247,38 @@
|
||||
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
|
||||
"fieldname": "submit_journal_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Submit Journal entries"
|
||||
"label": "Submit Journal Entries"
|
||||
},
|
||||
{
|
||||
"default": "Days",
|
||||
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
|
||||
"fieldname": "book_deferred_entries_based_on",
|
||||
"fieldtype": "Select",
|
||||
"label": "Book Deferred entries based on",
|
||||
"label": "Book Deferred Entries Based On",
|
||||
"options": "Days\nMonths"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "delete_linked_ledger_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete Accounting and Stock Ledger entries on deletion of transaction"
|
||||
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.over_billing_allowance > 0",
|
||||
"description": "Users with this role are allowed to over bill above the allowance percentage",
|
||||
"fieldname": "role_allowed_to_over_bill",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to over bill ",
|
||||
"label": "Role Allowed to Over Bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Period Closing Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounts_transactions_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Credit Limit Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_11",
|
||||
@@ -363,14 +363,14 @@
|
||||
"default": "1",
|
||||
"fieldname": "show_balance_in_coa",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show balances in Chart of Accounts"
|
||||
"label": "Show Balances in Chart Of Accounts"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
|
||||
"fieldname": "book_tax_discount_loss",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book tax loss on early payment discount"
|
||||
"label": "Book Tax Loss on Early Payment Discount"
|
||||
},
|
||||
{
|
||||
"fieldname": "journals_section",
|
||||
@@ -382,7 +382,7 @@
|
||||
"description": "Rows with Same Account heads will be merged on Ledger",
|
||||
"fieldname": "merge_similar_account_heads",
|
||||
"fieldtype": "Check",
|
||||
"label": "Merge similar Account Heads"
|
||||
"label": "Merge Similar Account Heads"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jpd0",
|
||||
@@ -393,13 +393,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "auto_reconcile_payments",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto reconcile Payments"
|
||||
"label": "Auto Reconcile Payments"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_taxes_as_table_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show taxes as table in print"
|
||||
"label": "Show Taxes as Table in Print"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -421,14 +421,14 @@
|
||||
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
|
||||
"fieldname": "ignore_account_closing_balance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Ignore Account closing balance"
|
||||
"label": "Ignore Account Closing Balance"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Tax Amount will be rounded on a row(items) level",
|
||||
"fieldname": "round_row_wise_tax",
|
||||
"fieldtype": "Check",
|
||||
"label": "Round tax amount row-wise"
|
||||
"label": "Round Tax Amount Row-wise"
|
||||
},
|
||||
{
|
||||
"fieldname": "reports_tab",
|
||||
@@ -440,14 +440,14 @@
|
||||
"description": "Truncates 'Remarks' column to set character length",
|
||||
"fieldname": "general_ledger_remarks_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "General Ledger remarks length"
|
||||
"label": "General Ledger"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Truncates 'Remarks' column to set character length",
|
||||
"fieldname": "receivable_payable_remarks_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Accounts Receivable / Payable remarks length"
|
||||
"label": "Accounts Receivable/Payable"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lvjk",
|
||||
@@ -481,7 +481,7 @@
|
||||
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
|
||||
"fieldname": "create_pr_in_draft_status",
|
||||
"fieldtype": "Check",
|
||||
"label": "Create payment requests in Draft status"
|
||||
"label": "Create in Draft Status"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_yuug",
|
||||
@@ -496,14 +496,14 @@
|
||||
"description": "Interval should be between 1 to 59 MInutes",
|
||||
"fieldname": "auto_reconciliation_job_trigger",
|
||||
"fieldtype": "Int",
|
||||
"label": "Auto Reconciliation job trigger"
|
||||
"label": "Auto Reconciliation Job Trigger"
|
||||
},
|
||||
{
|
||||
"default": "5",
|
||||
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
|
||||
"fieldname": "reconciliation_queue_size",
|
||||
"fieldtype": "Int",
|
||||
"label": "Reconciliation queue size"
|
||||
"label": "Reconciliation Queue Size"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -517,14 +517,14 @@
|
||||
"description": "Only applies for Normal Payments",
|
||||
"fieldname": "exchange_gain_loss_posting_date",
|
||||
"fieldtype": "Select",
|
||||
"label": "Posting Date inheritance for exchange gain / loss",
|
||||
"label": "Posting Date Inheritance for Exchange Gain / Loss",
|
||||
"options": "Invoice\nPayment\nReconciliation Date"
|
||||
},
|
||||
{
|
||||
"default": "Buffered Cursor",
|
||||
"fieldname": "receivable_payable_fetch_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Data fetch method",
|
||||
"label": "Data Fetch Method",
|
||||
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||
},
|
||||
{
|
||||
@@ -541,14 +541,14 @@
|
||||
"default": "0",
|
||||
"fieldname": "maintain_same_internal_transaction_rate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Maintain same rate throughout internal Transaction"
|
||||
"label": "Maintain Same Rate Throughout Internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "Stop",
|
||||
"depends_on": "maintain_same_internal_transaction_rate",
|
||||
"fieldname": "maintain_same_rate_action",
|
||||
"fieldtype": "Select",
|
||||
"label": "Action if same rate is not maintained throughout internal transaction",
|
||||
"label": "Action if Same Rate is Not Maintained Throughout Internal Transaction",
|
||||
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
|
||||
"options": "Stop\nWarn"
|
||||
},
|
||||
@@ -556,7 +556,7 @@
|
||||
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
|
||||
"fieldname": "role_to_override_stop_action",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role allowed to override stop action",
|
||||
"label": "Role Allowed to Override Stop Action",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
@@ -588,7 +588,7 @@
|
||||
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
|
||||
"fieldname": "add_taxes_from_taxes_and_charges_template",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically add taxes from Taxes and Charges Template"
|
||||
"label": "Automatically Add Taxes from Taxes and Charges Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ntmi",
|
||||
@@ -598,28 +598,19 @@
|
||||
"default": "0",
|
||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch valuation rate for internal Transaction"
|
||||
"label": "Fetch Valuation Rate for Internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enable this if you are experiencing issues with the new budget controller. Uses the older budget validation logic",
|
||||
"fieldname": "use_legacy_budget_controller",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use legacy Budget Controller"
|
||||
"label": "Use Legacy Budget Controller"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "use_legacy_controller_for_pcv",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use legacy controller for Period Closing Voucher"
|
||||
},
|
||||
{
|
||||
"default": "3600",
|
||||
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
|
||||
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
|
||||
"fieldname": "pcv_job_timeout",
|
||||
"fieldtype": "Int",
|
||||
"label": "PCV Job Timeout (seconds)"
|
||||
"label": "Use Legacy Controller For Period Closing Voucher"
|
||||
},
|
||||
{
|
||||
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
||||
@@ -637,7 +628,7 @@
|
||||
{
|
||||
"fieldname": "chart_of_accounts_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Chart of Accounts"
|
||||
"label": "Chart Of Accounts"
|
||||
},
|
||||
{
|
||||
"fieldname": "banking_section",
|
||||
@@ -682,7 +673,6 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/loyalty-program",
|
||||
"fieldname": "enable_loyalty_point_program",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Loyalty Point Program"
|
||||
@@ -709,7 +699,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "fetch_payment_schedule_in_payment_request",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch Payment Schedule in Payment Request"
|
||||
"label": "Fetch Payment Schedule In Payment Request"
|
||||
},
|
||||
{
|
||||
"default": "3",
|
||||
@@ -734,7 +724,7 @@
|
||||
{
|
||||
"fieldname": "repost_allowed_types",
|
||||
"fieldtype": "Table",
|
||||
"label": "Allowed DocTypes",
|
||||
"label": "Allowed Doctypes",
|
||||
"options": "Repost Allowed Types"
|
||||
},
|
||||
{
|
||||
@@ -742,21 +732,7 @@
|
||||
"description": "Runs a preview check on save before submission without making any actual changes.",
|
||||
"fieldname": "preview_mode",
|
||||
"fieldtype": "Check",
|
||||
"label": "Preview mode"
|
||||
},
|
||||
{
|
||||
"fieldname": "document_naming_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Document Naming"
|
||||
},
|
||||
{
|
||||
"fieldname": "transaction_naming_html",
|
||||
"fieldtype": "HTML"
|
||||
},
|
||||
{
|
||||
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
|
||||
"fieldname": "column_break_mfor",
|
||||
"fieldtype": "Column Break"
|
||||
"label": "Preview Mode"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -765,7 +741,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-24 12:59:41.868865",
|
||||
"modified": "2026-05-18 12:16:33.679345",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -90,7 +90,6 @@ class AccountsSettings(Document):
|
||||
make_payment_via_journal_entry: DF.Check
|
||||
merge_similar_account_heads: DF.Check
|
||||
over_billing_allowance: DF.Currency
|
||||
pcv_job_timeout: DF.Int
|
||||
preview_mode: DF.Check
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
|
||||
@@ -22,13 +22,11 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_usd_payable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"column_break_12",
|
||||
"branch_code",
|
||||
"bank_account_no",
|
||||
"statement_password",
|
||||
"address_and_contact",
|
||||
"address_html",
|
||||
"column_break_13",
|
||||
@@ -150,12 +149,6 @@
|
||||
"label": "Bank Account No",
|
||||
"length": 30
|
||||
},
|
||||
{
|
||||
"description": "Password used to open password-protected PDF statements for this account. Stored encrypted.",
|
||||
"fieldname": "statement_password",
|
||||
"fieldtype": "Password",
|
||||
"label": "Statement PDF Password"
|
||||
},
|
||||
{
|
||||
"fieldname": "address_and_contact",
|
||||
"fieldtype": "Section Break",
|
||||
|
||||
@@ -41,7 +41,6 @@ class BankAccount(Document):
|
||||
mask: DF.Data | None
|
||||
party: DF.DynamicLink | None
|
||||
party_type: DF.Link | None
|
||||
statement_password: DF.Password | None
|
||||
# end: auto-generated types
|
||||
|
||||
def onload(self):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-04-11 19:48:13.622253",
|
||||
"doctype": "DocType",
|
||||
@@ -8,8 +7,7 @@
|
||||
"field_order": [
|
||||
"bank_account",
|
||||
"date",
|
||||
"balance",
|
||||
"company"
|
||||
"balance"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -33,20 +31,12 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Balance",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "bank_account.company",
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-16 22:17:48.007982",
|
||||
"modified": "2026-04-11 19:49:45.374695",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account Balance",
|
||||
|
||||
@@ -16,7 +16,6 @@ class BankAccountBalance(Document):
|
||||
|
||||
balance: DF.Currency
|
||||
bank_account: DF.Link
|
||||
company: DF.Link | None
|
||||
date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import cint, flt, fmt_money, getdate
|
||||
from pypika import Order
|
||||
|
||||
@@ -94,7 +94,6 @@ class BankClearance(Document):
|
||||
invalid_document = []
|
||||
invalid_cheque_date = []
|
||||
entries_to_update = []
|
||||
self.check_permission("write")
|
||||
|
||||
def validate_entry(d):
|
||||
is_valid = True
|
||||
@@ -195,17 +194,14 @@ def get_payment_entries_for_bank_clearance(
|
||||
.select(
|
||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||
journal_entry.name.as_("payment_entry"),
|
||||
# non-grouped columns are constant per grouped JE name / account (against_account is
|
||||
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
|
||||
# same value MySQL picked.
|
||||
Max(journal_entry.cheque_no).as_("cheque_number"),
|
||||
Max(journal_entry.cheque_date).as_("cheque_date"),
|
||||
journal_entry.cheque_no.as_("cheque_number"),
|
||||
journal_entry.cheque_date,
|
||||
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
|
||||
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
|
||||
Max(journal_entry.posting_date).as_("posting_date"),
|
||||
Max(journal_entry_account.against_account).as_("against_account"),
|
||||
Max(journal_entry.clearance_date).as_("clearance_date"),
|
||||
Max(journal_entry_account.account_currency).as_("account_currency"),
|
||||
journal_entry.posting_date,
|
||||
journal_entry_account.against_account,
|
||||
journal_entry.clearance_date,
|
||||
journal_entry_account.account_currency,
|
||||
)
|
||||
.where(
|
||||
(journal_entry_account.account == account)
|
||||
@@ -218,13 +214,12 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
journal_entry_query = journal_entry_query.where(
|
||||
(journal_entry.clearance_date.isnull())
|
||||
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
journal_entries = (
|
||||
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
|
||||
.orderby(Max(journal_entry.posting_date))
|
||||
.orderby(journal_entry.posting_date)
|
||||
.orderby(journal_entry.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -294,8 +289,7 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
payment_entry_query = payment_entry_query.where(
|
||||
(pe.clearance_date.isnull())
|
||||
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
|
||||
@@ -332,8 +326,7 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
|
||||
(pi.clearance_date.isnull())
|
||||
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
paid_purchase_invoices = (
|
||||
@@ -373,8 +366,7 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
pos_sales_invoices_query = pos_sales_invoices_query.where(
|
||||
(si_payment.clearance_date.isnull())
|
||||
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
pos_sales_invoices = (
|
||||
|
||||
@@ -9,13 +9,6 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
@@ -51,7 +50,8 @@
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType"
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
@@ -60,14 +60,14 @@
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
||||
"fieldname": "supplier",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier",
|
||||
@@ -218,11 +218,10 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
"modified": "2025-08-29 11:52:33.550847",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, create_batch, flt
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
@@ -518,7 +518,6 @@ def create_internal_transfer(
|
||||
"""
|
||||
|
||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
bank_transaction.check_permission("write")
|
||||
|
||||
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
|
||||
company = frappe.get_cached_value("Account", bank_account, "company")
|
||||
@@ -779,6 +778,7 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"""
|
||||
Create a payment entry and reconcile it with the bank transaction
|
||||
"""
|
||||
|
||||
output = []
|
||||
|
||||
for bank_transaction_name in bank_transaction_names:
|
||||
@@ -1058,9 +1058,9 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_voucher: bool = False):
|
||||
# updated clear date of all the vouchers based on the bank transaction
|
||||
vouchers = frappe.parse_json(vouchers)
|
||||
vouchers = json.loads(vouchers)
|
||||
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
transaction.add_payment_entries(vouchers, is_new_voucher)
|
||||
transaction.validate_duplicate_references()
|
||||
@@ -1410,14 +1410,12 @@ def get_je_matching_query(
|
||||
Sum(getattr(jea, amount_field)).as_("paid_amount"),
|
||||
ConstantColumn("Journal Entry").as_("doctype"),
|
||||
je.name,
|
||||
# non-grouped columns are constant per grouped JE name (party_type/currency come from the
|
||||
# single bank-account line) -> Max() keeps the GROUP BY valid on postgres with the same value
|
||||
Max(je.cheque_no).as_("reference_no"),
|
||||
Max(je.cheque_date).as_("reference_date"),
|
||||
Max(je.pay_to_recd_from).as_("party"),
|
||||
Max(jea.party_type).as_("party_type"),
|
||||
Max(je.posting_date).as_("posting_date"),
|
||||
Max(jea.account_currency).as_("currency"),
|
||||
je.cheque_no.as_("reference_no"),
|
||||
je.cheque_date.as_("reference_date"),
|
||||
je.pay_to_recd_from.as_("party"),
|
||||
jea.party_type,
|
||||
je.posting_date,
|
||||
jea.account_currency.as_("currency"),
|
||||
)
|
||||
.where(je.docstatus == 1)
|
||||
.where(je.voucher_type != "Opening Entry")
|
||||
@@ -1425,7 +1423,7 @@ def get_je_matching_query(
|
||||
.where(jea.account == common_filters.bank_account)
|
||||
.where(filter_by_date)
|
||||
.groupby(je.name)
|
||||
.orderby(Max(je.cheque_date) if cint(filter_by_reference_date) else Max(je.posting_date))
|
||||
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
|
||||
)
|
||||
|
||||
if frappe.flags.auto_reconcile_vouchers is True:
|
||||
|
||||
@@ -17,10 +17,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "format:Bank Statement Import on {creation}",
|
||||
"beta": 1,
|
||||
"creation": "2019-08-04 14:16:08.318714",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -221,16 +221,16 @@
|
||||
"default": "0",
|
||||
"fieldname": "import_mt940_fromat",
|
||||
"fieldtype": "Check",
|
||||
"label": "Import MT940 Format"
|
||||
"label": "Import MT940 Fromat"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-19 14:18:00.000000",
|
||||
"modified": "2025-06-11 02:23:22.159961",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -290,7 +290,7 @@ def update_mapping_db(bank, template_options):
|
||||
for d in bank.bank_transaction_mapping:
|
||||
d.delete()
|
||||
|
||||
for d in frappe.parse_json(template_options)["column_to_field_map"].items():
|
||||
for d in json.loads(template_options)["column_to_field_map"].items():
|
||||
bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1], "file_field": d[0]})
|
||||
|
||||
bank.save()
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
"detected_transaction_starting_index",
|
||||
"detected_transaction_ending_index",
|
||||
"section_break_yulq",
|
||||
"column_mapping",
|
||||
"pdf_tables"
|
||||
"column_mapping"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -129,13 +128,6 @@
|
||||
"label": "Column Mapping",
|
||||
"options": "Bank Statement Import Log Column Map"
|
||||
},
|
||||
{
|
||||
"description": "Per-table extraction data for PDF statements (rows, bbox, page image, column mapping). Edited via the banking app.",
|
||||
"fieldname": "pdf_tables",
|
||||
"fieldtype": "JSON",
|
||||
"label": "PDF Tables",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Not Started",
|
||||
"fieldname": "status",
|
||||
|
||||
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