比较提交

...
7 次代码提交
共修改 68 个文件,包含 3800 行新增和 124 行删除
+5 -3
查看文件
@@ -17,9 +17,11 @@ CRITICAL — Recursion guard (read first):
You are the Trellis reviewer agent.
Trellis Context Loading Protocol:
- Look for the `<!-- trellis-hook-injected -->` marker in your input above.
- If the marker is present, the native `SubagentStart` hook has already loaded the role-specific task artifacts and spec context. Proceed directly with the check work.
- If the marker is absent, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing check work.
- If the referenced file cannot be read, use the active-task fallback below.
- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context.
- If there is no saved-output notice and the marker is absent, use the active-task fallback below.
- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
Your job is to review code changes against specs AND fix issues directly — not just report them. You have write access; use it.
+5 -3
查看文件
@@ -17,9 +17,11 @@ CRITICAL — Recursion guard (read first):
You are the Trellis implementer agent.
Trellis Context Loading Protocol:
- Look for the `<!-- trellis-hook-injected -->` marker in your input above.
- If the marker is present, the native `SubagentStart` hook has already loaded the role-specific task artifacts and spec context. Proceed directly with the implementation work.
- If the marker is absent, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing implementation work.
- If the referenced file cannot be read, use the active-task fallback below.
- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context.
- If there is no saved-output notice and the marker is absent, use the active-task fallback below.
- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
Rules:
- Read before write. Follow `.trellis/spec/` guidance relevant to the task.
+11 -6
查看文件
@@ -15,12 +15,17 @@ through the chat reply is a failure.
## Trellis Context Loading Protocol
Look for the `<!-- trellis-hook-injected -->` marker in your input above.
- If the marker is present, the native `SubagentStart` hook has supplied the
`Active task: <path>` header and research-only context. Use that task path.
- If the marker is absent, find `Active task: <path>` in your dispatch prompt.
If it is absent too, ask the main session for the path; do not run
- First look for `Full hook output saved to: <path>` in your input above. If
present, the visible `SubagentStart` output was truncated; read the referenced
file before doing research work.
- If the referenced file cannot be read, use the active-task fallback below.
- If there is no saved-output notice and the
`<!-- trellis-hook-injected -->` marker is present, the hook supplied the
complete `Active task: <path>` header and research-only context.
- If there is no saved-output notice and the marker is absent, use the
active-task fallback below.
- For the fallback, find `Active task: <path>` in your dispatch prompt. If it
is absent too, ask the main session for the path; do not run
`task.py current`, guess, or use another session's task.
- Do not load `implement.jsonl` or `check.jsonl`; research is role-isolated.
+8
查看文件
@@ -32,6 +32,14 @@ import sys
from pathlib import Path
from typing import Any
# Hook hosts send UTF-8 JSON regardless of the process locale.
_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None)
if callable(_stdin_reconfigure):
try:
_stdin_reconfigure(encoding="utf-8", errors="replace")
except (OSError, ValueError):
pass
# IMPORTANT: Force stdout to use UTF-8 on Windows
# This fixes UnicodeEncodeError when outputting non-ASCII characters
if sys.platform.startswith("win"):
+10
查看文件
@@ -33,6 +33,16 @@ jobs:
working-directory: client
run: npm ci
- name: Install BIFF8 roundtrip verifier
run: |
python3 -m venv "$RUNNER_TEMP/payment-order-venv"
"$RUNNER_TEMP/payment-order-venv/bin/pip" install --disable-pip-version-check --require-hashes -r server/requirements/payment-order-biff8.txt
echo "PAYMENT_ORDER_PYTHON_BIN=$RUNNER_TEMP/payment-order-venv/bin/python" >> "$GITHUB_ENV"
- name: Verify payment-order BIFF8 contract
working-directory: server
run: node scripts/check-payment-order-xls-roundtrip.js
- name: Run minimal checks
run: ./scripts/check-minimal.sh
+7 -7
查看文件
@@ -31,9 +31,9 @@
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
".agents/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1",
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
".codex/agents/trellis-check.toml": "42d774d175dab732872e1056aa70005b6a1693d93fecfde6b9c86ecc50bd7b9e",
".codex/agents/trellis-implement.toml": "c21eb1a671ebb8afe476ad54ae9ae1e33e4e97750bfdc4eabecc772a4d4e777b",
".codex/agents/trellis-research.toml": "200b9fca30a68e027d2e8b2b4c785fafccb9aa75853e70abd92d2d07d7cba5d1",
".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
@@ -42,18 +42,18 @@
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
".trellis/scripts/common/active_task.py": "3fcafde57bdc4442940081d1617295e0f4f8e620d0d4fbb97d33394e8679a39c",
".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119",
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
".trellis/scripts/common/git.py": "ea9ad7686bae598b04e41281ca938f83c06d8021716332947ea51339e7cb1aa9",
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
".trellis/scripts/common/session_context.py": "19927c62d3fd70fecf70da0c5b25c6c81e63683f8a594a1ae3b5d6c115070368",
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
".trellis/scripts/common/task_context.py": "51301e180ce702c6a5b1a2dd1b155a6eed1bf2b05cb2ac1e51081212628b11d4",
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6",
@@ -86,6 +86,6 @@
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
".codex/hooks/inject-subagent-context.py": "fe7e315b6ef5ca28dafa9a92072e4a07278492ee9cab74d42f0c0b6addf51662"
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609"
}
}
+1 -1
查看文件
@@ -1 +1 @@
0.6.9
0.6.12
+18 -9
查看文件
@@ -74,10 +74,15 @@ session_auto_commit: false
#-------------------------------------------------------------------------------
# Codex (dispatch behavior)
#-------------------------------------------------------------------------------
# Codex-only knob; other platforms ignore it. Trellis 0.6.9 supports native
# sub-agent context injection, but hyb-oa deliberately keeps the main session
# in charge unless a task explicitly approves sub-agent work. "sub-agent" is
# retained upstream as a backwards-compatible alias for "auto".
# Codex-only knob; other platforms ignore it. Trellis 0.6.12 supports native
# sub-agent context injection and recovery of truncated hook output, but hyb-oa
# deliberately keeps the main session in charge unless a task explicitly
# approves sub-agent work. "sub-agent" remains a backwards-compatible alias
# for "auto"; invalid explicit values safely use inline mode.
#
# In "auto" mode, sub-agents inherit the main session model unless their
# generated `.codex/agents/trellis-*.toml` file pins a model. There is no
# config.yaml model override.
#
codex:
dispatch_mode: inline # keep Codex main-session edits until a task explicitly asks for sub-agents
@@ -116,9 +121,11 @@ channel:
#-------------------------------------------------------------------------------
# Sub-agent context injection limits
#-------------------------------------------------------------------------------
# Trellis 0.6.9 caps injected task artifacts and referenced context. Defaults
# are sufficient for hyb-oa; keep them documented but implicit until a real
# task proves they need tuning.
# Trellis 0.6.12 caps injected task artifacts and referenced context. Oversized
# files are truncated with notice; total-budget overflow degrades remaining
# files to path/reason/size index lines. Defaults are sufficient for hyb-oa;
# keep them documented but implicit until a real task proves they need tuning.
# `0` disables the corresponding byte limit.
#
# context_injection:
# max_file_bytes: 32768
@@ -128,8 +135,10 @@ channel:
#-------------------------------------------------------------------------------
# Per-turn prompt injection
#-------------------------------------------------------------------------------
# Optional one-turn escape hatch. The project does not enable it by default so
# Trellis task state remains deterministic.
# Optional one-turn escape hatch for the per-turn `<workflow-state>` breadcrumb.
# It uses a case-insensitive standalone-word match and does not affect session
# start or sub-agent context injection. The project keeps it disabled so task
# state remains deterministic.
#
# prompt_injection:
# skip_keyword: "no-trellis"
+5 -2
查看文件
@@ -612,13 +612,16 @@ def clear_active_task(
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> ActiveTask:
"""Clear the active task by deleting the current session context file."""
"""Clear the active task by deleting its resolved session context file."""
context_key = resolve_context_key(platform_input, platform)
if not context_key:
return ActiveTask(None, "none")
previous = resolve_active_task(repo_root, platform_input, platform)
context_path = _context_path(repo_root, context_key)
if not previous.task_path or not previous.context_key:
return previous
context_path = _context_path(repo_root, previous.context_key)
if context_path.is_file():
_remove_file(context_path)
return previous
+9 -2
查看文件
@@ -10,11 +10,17 @@ import subprocess
from pathlib import Path
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
def run_git(
args: list[str],
cwd: Path | None = None,
timeout: float | None = None,
) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr).
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
consistent output across all platforms (Windows, macOS, Linux).
consistent output across all platforms (Windows, macOS, Linux). Callers
may provide a timeout for best-effort probes; normal Git operations remain
unbounded by default.
"""
try:
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
@@ -25,6 +31,7 @@ def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
+64 -11
查看文件
@@ -17,6 +17,7 @@ import json
import os
import re
import subprocess
import sys
from pathlib import Path
from .active_task import resolve_context_key
@@ -64,11 +65,17 @@ _POLYREPO_IGNORED_DIRS = {
"__pycache__",
}
_POLYREPO_SCAN_MAX_DEPTH = 2
_POLYREPO_SCAN_MAX_REPOS = 8
_GIT_PROBE_TIMEOUT_SECONDS = 2.0
def _is_git_worktree(path: Path) -> bool:
"""Return True when path is inside a Git worktree."""
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
rc, out, _ = run_git(
["rev-parse", "--is-inside-work-tree"],
cwd=path,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
return rc == 0 and out.strip().lower() == "true"
@@ -91,13 +98,27 @@ def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | N
if not (repo_dir / ".git").exists():
return None
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
status_rc, status_out, _ = run_git(
["status", "--porcelain"],
cwd=repo_dir,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
if status_rc != 0:
return None
changes = len([line for line in status_out.splitlines() if line.strip()])
_, branch_out, _ = run_git(
["branch", "--show-current"],
cwd=repo_dir,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
branch = branch_out.strip() or "unknown"
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
changes = len([l for l in status_out.splitlines() if l.strip()])
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
_, log_out, _ = run_git(
["log", "--oneline", "-5"],
cwd=repo_dir,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
return {
"name": name,
@@ -120,20 +141,36 @@ def _collect_root_git_info(repo_root: Path) -> dict:
"recentCommits": [],
}
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
_, branch_out, _ = run_git(
["branch", "--show-current"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
branch = branch_out.strip() or "unknown"
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
status_rc, status_out, _ = run_git(
["status", "--porcelain"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
status_lines = [line for line in status_out.splitlines() if line.strip()]
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
_, short_out, _ = run_git(
["status", "--short"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
_, log_out, _ = run_git(
["log", "--oneline", "-5"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
return {
"isRepo": True,
"branch": branch,
"isClean": len(status_lines) == 0,
"isClean": status_rc == 0 and len(status_lines) == 0,
"uncommittedChanges": len(status_lines),
"statusShort": short_out.splitlines(),
"recentCommits": _parse_recent_commits(log_out),
@@ -143,12 +180,16 @@ def _collect_root_git_info(repo_root: Path) -> dict:
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
"""Discover child Git repositories using the init-time polyrepo heuristic."""
found: list[str] = []
overflow = False
def is_candidate_dir(path: Path) -> bool:
name = path.name
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
def scan(rel_dir: Path, depth: int) -> None:
nonlocal overflow
if overflow:
return
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
return
abs_dir = repo_root / rel_dir
@@ -165,11 +206,23 @@ def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
)
if (child / ".git").exists():
if len(found) >= _POLYREPO_SCAN_MAX_REPOS:
overflow = True
return
found.append(child_rel.as_posix())
continue
scan(child_rel, depth + 1)
scan(Path("."), 0)
if overflow:
print(
"warning: found more than "
f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; "
"skipping automatic Git status collection. Configure explicit "
"packages entries with path and git: true in .trellis/config.yaml.",
file=sys.stderr,
)
return []
if len(found) < 2:
return []
return [(path.replace("/", "_"), path) for path in sorted(found)]
+4 -4
查看文件
@@ -236,22 +236,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
file_path, task_rel
):
warning = (
warning_message = (
f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
"implement/check.jsonl should reference spec/research docs; "
"agents read code themselves"
)
print(f" {colored(warning, Colors.YELLOW)}")
print(f" {colored(warning_message, Colors.YELLOW)}")
if max_file_bytes:
size = full_path.stat().st_size
if size > max_file_bytes:
warning = (
warning_message = (
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
"injection will truncate it"
)
print(f" {colored(warning, Colors.YELLOW)}")
print(f" {colored(warning_message, Colors.YELLOW)}")
if errors == 0:
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
+13 -2
查看文件
@@ -125,6 +125,11 @@ recorded in task `07-26-small-team-internal-oa-review`.
`--force` to erase `workflow.md`, `config.yaml`, `.gitignore` or `AGENTS.md`.
- Treat dry-run output and manifest cleanliness as evidence, but do not assume
a dry-run is write-free; inspect `git status` before and after it.
- `trellis update` may copy ignored, local-only platform settings into the new
`.trellis/.backup-<timestamp>/`. Keep that exact backup only through the
merge/rollback window, then remove it before the sensitive-content gate once
Git and the pinned prior CLI provide the rollback path. Do not weaken the
scanner and do not blanket-delete older backups outside the task scope.
### 4. Validation & Error Matrix
@@ -134,6 +139,9 @@ recorded in task `07-26-small-team-internal-oa-review`.
discovery, and rerun the preview.
- `.new` conflict files remain -> upgrade is incomplete; merge or discard each
one deliberately.
- Sensitive scan reports a complete local path inside the newly created
upgrade backup -> resolve the exact backup from this run, remove it after the
merge is stable, and rerun the scan; an ignored directory is still scanned.
- Post-upgrade dry-run changes manifest again -> investigate template hash drift
before commit.
- No active task directory after the last task is archived -> valid idle state;
@@ -157,8 +165,11 @@ recorded in task `07-26-small-team-internal-oa-review`.
- Exercise task validate/current/list and run Trellis governance.
- Archive the final active task and assert Trellis governance still passes with
zero active task directories.
- Confirm no `.new`, backup, bytecode or out-of-scope platform assets are
tracked; finish with `git diff --check`.
- Record pre-existing backup directory names before update; confirm no `.new`,
newly generated backup, bytecode or out-of-scope platform assets are tracked
or left for the sensitive scanner. Do not interpret a historical backup as
evidence that the current upgrade backup may remain. Finish with
`git diff --check`.
### 7. Wrong vs Correct
+141
查看文件
@@ -18,6 +18,147 @@
redacted form.
- `scripts/qa/check-trellis-governance.mjs` must pass before commit.
## Scenario: Bank Account Snapshots And Payment-Order Export
### 1. Scope / Trigger
- Trigger: company payment-account maintenance, employee payee-bank maintenance,
payment-batch creation, full-account reveal or bank payment-order export.
- OA prepares payment evidence and a bank-import file; it does not call a bank
payment API or treat the generated file as proof of payment.
### 2. Signatures
- DB: `BankInstitution`, `PaymentAccount`, `Employee.bank_institution_id`,
`PaymentBatch.payment_account_snapshot_json`, item `snapshot_json.payee`, and
`payment_order_export_*` fields.
- Reveal APIs use `POST` with JSON `{ "reason": "2-500 chars" }`; ordinary read
APIs return only bank name and masked account.
- Payment-order API: `POST /api/payment-batches/:id/payment-order` with a reason;
success is an in-memory BIFF8 `.xls` download with `Cache-Control: no-store`.
- Runtime: Node passes validated row JSON through stdin to the fixed
`fill-payment-order-template.py`; the dedicated Python runtime returns BIFF8
bytes on stdout. `PAYMENT_ORDER_PYTHON_BIN` may select the fixed interpreter,
but requests cannot select the script or template path.
### 3. Contracts
- Employee account editing remains HR/admin-owned. Never grant
`employee.bank.manage` to finance, finance manager or system administrator.
- Company account editing is finance-manager/admin-owned; ordinary finance is
masked read plus reason-audited full reveal.
- A new bank batch freezes the exact existing ciphertext for payer and payees.
Copy ciphertext directly; decrypt only to calculate the masked suffix. Never
decrypt and re-encrypt while building a snapshot.
- Review UI reads masked values from the frozen snapshot. Finance manual-payment
reveal also reads the frozen item snapshot, not the employee's current bank
profile. An employee profile change after review must not change the payment
target for that batch.
- Ordinary batch, AP and export DTOs must explicitly select safe fields and must
never return raw snapshot JSON, `v1:` ciphertext or a full account.
- Payment-order preflight returns a safe blocker array containing fixed codes
and, when useful, the reimbursement business number. Collect every batch and
item blocker before returning; do not restore fail-fast "fix one, find one"
behavior. The UI maps those fixed codes to natural Chinese and keeps manual
payment visible for legacy or unsupported batches.
- Legacy batches without ciphertext snapshots may still complete through manual
payment plus receipt, but they can neither export a payment order nor reveal a
supposed frozen account through a live-profile fallback.
- A payment-order file is an efficiency artifact. A bank receipt remains the
hard evidence required before registering bank payment complete.
- Generate bank files by filling the controlled, blank BIFF8 template asset.
Rebuilding a workbook with matching headers is forbidden when it loses the
bank template's column widths, row heights, fonts, fills, borders or
alignment. The asset contains headers and formatted blank rows only, and its
SHA-256 is verified before every fill.
- BIFF8 rich-text runs are part of the template contract. Open the template
with `formatting_info=True`, copy every source font property, and explicitly
call `write_rich_text`; `xlutils.filter` does not preserve those runs by
default. Never replace this with a hard-coded "red" font approximation.
- Node owns authorization, snapshot decryption, blockers, ordering, serials,
amounts, audit and export counters. Python only copies the fixed template and
fills the already validated 15-cell rows. Full accounts travel only on child
stdin; never place them in argv, environment variables, logs, stderr or a
temporary workbook file.
- CI and the production image install `xlrd`, `xlwt` and `xlutils` from the same
`--require-hashes` requirements. Do not fall back to a style-dropping
generator when Python, a dependency or the template is unavailable.
### 4. Validation & Error Matrix
- Missing/mixed legal entity, no unique active payer, missing bank institution,
missing account or decrypt failure -> reject batch creation with `409` and no
batch/item writes.
- First release bank code is not `CCB` for any payer/payee -> reject the whole
export; do not emit `02`, skip a row or create a partial file.
- Batch not `reviewed`, legacy snapshot or malformed snapshot -> reject export
with `409`; do not increment export count or send file bytes.
- Reveal without permission or approved finance business role -> `403`.
- Reveal/export without a 2-500 character reason -> `400 VALID_001` and no full
account/file response.
- Successful export audit/update failure -> no download response.
- Template hash/structure mismatch -> `409 PAYMENT_ORDER_EXPORT_BLOCKED`, no
export increment and no file bytes; manual payment plus receipt remains
available.
- Python executable/dependency missing, timeout, oversized output, non-zero exit
or invalid OLE magic -> fixed safe generation error; never copy child stderr
into the API error or application log.
### 5. Good/Base/Bad Cases
- Good: review shows a masked frozen CCB account; finance supplies a reason and
temporarily reveals that same frozen account for manual transfer.
- Base: a historical batch displays compatibility metadata, proceeds by manual
payment and receipt, and clearly has no payment-order capability.
- Bad: export or manual-transfer reveal re-queries the employee profile after
review, silently uses a changed account, or returns raw snapshot JSON.
- Good: a fourth synthetic row copies the first template data-row style and
height while the amount cell changes only to numeric `0.00` formatting.
- Bad: create a fresh workbook from the 15 labels, or invoke Python with an
account in argv/env, even if the resulting values look correct in a parser.
### 6. Tests Required
- Permission matrix for employee/company account manage, masked read and full
reveal; include an explicit system-admin denial.
- Privacy-negative assertions over batch/list/detail/AP responses for raw
accounts, `v1:` and raw snapshots.
- Batch creation tests for single legal entity, direct ciphertext copy,
CCB-only, decrypt failure and legacy behavior.
- BIFF8 template-fill tests plus independent readback for three sheets, 15
headers, leading-zero `01`, long account text, Chinese, numeric `0.00`
amounts and deterministic row order. Compare visual signatures for column
widths, row heights, font/bold, fill, border, alignment and wrapped text;
include a row beyond the three preformatted template rows.
- Compare rich-text segments semantically, including start offsets and complete
font properties. Required headers must assert the first `*` uses the source
red font and all remaining text uses the source black font; whole-cell style
equality is insufficient.
- Negative tests cover missing Python/dependencies, template hash drift, child
failure and a child that echoes the synthetic input to stderr; exposed errors
and logs must not contain the account or original row payload.
- Build one synthetic batch with at least two independent payer/payee blockers;
assert the DTO/error contains both safe blocker entries and emits no file,
export counter or success audit.
### 7. Wrong vs Correct
#### Wrong
```js
const liveEmployee = await prisma.employee.findUnique({ where: { id: ownerId } });
return decryptStringOrThrow(config, liveEmployee.bank_account_encrypted);
```
#### Correct
```js
const encrypted = paymentBatchItem.snapshot_json?.payee?.bankAccountEncrypted;
assertReviewedFinanceReveal({ batch, actor, reason, encrypted });
await writeRevealAudit();
return decryptStringOrThrow(config, encrypted);
```
## Scenario: Defensive Security Finding Summaries
### 1. Scope / Trigger
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,181 @@
# 企业银行账户与付款制单设计
## 1. 边界与交付顺序
本任务在 OA 内建立付款准备能力,不连接银行 API、不提交付款、不代替网银复核。交付按同一任务内的三个层次顺序完成:
1. 银行机构与公司付款账户主数据。
2. 员工收款信息默认脱敏、规范化银行标识和付款批次冻结快照。
3. 已复核批次的 BIFF8 制单导出、使用状态与审计。
三层共享同一迁移和权限设计,拆成独立任务会使中间版本产生无法制单或无法建批次的短暂合同,因此本轮保留为一个复杂 Trellis task,按提交批次分层验证。
## 2. 数据模型
### 2.1 BankInstitution
新增银行机构主数据:
- `id` UUID
- `code` 唯一、不可变的规范化代码;首版至少存在 `CCB`
- `name` 展示名称
- `enabled`、`archived_at`
- `created_at/by`、`updated_at/by`
银行机构由 `payment.account.manage` 管理。员工收款资料与公司付款账户都引用同一主数据,不把银行名称字符串当身份键。已被引用的代码不能修改或物理删除;停用只阻止新绑定。
### 2.2 PaymentAccount
新增独立付款账户表并关联 `LegalEntity`:
- `id`、`legal_entity_id`、`bank_institution_id`
- `account_name`
- `account_number_encrypted`
- `enabled`、`archived_at`
- `created_at/by`、`updated_at/by`
PostgreSQL 使用部分唯一索引保证同一法人主体最多一条 `enabled=true AND archived_at IS NULL` 的账户;服务端先做自然中文校验,数据库索引负责并发兜底。
已启用账户不原地覆盖账号。变更操作在同一事务内归档旧记录并创建新记录,保留历史身份;停用只改变旧记录状态。完整账号沿用现有字段加密服务,普通 DTO只计算脱敏尾号。
### 2.3 Employee 与 PaymentBatch
- `Employee` 增加可空 `bank_institution_id`。存量数据不通过自由文本自动猜测;由 HR/admin 在 UI 中确认。
- `PaymentBatch` 增加可空 `legal_entity_id`、`payment_account_id`、内部付款账户快照、制单次数、最近制单时间和操作者。
- 报销付款项的 `snapshot_json.payee` 增加内部密文和银行机构代码;既有姓名、银行显示名和脱敏尾号继续保留。
付款账户快照仅存内部使用字段:账户记录 ID、法人主体 ID、账户名称、银行机构代码/名称、原始密文和脱敏尾号。API 映射层只返回名称、银行名称和脱敏尾号。
迁移全部新增为可空,禁止回填历史完整账号。历史批次因此保持可查看、可人工付款,但制单明确失败关闭。
## 3. 权限与威胁模型
新增权限:
| 权限 | 角色 | 用途 |
| --- | --- | --- |
| `payment.account.manage` | `finance_mgr`、`admin/*` | 银行机构与公司付款账户新增、替换、停用 |
| `payment.account.read` | `finance`、`finance_mgr`、`admin/*` | 公司账户脱敏列表/详情 |
| `payment.account.read_full` | `finance`、`finance_mgr`、`admin/*` | 填理由查看公司完整账号 |
| `payment.order.export` | 明确具有 `finance` 或 `finance_mgr` 业务角色者 | 已复核批次制单;单有 `admin/*` 不作为财务业务角色代办 |
`employee.bank.manage` 不授予财务或 system_admin。员工完整收款账号查看继续使用独立读取权限,但现有 GET 默认改为脱敏;完整值通过带原因的 POST 动作获取并先写审计。
公司付款账户维护防的是误配置、错误来源账户和缺少及时知情,不新增双人审批。真实受益人欺诈边界由“HR/admin 维护员工收款资料”和“finance/finance_mgr 制单”分离控制。
## 4. 主数据 API 与页面
### 4.1 公司付款账户
新增专用路由而不复用 Feature Flag:
- 脱敏列出银行机构与付款账户。
- 创建首个账户、替换当前账户、停用当前账户。
- POST 完整账号查看动作,body 必须有 `reason`。
管理入口放在 PC `管理 -> 基础资料/财务配置 -> 企业银行账户`。默认卡片只显示法人主体、账户名称、银行名称、脱敏尾号和状态;完整账号查看、替换、停用均使用明确动作与后果确认。移动端不提供配置入口。
账户新增、替换或停用在事务内写脱敏审计,并为所有 active admin 与 CEO 创建 OA 通知;通知不带完整账号、密文或本地路径,也不要求 CEO 具备账户主数据页面权限。
### 4.2 员工收款信息
人员档案增加收款信息区:
- HR/admin 可选银行机构并录入账号。
- finance/finance_mgr 只能看银行名称和脱敏尾号。
- 员工本人在个人资料看到自己的银行名称和脱敏尾号。
- 授权角色查看完整账号时填写原因;服务端先审计后返回,前端不会把完整值缓存到长期状态或普通查询缓存。
## 5. 批次创建与冻结快照
对 `entityType=reimbursement` 且银行渠道的新批次:
1. 在同一事务锁定所选报销。
2. 解析每张报销所有人的 `legal_entity_id`,要求非空且全批次一致。
3. 查询该法人主体唯一启用的建行付款账户。
4. 要求每位收款人有 `CCB` 银行机构与可用账号密文。
5. 直接复制员工现有密文,不解密后重新加密;只为脱敏尾号做一次受控解密。
6. 冻结付款账户和每一项收款信息后创建批次。
缺法人主体、混合法人主体、缺付款账户、非建行、缺账号或解密失败时,创建失败并一次性返回自然中文处理建议。其他渠道及非报销实体沿用原流程,但不具备银行制单能力。
API 映射函数必须显式挑选安全字段。测试扫描所有普通批次/API/AP 响应,断言不存在 `v1:` 密文、完整账号或原始 `snapshot_json`。
## 6. 制单服务与接口
### 6.1 资格校验
POST 制单接口接收 `{ reason }`,仅允许明确 finance/finance_mgr 业务角色、`payment.order.export` 权限和 `reviewed` 状态。它必须拒绝:
- 历史/不完整快照
- 非报销或非银行渠道
- 公司或任一员工不是 `CCB`
- 缺完整密文、账号解密失败
- 缺金额、业务编号或付款账户
- 其他四种批次状态
校验聚合所有业务阻塞项后统一返回;权限失败仍立即拒绝,不泄露批次内容。
### 6.2 确定性行与字段
项目按 `entity_code ASC, id ASC` 显式排序。唯一 ID 仅作同业务编号异常重复时的兜底,不进入银行文件。
- 序号:从 1 开始的字符串。
- 付款方账号/名称:付款账户快照。
- 行别代码:固定字符串 `01`。
- 收款方账号/名称、开户行:收款快照。
- 联行号:空字符串。
- 客户流水号:单一 `buildCustomerSerial(batchNo, rowIndex)` 函数生成 `<batchNo>-R<NNN>`,写入前校验长度、字符集和批内唯一;`R` 显式区分批次序号与行序号。
- 金额:初版数值单元格 + 两位小数格式。
- 用途:`报销款`。
- 备注:报销单编号。
- 短信标志:字符串 `0`;手机号与附加信息留空。
禁止部分成功:任意一项失败时不生成文件、不更新制单状态、不写成功导出审计。
### 6.3 BIFF8 模板填充与下载
- 仓库保存一份经脱敏和元数据检查的固定空白 BIFF8 模板资产;它保留三个工作表、15 列表头和三行预格式化空数据行,不含真实业务数据。运行时校验固定 SHA-256,不解析上传文件,也不接受请求控制模板路径。
- Node 继续负责解密、资格校验、聚合 blockers、排序、流水号、金额和审计;固定 Python 填充器只接收已经校验的行 DTO,不承担权限或状态机判断。
- Node 通过子进程 stdin 发送 JSON,Python 从 stdout 返回 BIFF8 二进制。完整账号不得进入 argv、env、日志或 stderr;子进程使用固定解释器路径、超时和 `maxBuffer`,并校验退出码、输出大小与 OLE 魔数。
- Python 使用固定版本 `xlrd==2.0.2`、`xlwt==1.3.0`、`xlutils==2.0.0`,以 `XLWTWriter + process(XLRDReader(...))` 复制原工作簿,再按模板首个数据行样式写值。生产镜像与 CI 从同一份 `--require-hashes` requirements 安装到专用 venv。
- `xlutils.filter` 不自动保留 BIFF8 富文本 run。填充器从 `formatting_info=True` 的 `rich_text_runlist_map` 读取每个表头分段,把模板中的完整字体属性转换为 `xlwt.Font`,再显式调用 `write_rich_text`;不得只硬编码红色或重造一种近似字体。
- 模板已有三行数据样式;超过三笔时复制第一个数据行的样式并设置相同行高。金额列复制原样式后只把 `num_format_str` 改为 `0.00`,七个标识字段以字符串写入。
- 生成器不再依赖 SheetJS;`xlsx` 依赖从服务端移除。先完成审计和制单计数事务,再设置 `application/vnd.ms-excel`、安全附件名、`nosniff`、`private, no-store` 等响应头并发送。
- 不写对象存储、临时制单文件或服务器持久目录。网络中断后的已记录导出按保守口径视为已生成,用户可填写新原因重导。
## 7. 页面动作与进度
付款批次 DTO 返回脱敏付款账户摘要、`paymentOrderExportCount`、最近导出时间/人和 `canExportPaymentOrder`/阻塞原因。
- 新快照 + reviewed + 未导出:顶部主动作 `下载银行制单`;回单区仍可直接上传,人工路径不被阻断。
- 已导出且无回单:主动作 `上传银行回单`,`重新下载制单` 放入 `更多`。
- 重导确认固定展示:`重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
- 已有回单:主动作 `登记付款完成`。
- 旧批次或资料不完整:不显示可执行制单动作,展示具体原因;原人工付款动作保持。
- 详情显示 `未生成制单` 或 `已生成 N 次`;处理进度按是否导出显示已批准的两种文案。
整体详情页结构统一仍由后续独立任务负责,本任务只在当前操作组和处理进度数据中接入制单状态,不顺带重构页面骨架。
## 8. 审计、通知与隐私
审计动作至少覆盖:付款账户创建/替换/停用、公司完整账号查看、员工完整账号查看、银行制单首次导出/重导和失败阻断摘要。审计 extra 只存业务 ID、法人主体、批次号、笔数、合计金额、原因、导出次数和结果代码;不存完整账号、密文、工作簿 buffer 或原始快照。
普通日志只记录固定错误码、批次 ID/业务号和数量。依赖或解密错误返回自然中文业务错误,禁止裸 `Error` 使页面白屏,也禁止把底层加密异常文本返回客户端。
## 9. 验证与依赖门禁
- Node 单元/路由测试覆盖权限矩阵、单一启用账户并发约束、通知、审计、批次快照、历史拒绝、CCB-only、确定性排序、重导和隐私负向断言。
- 使用合成数据生成 `.xls`,由 CI 的固定 Python 环境独立读回三个工作表、15 列、七个字符串字段、金额、中文和重复导出一致性,并比较列宽、行高、字体、加粗、底色、边框、对齐及富文本 run 等模板样式签名。
- 覆盖超过三笔的扩展行、模板 hash 漂移、解释器/依赖缺失、子进程失败、输出越界和 stderr 隐私负向断言。
- 用合成非建行项证明整批失败且没有文件、制单计数或成功审计。
- Excel/WPS 人工打开验证中文、长字符串和金额;企业网银只有存在明确“仅校验”步骤时才使用虚构建行数据做最终确认。
- 固定 Python requirements 必须带完整哈希并在 CI/镜像使用 `--require-hashes`;任何包或模板哈希不符时暂停,不允许联网浮动安装或自动降级到重建工作簿。
## 10. 迁移、上线与回滚
- 迁移只新增表、可空关系、可空快照和制单统计字段,不修改或回填存量账号/批次。
- 部署后先由 admin 建立 CCB 主数据与公司付款账户,再由 HR/admin 为员工确认 CCB 机构;这两类生产写入必须另行批准,完整账号不经过聊天或仓库。
- 配置完成前暂停创建新的银行渠道付款批次,避免产生无法制单的新历史快照;已有杨俊批次继续人工付款。
- 公网 `/ready`、角色/隐私只读检查和一条合成/受控流程通过后才通知财务使用制单。
- 代码回滚保留新增表和可空字段,不删除已产生的审计、通知或制单统计;旧应用忽略新字段并继续人工付款。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,123 @@
# 企业银行账户与付款制单实施计划
## 1. 实施前门禁
- [x] 用户审查并批准 PRD、设计与本实施计划后,才运行 `task.py start`。
- [x] 运行 `trellis-before-dev`,读取 backend/frontend/hyb-oa 相关 spec 与研究材料。
- [x] 核对工作树,不提交本地空白银行模板、生成制单文件、真实账号或个人 workspace。
- [x] 记录当前 `FIELD_ENCRYPTION_KEY` 载荷兼容口径,但不读取或输出任何密钥/真实账号。
## 2. 依赖与迁移
- [x] 以精确官方 tarball安装 SheetJS `0.20.3`,锁定 package-lock 完整性;确认许可证与依赖门禁,不使用 npm registry 的 `[email protected]`。
- [x] 新增 `BankInstitution`、`PaymentAccount`,以及 Employee/PaymentBatch 可空关系、内部快照和制单统计字段。
- [x] 迁移增加同一法人主体单一 active 付款账户的 PostgreSQL 部分唯一索引;不回填存量批次或员工银行机构。
- [x] 更新 Prisma Client 并运行 `npm run prisma:validate`;核对 migration SQL 和回滚兼容说明。
## 3. 权限、账户与员工收款资料
- [x] 新增 `payment.account.manage/read/read_full` 与 `payment.order.export`,按设计分配角色;清理 `finance_mgr` 重复的 `payment.batch.manage` 条目。
- [x] 实现银行机构和付款账户脱敏 CRUD/替换/停用 API,事务内审计并通知 active admin/CEO。
- [x] 实现公司完整账号带原因查看:先写审计,再返回;负向路径不返回明文。
- [x] 扩展员工敏感资料接口:默认脱敏、HR/admin 可写银行机构与账号、finance/finance_mgr 只读;完整查看独立带原因审计。
- [x] 员工本人 API 返回银行名称与脱敏尾号,不返回完整账号。
- [x] 增加管理端企业银行账户页面和人员档案收款信息区;移动端不增加复杂配置入口。
## 4. 批次冻结快照
- [x] 对报销银行批次解析并锁定单一法人主体、唯一 active CCB 付款账户和完整员工 CCB 收款资料。
- [x] 直接复制现有账号密文进入内部快照;只为脱敏显示做受控解密,不进行解密后再加密。
- [x] 扩展付款 DTO,只映射付款账户/收款账户脱敏字段及制单统计,禁止原始 snapshot 或 `v1:` 密文外泄。
- [x] 旧批次继续显示兼容信息、允许人工付款,但永久不具备制单资格。
## 5. BIFF8 制单
- [x] 建立独立制单服务:固定 15 列和三个工作表、稳定排序、单一流水号函数、七列字符串类型、金额两位小数、中文和 CCB-only 校验。
- [x] 实现 POST 制单下载接口:finance/finance_mgr 业务角色 + 独立权限 + reviewed 状态 + 必填原因。
- [x] 在生成任何文件前聚合阻塞项;任何非 CCB、历史快照、缺资料或异常均整批失败,不生成部分文件。
- [x] 首次导出/重导在事务内写制单统计与脱敏审计,之后以内存 buffer 和 no-store 安全响应头下载,不持久化文件。
- [x] 重导确认固定展示重复付款风险原文,不暗示旧文件已作废。
## 6. 付款详情交互
- [x] DTO 提供制单次数、最近导出摘要、可制单状态与聚合阻塞原因,页面用自然中文逐项说明。
- [ ] 未导出新批次主动作显示“下载银行制单”;已导出无回单时主动作显示“上传银行回单”;当前重导为顶部次动作,待详情页结构统一任务再收进正式“更多”槽位。
- [x] 人工付款可直接上传回单,不把制单设为上传或登记付款的门禁。
- [x] 详情显示是否已生成制单;处理进度按未导出/已导出显示两种批准文案。
- [x] 不在本任务重构完整详情页骨架;与未来详情结构统一任务保持组件边界清楚。
## 7. 自动化与独立读回
- [x] 后端测试覆盖权限矩阵、active 唯一约束、账户替换/停用、通知、审计、默认脱敏和完整查看原因。
- [x] 付款批次测试覆盖单一法人主体、CCB-only、密文复制、员工资料后改不漂移、旧批次拒绝和普通响应无密文。
- [x] 制单测试覆盖 15 列、三个工作表、确定性排序、流水号校验、七列字符串、金额、中文、重导一致性、整批失败和无持久化。
- [x] 增加可复现的 `xlrd==2.0.2` 独立读回环境和 CI 步骤;合成文件仅存临时目录并自动清理。
- [ ] 前端测试覆盖角色可见性、员工本人脱敏、管理表单、导出原因、重导警告、主动作切换和旧批次人工兜底。
## 8. 全量验证与只读复核
- [x] `cd server && npm run prisma:validate`。
- [x] 对全部触及的服务端 JS 运行 `node --check`,运行付款批次/用户/导出/通知聚焦 Vitest。
- [x] `cd client && npm run typecheck && npm run build`;未新增或运行本任务前端 E2E。
- [x] 运行 BIFF8 独立读回脚本;Excel/WPS 人工打开仍待执行。
- [x] 运行依赖审计、Trellis governance、process contract、敏感扫描、`git diff --check`;依赖审计保留仓库既有 14 项告警,新增 SheetJS 未命中告警。
- [x] 对权限、状态机、迁移、密文数据流和 API 响应执行一次独立只读 review;发现的聚合阻塞项合同漂移已由主流程修复并复验。
## 9. 文档、提交与上线边界
- [x] 仅把稳定安全/付款合同更新到现有 spec、产品事实源和管理员手册,不新建无必要业务文档。
- [x] 按逻辑分批中文提交,提交信息引用 `corporate-bank-account-payment-order`。
- [x] 不在代码任务中写入任何生产银行资料;生产配置、部署和员工资料确认分别取得明确批准。
- [ ] 部署后先由 admin 配置 CCB 与公司付款账户,再由 HR/admin 确认员工 CCB;配置完成前暂停新建银行批次。
- [x] 旧杨俊批次继续人工付款并上传回单,不补写快照、不尝试制单。
- [ ] 公网门禁通过后才通知王芝玲测试制单;没有独立网银校验步骤时不做生产网银导入试验。
## 10. 本地验证记录
- 服务端全量:176 个测试文件、1186 项测试通过。
- 本任务聚焦:付款批次与 BIFF8 服务 57 项测试通过;公司账户、员工敏感资料和个人资料测试包含在全量结果中。
- 前端:TypeScript 检查和生产构建通过。
- 数据与文件:Prisma schema 校验通过;`xlrd==2.0.2` 独立读回确认三个工作表、15 列、长账号字符串、`01` 字符串、数值金额与中文。
- 治理:Trellis task 校验、governance、process contract、敏感内容扫描和 `git diff --check` 通过;文档注册审计仅保留仓库既有 22 个未登记文件警告。
- 依赖:`[email protected]` 来自官方精确 tarball且锁定;生产依赖审计仍有 14 项仓库既有告警,未执行破坏性 `npm audit fix --force`。
- 尚未完成:Excel/WPS 人工打开、前端 E2E/截图、生产数据库迁移、真实资料配置、部署、公网检查和企业网银校验。
## 11. 原模板保真修订(2026-08-03)
- [x] Excel 人工打开否决 SheetJS 重建输出:字段合同正确,但原模板列宽、行高、字体、底色、边框和对齐丢失。
- [x] 用户批准改为“固定脱敏原模板 + Python BIFF8 保真填充”,不再接受重建工作簿方案。
- [x] 提交经敏感检查的空白模板资产并固定 SHA-256;不提交 `input/` 原件或任何生成制单文件。
- [x] 用固定 Python requirements 实现 stdin JSON / stdout BIFF8 填充器,stderr 仅固定错误码。
- [x] Node 改为子进程桥接,保留现有资格、冻结快照、blockers、排序、流水号、审计与导出统计合同。
- [x] 生产镜像和 CI 使用同一带哈希 requirements 和绝对 venv 解释器;移除服务端 SheetJS 生成依赖。
- [x] 独立读回增加模板视觉签名、超过三行扩展、模板/运行时失败和隐私负向测试。
- [x] 重新运行聚焦/全量后端、前端、治理和只读复核;用合成数据交付 Excel 人工复核,不部署。
### 修订验证记录
- 受控模板仅有 15 个表头和三行预格式化空数据行;模板 SHA-256 与运行时常量一致,字符串扫描未发现账号、URL、凭据或业务数据。
- 固定 Python 环境的独立读回已确认三个工作表、15 列、账号/`01` 文本、数值金额 `0.00`,并逐格比较列宽、行高、字体/加粗、底色、边框、对齐;第四笔扩展行同样通过。
- 聚焦测试在资格门禁修订后为 70 项通过;新增覆盖 admin 非财务拒绝、四种非 reviewed 状态、历史 item 非银行 override、模板 hash、依赖缺失、子进程超时/输出越界和 stderr 隐私。
- 后端全量最终为 176 个文件、1199 项全部通过;此前 Dockerfile 缓存层文本合同失败已通过恢复独立 `RUN cd server && npm ci` 修复并纳入最终全量。
- 前端 typecheck/build、Prisma validate、流程合同、Trellis governance、敏感扫描和 diff 检查通过;文档审计仅保留仓库既有 22 个未登记文件警告。
- 本地生产镜像已真实构建通过;Alpine Python 3.14.5 成功从带哈希 requirements 安装固定依赖,镜像内生成与独立 BIFF8 读回合同通过。部署、公网与网银验证仍待单独批准。
- 当前实现已生成并独立读回一份 gitignored 合成 Excel 验收文件;用户已用 Excel 确认当前实现文件外观可接受。
- 付款/隐私只读复核在资格门禁修订后确认无 Blocker、High 或 Medium;唯一 Low(失败路径缺显式“无成功审计”断言)已补测试并复验。
## 12. Excel 富文本与流水号可读性修订(2026-08-03)
- [x] Excel 人工验收发现原模板红色必填星号在生成文件中变黑;`xlrd` 诊断确认原模板存在 rich-text run,而生成文件的 run map 为空。
- [x] 用户批准显式恢复模板富文本,并把客户流水号从 `批次号-001` 改为 `批次号-R001`;首份文件尚未导入网银,不产生历史格式兼容成本。
- [x] 从模板逐段复制全部字体属性并调用 `write_rich_text`,不硬编码近似红色字体。
- [x] 独立读回逐段断言必填星号红色、其余文字黑色,并覆盖模板其他富文本表头。
- [x] 更新流水号单元测试与独立读回,重新生成 Excel 交付人工验收。
- [x] 完成聚焦/全量验证和生产镜像真实构建;构建通过前不批准部署。
### 富文本修订验证记录
- 模板读取保持 `formatting_info=True`;填充器显式恢复所有模板富文本 run,并逐段复制字体名、字号、粗体、颜色、字符集及其他字体属性。
- 独立读回逐段比较模板与生成文件,确认必填星号使用模板红色、其余表头文字使用模板黑色;第四笔扩展行样式仍与模板首个数据行一致。
- 客户流水号已统一为 `批次号-R三位行号`,合成验收文件确认首行 `R001`、第四行 `R004`;固定验证夹具日期与 PRD 示例统一为 2026-08-03。
- 一次人工验收文件校验失败被定位为诊断阶段名滞后:空短信附加信息实际为 BIFF8 `BLANK`,真正失败来自后续固定流水号夹具日期不一致;校验器已为字段值断言设置独立阶段名,避免再次误报为空单元格问题。
- 聚焦测试 70 项、服务端全量 1199 项、宿主机独立 BIFF8 合同和已构建生产镜像内合同均通过;未部署、未写生产银行资料。
- 用户已使用 Excel 打开最终合成验收文件,确认红色必填星号和 `R001` 流水号显示符合预期,人工外观验收通过。
@@ -0,0 +1,143 @@
# 企业银行账户与付款制单
## Goal
为财务付款批次提供受控的公司付款账户、员工收款银行标识和企业网银 BIFF8 `.xls` 制单能力,使付款复核看到的收款账户与实际导出的账户同源,同时避免完整银行账号在普通页面、日志、审计或服务器文件中泄露。
## Background
- 用户提供的空白银行制单模板为传统 BIFF8 `.xls`,包含三个工作表;第一个工作表有 15 列表头,另两个工作表为空。
- 银行导入合同明确要求 `.xls`,不再把 `.xlsx` 或 CSV 作为首版候选。
- 模板必填字段为序号、付款方账号、付款方名称、收款方行别代码、收款方账号、收款方名称、金额和用途。
- 当前系统只有员工开户银行自由文本和加密账号;没有规范化银行标识、公司付款账户模型或银行制单导出。
- 当前付款批次快照只冻结收款人姓名、开户银行和脱敏尾号。历史兼容路径可用于展示,但不得用于真实划款制单。
- MVP 采用每个法人主体仅一条启用的公司付款账户;数据模型仍应允许将来存在历史账户或多账户,不把账号直接堆到 `LegalEntity`。
- 当前公司付款账户与员工工资卡均为中国建设银行;首版生产场景限定为建行本行转账。
## Scope
- 建立法人主体维度的受控企业银行账户、规范化银行机构和员工收款资料维护能力。
- 在新建银行付款批次中冻结公司付款账户与员工收款账户,普通页面与 API 仅展示脱敏摘要。
- 为符合条件的已复核建行批次生成确定性的 BIFF8 `.xls` 制单文件,并记录带原因的逐次审计。
- 保留历史批次和异常场景的人工付款、上传回单与登记完成路径;不连接银行、不自动提交或放款。
- 生产银行资料写入、部署、公网验证和企业网银导入不属于本次代码实施授权范围。
## Requirements
### R1 公司付款账户
- 新建隶属于法人主体的付款账户资料,至少包含账户名称、加密账号、规范化银行标识、银行显示名称、启用/归档状态及审计字段。
- MVP 对同一法人主体只允许一条启用记录;更换账户不得覆盖历史记录。
- 普通页面默认只展示开户行和脱敏尾号;完整账号不得随列表或普通详情响应返回。
- 公司付款账户不得放入 `.env`、Feature Flag、普通 JSON 偏好或明文配置值。
- 公司付款账户维护的主要风险是误操作、制单失效、错误公司账户出款和缺少及时知情,不将其描述为改变收款人的欺诈路径,也不为 MVP 增加审批流或双人确认。
- 公司付款账户新增、变更、停用仅授权 `finance_mgr` 与 `admin`;当前没有 active `finance_mgr` 时由 `admin` 维护。
- `finance` 只能读取公司付款账户的开户行与脱敏尾号并在授权状态下制单,不得新增、修改、停用或归档付款账户。
- `finance`、`finance_mgr` 与 `admin` 仅可通过独立“查看完整账号”动作填写原因后查看公司付款账户完整账号;每次查看必须先写脱敏审计,普通查询永不返回完整账号。
- `hr`、`manager`、`ceo` 与 `system_admin` 不获得公司付款账户主数据读取或维护权限;付款批次合法复核人仅可在该批次中看到冻结的付款账户脱敏尾号。`admin` 的全权限作为受控管理入口保留。
- 新增、变更、停用付款账户除写审计外,还必须即时通知所有 active `admin` 与 `ceo`;通知只含法人主体、动作、操作者、时间和脱敏尾号,不含完整账号或密文。
### R2 员工收款银行标识
- 员工收款资料增加从受控选项选择的规范化银行标识,不依赖“建设银行/建行/某某分行”等自由文本比较,也不直接保存“本行/他行”这种相对关系。
- 在人员档案提供“收款信息”入口:HR 与 `admin` 可维护规范化开户银行和账号;`finance`、`finance_mgr` 只读,不能编辑。
- 员工本人可查看自己的开户银行和脱敏尾号,但不直接查看完整账号、不自助修改;HR/admin/finance/finance_mgr 默认同样只看脱敏尾号。
- HR、`admin`、`finance` 与 `finance_mgr` 查看员工完整账号时必须通过独立动作填写原因并先写查看审计;现有默认返回完整账号的敏感资料读取路径必须收紧为默认脱敏。
- 首版仅支持规范化标识为 `CCB` 的公司付款账户和员工收款账户;双方均为建行时生成字符串 `01` 并保留前导零。
- 任一批次项的收款银行不是建行时,拒绝整份制单并列出对应员工及处理建议;不得生成 `02`、不得跳过该项、不得生成部分文件。
- `employee.bank.manage` 继续只授予 HR 与 `admin`,不得为了财务制单便利把该权限授予 `finance`、`finance_mgr` 或 `system_admin`。
- 真实资金欺诈的关键路径是“修改收款账号 -> 制单导出 -> 向错误受益人付款”;员工收款资料维护与财务制单必须保持角色分离。
### R3 可追溯收款快照
- 创建付款批次时直接复制员工已有的银行账号密文进入内部快照,不执行“解密后再加密”。
- 复核页面的脱敏尾号与制单导出的完整账号必须来自同一份批次快照。
- 员工资料在批次创建后发生变化,不得静默改变已复核批次的制单内容。
- 缺少加密账号快照的历史批次一律禁止制单导出,不允许实时回查员工资料兜底,并返回明确错误。
- 普通付款 DTO、AP 页面、日志和审计不得返回密文、完整账号或原始快照。
- 付款批次快照同时冻结所选公司付款账户的身份与脱敏尾号;付款复核页面应显示该脱敏尾号,使复核人能回答“从哪个账户付、付给谁、付多少”。
### R4 银行制单导出
- 从符合状态与权限要求的付款批次生成与受控空白原模板视觉和结构一致的 BIFF8 `.xls`;不得重建一个仅字段相同但丢失列宽、行高、字体、底色、边框或对齐的工作簿。
- 银行制单是推荐的效率工具而非付款完成硬门禁;旧快照、格式异常或人工付款场景仍可通过线下转账、上传回单和登记完成闭环,银行回单继续是银行渠道付款完成的证据门禁。
- 新批次已复核、资料完整且尚未制单时,顶部主动作显示“下载银行制单”;首次制单后主动作切换为“上传银行回单”,重新下载进入“更多”。已经人工付款的用户可直接在回单区域上传,不被制单状态阻断。
- 批次详情显示是否已生成制单;处理进度在未制单时显示“等待财务线下付款并上传回单”,制单后显示“已生成银行制单,等待付款完成并上传回单”。
- 仅 `reviewed`(待财务付款)批次允许制单;`draft`、`failed`、`cancelled` 与 `executed` 均禁止制单。
- `reviewed` 状态允许因文件丢失或本地操作失败重复导出,每次仍须填写原因并独立审计;重新下载前必须明确提示“重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。”重复导出的业务字段必须来自同一冻结快照,不允许临时覆盖账号、金额或用途。
- `executed` 后不得重新生成可导入网银的指令文件;付款归档依赖银行回单与付款台账,避免把归档需要和可重复付款指令混为一体。
- 原样保留三个工作表、15 列顺序与表头,以及原模板的列宽、表头/数据行行高、字体、加粗、底色、边框、对齐、换行和单元格内富文本分段;必填表头的首个 `*` 保持原模板红色,其余文字保持原模板黑色和字体属性。不得把格式伪装成 `.xls` 扩展名。
- 仓库可提交一份经脱敏检查、仅含空白表头和预格式化空行的受控模板资产;运行时只读取该固定资产,不接受用户上传模板或请求指定路径。模板哈希漂移时制单失败关闭。
- 模板预置数据行不足时,新行必须复制首个数据行的样式和行高;金额列仅覆盖为数值类型与 `0.00` 格式,不改变其他视觉属性。
- 用途固定为“报销款”;客户方流水号格式为 `<付款批次编号>-R<三位行序号>`,例如 `PB-20260803-001-R001`;备注写对应报销单编号;开户行名称取冻结快照;付款凭证号不进入制单。
- 批次项必须使用显式、稳定且带唯一兜底键的排序,禁止依赖数据库自然顺序;同一批次重复导出时每项的行序号和客户流水号必须不变。
- 客户方流水号由单一生成函数负责,并在写文件前校验长度、字符集和批次内唯一性;不允许静默截断或分散内联拼接。首轮验证若证明银行只接受纯数字,应只调整该生成函数与对应合同。
- 付款方账号、收款方账号、联行号、客户流水号、手机号、行别代码和短信通知标志七列必须以字符串单元格写入,禁止数值类型推断;短信通知标志首版写字符串 `0`。
- 金额首版按数值写入并设置两位小数格式,必须无科学计数法和精度损失;若银行安全导入验证明确拒绝,再改为两位小数字符串。
- 收款人名称、用途与备注中文不得乱码。
- 收款方联行号首版固定留空;首次安全验证使用字符串 `01`、建行虚构账号和空联行号,覆盖真实生产主场景。跨行 `02` 与联行号留待支持非建行员工时另立增量任务。
- 导出文件通过响应直接下载,不持久化到服务器存储,不记录原始制单内容;只有脱敏空白模板资产可以进入 Git。
- 完整账号只可通过子进程标准输入传给固定填充器,不得进入命令行参数、环境变量、日志或错误输出;生成的二进制只从标准输出返回,并设置明确的大小上限。
- 导出使用独立权限、必填原因和逐次审计;审计只记录操作人、批次、笔数、合计金额及原因,不记录完整账号。
- 旧快照批次、非银行渠道、缺少付款账户、缺少员工银行标识或账号、公司或员工银行不是建行的批次必须在导出前一次性列出不可制单原因。
### R5 本地与人工验证
- 使用合成数据生成 `.xls`,再用独立解析验证工作表、表头、字段类型、中文、金额和模板视觉签名;至少覆盖列宽、行高、字体、加粗、底色、边框、对齐和表头富文本分段,逐段断言必填星号红色、其余文字黑色。
- 独立解析器及版本必须进入可复现的测试环境,不能依赖某台开发机偶然安装的 `xlrd`。
- 至少使用 Excel 或 WPS 人工打开一次,确认中文和账号显示。
- 只有企业网银存在明确的“仅校验、不提交”环节时,才允许用完全虚构数据做最终格式确认;否则不进行生产网银试验。
### R6 依赖安全
- BIFF8 生成依赖必须在设计阶段确定受支持版本、来源、锁定和完整性校验方式;CI 与生产镜像必须使用同一份带哈希的固定 Python requirements。
- 不得静默接受已知高危依赖告警;若只能使用存在已知解析漏洞的版本,必须证明运行路径只生成、不解析不可信输入,并单独获得风险例外批准。
## Test Plan
- 验证 Prisma schema 与迁移,检查单一 active 付款账户约束和存量数据兼容边界。
- 运行公司付款账户、员工敏感资料、付款批次、个人资料和 BIFF8 生成服务的聚焦测试及服务端全量测试。
- 使用合成数据生成 BIFF8 文件,并由固定版本 `xlrd` 独立读回工作表、表头、字段类型、金额与中文。
- 运行前端 TypeScript 检查和生产构建,核对权限可见性、默认脱敏、带原因 reveal、制单与人工付款动作。
- 执行权限、状态机、迁移、密文流和普通 API DTO 的只读复核,并运行 Trellis、流程、文档、敏感内容与 diff 门禁。
- Excel/WPS 人工打开、生产网银仅校验、公网部署与真实银行资料配置分别作为需额外授权的上线验收,不纳入本地自动化通过声明。
## Acceptance Criteria
- [ ] 管理入口可为法人主体建立、启用、停用和归档公司付款账户,默认仅显示开户行与脱敏尾号。
- [ ] 只有 `finance_mgr` 与 `admin` 能维护公司付款账户;`finance` 只能看脱敏信息和执行授权制单,HR/经理/CEO/system_admin 无主数据访问权。
- [ ] `finance`、`finance_mgr` 与 `admin` 查看公司完整账号时必须填写原因并留下成功查看审计,普通列表和详情响应不含完整账号。
- [ ] 公司付款账户新增、变更或停用后,active admin 与 CEO 收到不含完整账号的即时通知,并存在脱敏审计。
- [ ] 同一法人主体无法同时存在两条启用的付款账户,历史账户不会被覆盖。
- [ ] 员工收款资料通过受控选项维护规范化银行标识;公司和员工均为 `CCB` 时生成字符串 `01`,任一非建行项导致整批制单失败且不遗漏任何付款项。
- [ ] 员工本人可核对自己的开户银行和脱敏尾号;HR/admin 可维护,finance/finance_mgr 只读,所有授权完整账号查看均要求原因并留审计。
- [ ] `finance`、`finance_mgr` 与 `system_admin` 均不能修改员工收款账号或银行标识,现有 HR/财务职责分离没有被放宽。
- [ ] 新付款批次冻结加密完整账号;普通 API 与页面只返回脱敏信息。
- [ ] 修改员工收款资料后,既有批次的复核尾号和制单内容保持不变。
- [ ] 付款复核页面同时显示冻结的公司付款账户脱敏尾号、收款人、收款账户尾号和金额。
- [ ] 历史批次制单请求被明确拒绝,且不会实时读取当前员工完整账号。
- [ ] 未制单批次、已制单批次和人工付款路径均可按既定主动作/次动作继续,制单状态不是上传回单或登记付款完成的硬门禁。
- [ ] 批次详情和处理进度能区分“未生成制单”与“已生成制单待回单”,便于观察实际使用覆盖率。
- [ ] 授权财务人员填写原因后可下载符合模板的 BIFF8 `.xls`,下载行为留下脱敏审计。
- [x] Excel 打开生成文件时,原模板列宽、行高、字体、加粗、底色、边框、对齐和换行保持不变;超过三笔时新增行仍复制相同数据行样式。
- [x] 所有必填表头逐段保留原模板富文本:首个星号红色、其余文字黑色;客户流水号使用 `批次号-R三位行号`,不存在两个无标签 `001` 连续造成的误读。
- [ ] 重新下载前展示重复付款风险原文;每次重导独立记录原因,但不会宣称旧文件已失效。
- [ ] 只有 `reviewed` 批次可制单;同一 `reviewed` 批次可带新原因重导,其他四种状态均被明确拒绝。
- [ ] 批次进入 `executed` 后无法重新生成银行制单,银行回单与付款台账仍可用于归档核验。
- [ ] 七个标识/开关字段读回均为字符串,长账号无精度丢失,`01` 不丢前导零,短信标志精确为字符串 `0`。
- [ ] 批次项按显式确定性顺序生成;同一冻结批次重复导出时,每张报销的行号、客户流水号和业务内容逐项一致。
- [ ] 客户流水号的长度、字符集与唯一性校验失败时制单被明确阻断,不发生截断、替换或自动降级。
- [ ] 金额读回值精确到两位小数且无科学计数法;数值/字符串类型最终以安全银行验证结果固化。
- [ ] 使用字符串 `01`、建行合成账号和空联行号完成本地验证;合成非建行项证明整批失败且不会生成部分文件。
- [ ] 缺失资料或状态不合格时,页面一次性说明全部阻塞项及处理方式。
- [ ] 自动化、本地人工打开和可选网银校验均使用合成数据,不触发真实付款。
- [ ] 模板哈希漂移、Python 或固定依赖缺失、填充器失败时返回固定安全错误,stderr、日志和测试证据均不含完整账号或原始 payload,人工付款路径不受影响。
## Out of Scope
- 工资代发、供应商付款、跨境付款和多币种制单。
- 银行 API 直连、自动提交付款、自动放款或自动复核。
- 首版支持多个同时启用的公司付款账户。
- 非建行、跨行 `02`、联行号维护和混合银行批次制单。
- 为历史付款批次补写或回填完整账号快照。
- 将真实账号、含业务数据的模板或生成的制单文件提交到仓库;经检查的脱敏空白模板资产除外。
@@ -0,0 +1,47 @@
# 企业银行账户与 BIFF8 制单研究
## 模板证据
- 用户提供的空白模板是真实 BIFF8 `.xls`(OLE Compound Document),代码页 936,无宏、无外部链接。
- 工作簿固定包含 `Sheet1`、`Sheet2`、`Sheet3`;只有 `Sheet1` 有 15 列表头,另两个工作表为空。
- 必填列为序号、付款方账号/名称、收款方行别代码、收款方账号/名称、金额、用途。
- 数据行多数使用文本格式;账号、行别代码、联行号、流水号、手机号和短信标志必须在写入值时显式使用字符串类型,不能依赖显示格式补救。
- 2026-08-03 Excel 人工打开证明 SheetJS 重建工作簿虽满足字段合同,但无法保留原模板列宽、行高、宋体/加粗、底色、边框和对齐,因此该方案被否决。
- 原始 `input/` 文件仍只作为本地分析输入;实施允许从中制作一份经脱敏、元数据和样式签名检查的受控空白模板资产进入 Git 与运行时镜像。含业务数据的模板和生成文件仍禁止提交。
## 当前数据与权限
- `Employee` 只有 `bank_name` 与 `bank_account_encrypted`,没有规范化银行机构关系;现有密文字段使用 `FIELD_ENCRYPTION_KEY` 的 `v1:` 载荷。
- `buildPayeeSnapshot()` 当前会解密员工账号后只保留脱敏尾号;付款 API 通过 `readSnapshotPayee()` 映射脱敏 DTO,历史项可用 `loadLegacyPayees()` 实时回查展示。
- 历史实时回查只适合兼容展示,不能用于制单;旧批次没有冻结完整密文时必须拒绝制单。
- `LegalEntity` 已是员工、成本中心、供应商等主数据锚点,独立 `PaymentAccount` 关系比直接向 `LegalEntity` 堆账号字段更适合保留历史和未来多账户。
- 当前角色:`finance` 有 `payment.batch.manage` 与 `employee.bank.read`;`finance_mgr` 另有 `system.settings`、`system.audit.view`;HR 有 `employee.bank.manage`;`system_admin` 没有银行读取权限;`admin` 为通配权限。
- `finance_mgr` 权限数组中 `payment.batch.manage` 重复两次,行为无害,可在本任务触及权限表时机械清理。
## 现有可复用模式
- 敏感查看/下载已有“填写原因 -> 服务端校验 -> 先写审计 -> 再返回内容”的模式,参考用户敏感资料查看、附件用途对话框和高敏附件下载合同。
- 通知可复用 `createNotificationsWithRecords()` 与按角色解析 active 员工的查询方式;付款账户通知只发送脱敏摘要。
- 财务导出已有内存生成、审计与 Blob 下载模式,但现有 `ExcelJS` 只能生成 `.xlsx`,不能满足 BIFF8 合同。
- 管理配置应进入 PC `管理` 路由下的明确子页面;复杂配置与制单不进入移动端。
## BIFF8 依赖结论
- SheetJS CE 即使读取模板并启用样式选项后再写 BIFF8,仍无法保真复制字体、底色、边框、对齐和行高,因此从运行时生成路径移除。
- 隔离验证确认 `xlrd==2.0.2`、`xlwt==1.3.0`、`xlutils==2.0.0` 使用 `XLWTWriter + process(XLRDReader(...))` 可保留原模板视觉合同;金额列可在复制样式后单独覆盖 `0.00`。
- 固定 Python 填充器只读取仓库内受控模板,只从 stdin 接受 Node 已校验的合成行 DTO,并把二进制写 stdout;不接受上传模板或外部路径。
- Python 包必须以带哈希 requirements 固定,并由 CI 与生产镜像同源安装。解释器、依赖、模板或哈希异常均 fail closed,不回退到无样式生成。
## 独立读回验证
- 当前开发机可用 `xlrd 2.0.2`,但本轮需新增最小、带哈希的固定 requirements,使生成和读回环境在 CI 与镜像可复现。
- 生成由固定 Python 模板填充器完成;合同脚本再以独立打开步骤读回并比较原模板样式签名,而不是依赖生成器返回的内部对象。
- 验证文件必须写入临时目录并在结束后清理;失败输出只报告字段名和断言,不打印合成账号之外的任何内容。
## 关键设计约束
- 当前生产主场景是建设银行公司账户向建设银行员工工资卡付款;首版只生成字符串 `01`,跨行 `02` 明确不支持。
- 任何非 `CCB` 员工项必须使整批制单失败,不能跳行或生成部分文件。
- 批次项必须显式按稳定业务编号并以唯一 ID 兜底排序,确保重复导出的行号和流水号一致。
- 制单是推荐效率工具,不是付款完成门禁;旧批次和人工付款路径继续以银行回单为完成证据。
- 首次导出后的处理进度需要可见,但完整文件不在服务端留存;批次只记录导出次数、最近导出时间/人和审计证据。
@@ -0,0 +1,26 @@
{
"id": "corporate-bank-account-payment-order",
"name": "corporate-bank-account-payment-order",
"title": "企业银行账户与付款制单",
"description": "建立法人主体企业银行账户、员工受控收款信息、冻结付款快照与建设银行 BIFF8 制单能力。",
"status": "in_progress",
"dev_type": "feature",
"scope": "公司付款账户、员工收款资料、付款批次冻结快照与建设银行 BIFF8 制单",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": "5261d2849bf3098b433f7356d22a4df2fff94cee",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "本地实现与自动化验证完成;等待 Excel/WPS 人工打开、生产迁移与配置、部署和公网/可选网银验证。",
"meta": {}
}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,40 @@
# Trellis 0.6.12 升级设计
## 1. 升级边界
升级分为两个独立但必须一致的对象:npm 全局安装的 `@mindfoldhq/trellis` CLI,以及仓库中由 `trellis update` 管理的 `.trellis/`、`.codex/`、`.agents/` 和 `AGENTS.md` 模板。`@mindfoldhq/trellis-core` 由 CLI 包依赖并与 CLI 同版本发布,不单独在项目中新增依赖。
## 2. 冲突处理策略
先用 0.6.12 CLI dry-run 确认变更,再运行 `trellis update --create-new --migrate`。自动更新文件接受上游版本;本地修改文件按下表处理,禁止 `--force`:
| 文件 | 决策 |
| --- | --- |
| `.trellis/scripts/common/task_context.py` | 合入上游 `warning` → `warning_message` 修复,保留其余本地行为 |
| `.trellis/config.yaml` | 保留 `session_auto_commit: false`、`codex.dispatch_mode: inline` 和项目注释,吸收 0.6.12 有价值的配置说明 |
| `.trellis/.gitignore` | 保留 `workspace/` 忽略;上游 sidecar 仅删除该项目规则,不接受 |
| `.trellis/workflow.md` | 保留文件头部 hyb-oa overlay;其余上游模板无实质差异时不重写 |
| `AGENTS.md` | 在既有项目规则之后加入受管 `TRELLIS:START/END` 指引块,不覆盖现有规则 |
| `trellis-channel` command reference | 仅空白差异,不制造功能性提交 |
升级后删除所有 `.new` sidecar;保留 CLI 自动创建的忽略型备份,直到验证完成。模板 hash 只由 CLI 更新,不手工编辑。
## 3. 自动更新面
接受 0.6.12 对以下运行时的上游更新:
- 活动任务清理使用已解析 session key,避免错误清除 fallback/歧义会话。
- Git helper 增加可选超时;session context 对自动 polyrepo 探测设置仓库数量与 Git 探测时间边界。
- Codex hook 独立按 UTF-8 解码输入。
- Codex implement/check/research agent 在注入输出被截断时优先读取保存的完整 hook 输出。
## 4. 状态与兼容
- 更新期间不运行 `task.py archive`,不迁移或删除任何 task。
- 当前升级 task 保持 planning,直到用户批准本设计后才 `task.py start`。
- 报销 task 仍保持 `in_progress`;升级 task 完成后可恢复该任务为当前 session 指针,不改变其业务状态。
- 若升级验证失败,先停止,不用 `--force` 修复;必要时把 CLI 降回 `0.6.9`,并从 CLI 生成的备份恢复本轮涉及的受管文件。
## 5. 安全与输出
升级不访问生产系统,不读取 Secret 或个人资料。命令输出和提交仅包含版本、固定文件名、PASS/FAIL 与公开的上游变更摘要,不记录完整本地路径。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,41 @@
# Trellis 0.6.12 升级实施计划
## 1. 实施前门禁
- [x] 用户审查并批准 PRD、设计和本实施计划。
- [x] 运行 `git status --short`,确认除当前 task 规划文件外没有未识别改动。
- [x] 记录全局 CLI、项目版本和 npm 最新稳定版,拒绝预发布版本。
- [x] 运行 0.6.12 `update --dry-run`,确认变更集合仍与规划一致;若新增覆盖范围则停止并重新审查。
## 2. 升级与合并
- [x] 用现有 npm 全局前缀安装 `@mindfoldhq/[email protected]`,确认 CLI 生效。
- [x] 执行 `trellis update --create-new --migrate`,不使用 `--force`。
- [x] 接受 7 个自动更新文件和 CLI 管理的版本/hash 更新。
- [x] 逐项合并 `task_context.py`、`config.yaml`、`AGENTS.md`;明确保留 `.gitignore`、workflow overlay 和纯空白 command-reference 当前版本。
- [x] 清除已决策的 `.new` sidecar,确认任务、spec、workspace 和开发者状态未被覆盖。
## 3. 验证
- [x] `trellis --version` 与 `.trellis/.version` 均为 `0.6.12`。
- [x] `trellis update --dry-run` 无版本升级或 pending migration,仓库无 `.new` 文件。
- [x] `python3 -m compileall -q .trellis/scripts .codex/hooks`。
- [x] `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-02-upgrade-trellis-0-6-12`。
- [x] `python3 ./.trellis/scripts/get_context.py`、`task.py current --source` 和活动任务列表正常。
- [x] `node scripts/qa/check-trellis-governance.mjs`。
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`。
- [x] `node scripts/qa/check-sensitive-content.mjs` 与 `git diff --check`。
- [x] 核对 `session_auto_commit: false`、`dispatch_mode: inline`、`workspace/` ignore、hyb-oa workflow overlay 与受管 AGENTS block。
## 4. 提交与恢复
- [x] 按逻辑提交升级文件和任务证据,中文 commit message 引用 `upgrade-trellis-0-6-12`(工作提交 `1c557e4`)。
- [x] 不部署应用,不归档报销 task;升级 task 收口后恢复报销 task 的 session 当前指针。
- [x] 回退条件未触发:全部验证通过,未留下半升级状态。
## 5. 实施证据(2026-08-02)
- 全局 CLI、项目版本与 npm 稳定版均为 `0.6.12`;稳定 dry-run 仅报告 4 个明确保留的项目 overlay,没有升级、自动更新或 migration 待执行。
- 7 个上游模板文件自动更新;`task_context.py` 与 AGENTS 受管块合入,config 的 inline/禁用自动提交和项目注释保留,workspace ignore 与 workflow overlay 未被覆盖,零 `.new` 残留。
- 首轮治理门禁发现本轮升级备份复制了本机私有平台设置并触发完整路径敏感扫描;在 Git 与固定旧 CLI 已提供回滚路径后,仅删除本轮生成的精确备份目录并复跑,治理和敏感扫描转绿。未删除历史备份。
- Python compileall、task validate/current/list、get_context、Trellis governance、process contract、敏感内容扫描和 `git diff --check` 全部 PASS;未访问生产系统或修改业务代码。
@@ -0,0 +1,54 @@
# 升级本机 Trellis 工作流至 0.6.12
## Goal
将本机全局 Trellis CLI 和 hyb-oa 项目内 Trellis 运行时从 `0.6.9` 升级到 npm 最新稳定版 `0.6.12`,获得 0.6.10–0.6.12 的上下文注入、会话清理、Git 探测和 UTF-8 修复,同时完整保留项目自己的 Trellis-first 治理规则。
## Background
- 本机 `trellis --version` 与项目 `.trellis/.version` 当前均为 `0.6.9`;`@mindfoldhq/trellis` 和 `@mindfoldhq/trellis-core` 最新稳定版均为 `0.6.12`,两包按同一版本发布。
- 使用 `0.6.12` CLI 执行只读 `trellis update --dry-run`,确认 7 个模板文件可自动更新,6 个本地定制文件需要人工决策;用户数据目录 `.trellis/tasks/`、`.trellis/spec/`、`.trellis/workspace/`受保护。
- 0.6.10–0.6.12 主要修复 Python 3.9–3.11 兼容、Codex 截断的 `SubagentStart` 输出恢复、`task.py finish` 会话清理、UTF-8 hook 输入、Git 状态探测超时/数量边界,以及 Pi/channel 运行时问题。
## Scope
- 本机 npm 全局安装的 `@mindfoldhq/trellis` CLI。
- 仓库中由 Trellis 管理的 `.trellis/` 运行时、`.codex/` agent/hook、共享 Trellis skill 参考文件和 `AGENTS.md` 受管指引。
- 当前项目的版本、模板 hash、配置兼容、活动任务解析和升级验证证据。
## Requirements
- 同时升级全局 CLI 和项目生成文件,结束后两者版本必须一致为 `0.6.12`。
- 禁止使用 `trellis update --force`;必须先 dry-run,再以 `.new` sidecar 方式处理本地冲突,并逐项人工合并。
- 必须保留 hyb-oa 的 workflow overlay、中文提交要求、事实源优先级、生产审批边界和敏感信息约束。
- 必须保留 `.trellis/config.yaml` 中 `session_auto_commit: false` 与 `codex.dispatch_mode: inline`。
- 必须保留 `.trellis/.gitignore` 对个人 `workspace/` 日志的忽略,不得把个人日志纳入版本控制。
- `.trellis/tasks/`、`.trellis/spec/`、`.trellis/workspace/`、开发者身份和活动任务状态不得丢失或被模板覆盖。
- 上游可自动更新的运行时代码、Codex agent 和 hook 文件按 0.6.12 接收;本地修改的 `task_context.py` 合入 Python 兼容修复。
- `AGENTS.md` 可加入 0.6.12 的受管 Trellis 指引块,但不得覆盖其现有 hyb-oa 规则;纯空白差异不制造无意义变更。
- 升级仅作用于本机工具链和仓库 Trellis/Codex 集成,不修改业务代码、不部署、不触碰生产配置或真实数据。
## Acceptance Criteria
- [x] `trellis --version`、项目 `.trellis/.version` 和 npm 稳定版均为 `0.6.12`。
- [x] `trellis update --dry-run` 不再提示版本升级或待执行 migration;所有本地冲突均已明确保留或合并,仓库内没有遗留 `.new` 文件。
- [x] 7 个自动更新文件包含 0.6.12 的会话清理、Git 探测、UTF-8 和 Codex 截断上下文恢复修复。
- [x] `task_context.py` 保留现有行为并合入上游 `warning_message` 兼容修复。
- [x] `session_auto_commit: false`、`codex.dispatch_mode: inline`、`workspace/` ignore 和 hyb-oa workflow overlay 均保持有效。
- [x] 当前任务、其他活动任务、spec、workspace 和开发者身份仍可由 Trellis 脚本正常解析。
- [x] Python 编译、task validate、上下文读取、Trellis governance、process contract、敏感内容扫描和 `git diff --check` 全部通过。
- [x] 变更以中文提交并引用 `upgrade-trellis-0-6-12`;不自动归档仍在运行的报销任务。
## Out of Scope
- 不升级到 `0.7.0-beta` 或其他预发布版本。
- 不切换到 channel-driven、TDD 或 sub-agent 自动派发工作流。
- 不修改项目业务 spec、产品文档、应用代码、依赖或生产环境。
- 不清理历史任务、个人 workspace 或其他 Trellis 运行证据。
## Test Plan
- 比对全局 CLI、项目版本与 npm 稳定版,并复跑 `trellis update --dry-run`。
- 编译 `.trellis/scripts` 与 Codex Python hooks,验证 task/context 生命周期命令可执行。
- 运行当前 task validate、Trellis governance、process contract、敏感内容扫描和 `git diff --check`。
- 逐项断言 `session_auto_commit: false`、`dispatch_mode: inline`、`workspace/` ignore、hyb-oa workflow overlay、AGENTS 受管块和零 `.new` 残留。
@@ -0,0 +1,26 @@
{
"id": "upgrade-trellis-0-6-12",
"name": "upgrade-trellis-0-6-12",
"title": "升级本机 Trellis 工作流至 0.6.12",
"description": "将本机 Trellis CLI 与 hyb-oa 项目工作流从 0.6.9 升级到最新稳定版 0.6.12,安全合并上游模板并保留项目定制。",
"status": "completed",
"dev_type": null,
"scope": "tooling",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-02",
"completedAt": "2026-08-02",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+22
查看文件
@@ -53,3 +53,25 @@
- 涉及权限模型、状态机、workflow、数据迁移、审批规则时,实施后必须启动只读 review,不让 reviewer 改代码。
- 公网部署、生产配置、真实数据写入、数据库重置、企业微信写入和真实付款必须另行获得明确批准。
- 若 Trellis 规则与业务文档冲突:当前 Trellis task PRD/设计 > 当前模块规约 > 业务规约和 `docs/产品设计规则.md` 中的体验事实 > Trellis spec > 历史证据;前端视觉裁决优先使用 `frontend-design` skill,历史 Issue CSV 只用于追溯。
<!-- TRELLIS:START -->
# Trellis Instructions
These instructions are for AI assistants working in this project.
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
- `.trellis/workspace/` — per-developer journals and session traces
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
- `.agents/skills/` — reusable Trellis skills
- `.codex/agents/` — optional custom subagents
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
<!-- TRELLIS:END -->
+61
查看文件
@@ -0,0 +1,61 @@
import { apiRequestData } from "./http";
export type BankInstitution = { id: string; code: string; name: string };
export type PaymentAccount = {
id: string;
legalEntity: { id: string; code: string; name: string };
bankInstitution: BankInstitution;
accountName: string;
accountNumberMasked: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export async function fetchBankInstitutions() {
return apiRequestData<{ items: BankInstitution[] }>("/api/bank-institutions");
}
export async function fetchPaymentAccounts() {
return apiRequestData<{ items: PaymentAccount[] }>("/api/payment-accounts");
}
export async function createPaymentAccount(payload: {
legalEntityId: string;
bankInstitutionId: string;
accountName: string;
accountNumber: string;
}) {
return apiRequestData<PaymentAccount>("/api/payment-accounts", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function replacePaymentAccount(accountId: string, payload: {
legalEntityId: string;
bankInstitutionId: string;
accountName: string;
accountNumber: string;
}) {
return apiRequestData<PaymentAccount>(`/api/payment-accounts/${accountId}/replace`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function disablePaymentAccount(accountId: string) {
return apiRequestData<{ id: string; disabled: boolean }>(`/api/payment-accounts/${accountId}/disable`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function revealPaymentAccount(accountId: string, reason: string) {
return apiRequestData<{ accountNumber: string }>(`/api/payment-accounts/${accountId}/reveal`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
+27
查看文件
@@ -49,6 +49,18 @@ export type PaymentBatch = {
secondaryReviewComment: string | null;
executionSummaryJson: Record<string, unknown> | null;
receiptAttachmentCount?: number;
paymentAccount: {
accountName: string;
bankName: string;
bankInstitutionCode: string;
accountNumberMasked: string;
} | null;
paymentOrderExportCount: number;
paymentOrderLastExportedAt: string | null;
paymentOrderLastExportedBy: string | null;
canExportPaymentOrder: boolean;
paymentOrderExportBlockReason: string | null;
paymentOrderExportBlockers: Array<{ code: string; entityCode?: string }>;
createdAt: string | null;
createdBy: string | null;
reviewedAt: string | null;
@@ -171,6 +183,21 @@ export async function cancelPaymentBatch(batchId: string, reason: string) {
});
}
export async function downloadPaymentOrder(batchId: string, reason: string): Promise<ApiBlobResponse> {
return apiRequestBlob(`/api/payment-batches/${batchId}/payment-order`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason }),
});
}
export async function revealPaymentBatchPayeeBank(batchId: string, itemId: string, reason: string) {
return apiRequestData<{ bankAccount: string }>(
`/api/payment-batches/${batchId}/items/${itemId}/payee-bank/reveal`,
{ method: "POST", body: JSON.stringify({ reason }) },
);
}
export async function fetchPaymentBatchReceiptAttachments(batchId: string) {
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
+5
查看文件
@@ -42,6 +42,11 @@ export type MeResponse = {
status: string;
notificationPushEnabled: boolean;
roleCodes: string[];
bankAccount: {
bankName: string | null;
bankInstitutionCode: string | null;
accountNumberMasked: string | null;
};
profileCompleteness: ProfileCompleteness;
wecomBinding?: {
externalUserId: string;
+28
查看文件
@@ -0,0 +1,28 @@
import { apiRequestData } from "./http";
export type EmployeeBankSummary = {
bankInstitution: { id: string; code: string; name: string } | null;
bankName: string | null;
bankAccountMasked: string | null;
};
export async function fetchUserSensitive(userId: string) {
return apiRequestData<EmployeeBankSummary & { idNumber?: string | null }>(`/api/users/${userId}/sensitive`);
}
export async function updateUserBank(userId: string, payload: {
bankInstitutionId: string | null;
bankAccount?: string | null;
}) {
return apiRequestData<{ id: string; updatedFields: string[] }>(`/api/users/${userId}/sensitive`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function revealUserBank(userId: string, reason: string) {
return apiRequestData<{ bankAccount: string | null }>(`/api/users/${userId}/sensitive/bank/reveal`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
+7 -1
查看文件
@@ -100,6 +100,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.view_all",
"attendance.view_all",
"employee.bank.read",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"payroll.manage",
],
finance_mgr: [
@@ -125,7 +128,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
"project.view_all",
"attendance.view_all",
@@ -133,6 +135,10 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.sync",
"payroll.manage",
"employee.bank.read",
"payment.account.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
],
administration: [
"expense.proxy_submit",
@@ -27,7 +27,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/weekly-reports", label: "周报", icon: "article" },
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
+69
查看文件
@@ -33,6 +33,8 @@ import {
type WecomDirectoryState,
} from "../api/employees";
import { fetchEmployeePerformanceTimeline } from "../api/performance";
import { fetchBankInstitutions } from "../api/paymentAccounts";
import { fetchUserSensitive, revealUserBank, updateUserBank } from "../api/userSensitive";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -187,6 +189,9 @@ export function EmployeeProfilePage() {
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
const canManageEmployeeContracts = hasPermission(permissions, "employee.contract.manage");
const canManagePayroll = hasPermission(permissions, "payroll.manage");
const canManageBank = hasPermission(permissions, "employee.bank.manage");
const canReadBank = canManageBank || hasPermission(permissions, "employee.bank.read") || me?.id === employeeId;
const canRevealBank = canManageBank || hasPermission(permissions, "employee.bank.read");
const canEditSelfProfile = me?.id === employeeId;
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
const canEditExtendedFields = canManageEmployeeProfile;
@@ -216,6 +221,21 @@ export function EmployeeProfilePage() {
emergencyContactPhone: "",
notes: "",
});
const [bankInstitutionId, setBankInstitutionId] = useState("");
const [bankAccountInput, setBankAccountInput] = useState("");
const [bankRevealReason, setBankRevealReason] = useState("");
const [revealedBankAccount, setRevealedBankAccount] = useState("");
const bankQuery = useQuery({
enabled: Boolean(employeeId) && canReadBank,
queryKey: ["employee-bank", employeeId],
queryFn: () => fetchUserSensitive(employeeId),
});
const bankInstitutionsQuery = useQuery({
enabled: canManageBank,
queryKey: ["bank-institutions"],
queryFn: fetchBankInstitutions,
});
const contractsQuery = useQuery({
enabled: Boolean(employeeId) && canManageEmployeeContracts,
@@ -265,6 +285,34 @@ export function EmployeeProfilePage() {
});
}, [profileQuery.data]);
useEffect(() => {
setBankInstitutionId(bankQuery.data?.bankInstitution?.id ?? "");
setBankAccountInput("");
setBankRevealReason("");
setRevealedBankAccount("");
}, [bankQuery.data, employeeId]);
const saveBankMutation = useMutation({
mutationFn: () => updateUserBank(employeeId, {
bankInstitutionId: bankInstitutionId || null,
...(bankAccountInput.trim() ? { bankAccount: bankAccountInput.trim() } : {}),
}),
onSuccess: async () => {
setBankAccountInput("");
await queryClient.invalidateQueries({ queryKey: ["employee-bank", employeeId] });
toast.success("收款信息已保存");
},
onError: (error) => toast.error(errorMessage(error, "保存收款信息失败")),
});
const revealBankMutation = useMutation({
mutationFn: () => revealUserBank(employeeId, bankRevealReason.trim()),
onSuccess: ({ bankAccount }) => {
setRevealedBankAccount(bankAccount ?? "");
window.setTimeout(() => revealBankMutation.reset(), 0);
},
onError: (error) => toast.error(errorMessage(error, "查看收款账号失败")),
});
useEffect(() => {
setActiveTab(requestedTab);
}, [employeeId, requestedTab]);
@@ -914,6 +962,27 @@ export function EmployeeProfilePage() {
</div>
<div className="space-y-6">
{canReadBank ? (
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">收款信息</h3>
<div className="grid gap-3 text-sm">
<div><div className="text-zinc-500">开户银行</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankName ?? "未填写"}</div></div>
<div><div className="text-zinc-500">银行账号</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankAccountMasked ?? "未填写"}</div></div>
</div>
{canManageBank ? (
<div className="space-y-3 border-t border-zinc-100 pt-4">
<div className="space-y-2"><label className="text-sm text-zinc-500">开户银行</label><select className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}><option value="">请选择</option>{(bankInstitutionsQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className="space-y-2"><label className="text-sm text-zinc-500">新银行账号</label><Input type="password" autoComplete="new-password" value={bankAccountInput} onChange={(event) => setBankAccountInput(event.target.value)} placeholder={bankQuery.data?.bankAccountMasked ? "不修改则留空" : "请输入完整账号"} /></div>
<Button type="button" disabled={saveBankMutation.isPending || !bankInstitutionId || (!bankQuery.data?.bankAccountMasked && !bankAccountInput.trim())} onClick={() => saveBankMutation.mutate()}>{saveBankMutation.isPending ? "保存中..." : "保存收款信息"}</Button>
</div>
) : null}
{canRevealBank ? (
<div className="space-y-2 border-t border-zinc-100 pt-4">
{revealedBankAccount ? <><div className="rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm break-all">{revealedBankAccount}</div><Button type="button" variant="secondary" onClick={() => { setRevealedBankAccount(""); setBankRevealReason(""); }}>隐藏完整账号</Button></> : <><label className="text-sm text-zinc-500">查看完整账号原因</label><Textarea rows={2} maxLength={500} value={bankRevealReason} onChange={(event) => setBankRevealReason(event.target.value)} /><Button type="button" variant="secondary" disabled={bankRevealReason.trim().length < 2 || revealBankMutation.isPending} onClick={() => revealBankMutation.mutate()}>查看完整账号</Button></>}
</div>
) : null}
</div>
) : null}
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
+7
查看文件
@@ -239,6 +239,13 @@ export function MobileProfilePage() {
</div>
</SectionPanel>
<SectionPanel title="收款信息">
<dl className="grid gap-2 text-sm">
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
</dl>
</SectionPanel>
<SectionPanel title="组织与角色摘要">
<details className="mobile-list-card text-sm">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
+169
查看文件
@@ -0,0 +1,169 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createPaymentAccount,
disablePaymentAccount,
fetchBankInstitutions,
fetchPaymentAccounts,
replacePaymentAccount,
revealPaymentAccount,
type PaymentAccount,
} from "../api/paymentAccounts";
import { fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
function errorText(error: unknown) {
return isApiError(error) ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : "操作失败";
}
export function PaymentAccountsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canManage = hasPermission(permissions, "payment.account.manage");
const canReveal = hasPermission(permissions, "payment.account.read_full");
const [editing, setEditing] = useState<PaymentAccount | null>(null);
const [legalEntityId, setLegalEntityId] = useState("");
const [bankInstitutionId, setBankInstitutionId] = useState("");
const [accountName, setAccountName] = useState("");
const [accountNumber, setAccountNumber] = useState("");
const [revealTarget, setRevealTarget] = useState<PaymentAccount | null>(null);
const [revealReason, setRevealReason] = useState("");
const [revealedAccount, setRevealedAccount] = useState("");
const [disableTarget, setDisableTarget] = useState<PaymentAccount | null>(null);
const accountsQuery = useQuery({ queryKey: ["payment-accounts"], queryFn: fetchPaymentAccounts });
const banksQuery = useQuery({ queryKey: ["bank-institutions"], queryFn: fetchBankInstitutions });
const legalEntitiesQuery = useQuery({
queryKey: ["master-data", "legal-entities", "payment-accounts"],
queryFn: () => fetchLegalEntities({ enabled: true }),
enabled: canManage,
});
const resetForm = () => {
setEditing(null);
setLegalEntityId("");
setBankInstitutionId("");
setAccountName("");
setAccountNumber("");
};
const refresh = () => queryClient.invalidateQueries({ queryKey: ["payment-accounts"] });
const saveMutation = useMutation({
mutationFn: () => {
const payload = { legalEntityId, bankInstitutionId, accountName, accountNumber };
return editing ? replacePaymentAccount(editing.id, payload) : createPaymentAccount(payload);
},
onSuccess: async () => {
toast.success(editing ? "企业银行账户已替换,历史账户已保留" : "企业银行账户已新增");
resetForm();
await refresh();
},
onError: (error) => toast.error(errorText(error)),
});
const disableMutation = useMutation({
mutationFn: disablePaymentAccount,
onSuccess: async () => {
setDisableTarget(null);
toast.success("企业银行账户已停用");
await refresh();
},
onError: (error) => toast.error(errorText(error)),
});
const revealMutation = useMutation({
mutationFn: () => revealPaymentAccount(revealTarget!.id, revealReason.trim()),
onSuccess: ({ accountNumber: value }) => {
setRevealedAccount(value);
window.setTimeout(() => revealMutation.reset(), 0);
},
onError: (error) => toast.error(errorText(error)),
});
const accounts = accountsQuery.data?.items ?? [];
return (
<PageScaffold>
<PageHeader title="企业银行账户" subtitle="维护法人主体的付款来源账户;完整账号仅在填写原因后临时显示。" />
<div className="mx-auto max-w-6xl space-y-5">
{canManage ? (
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="payment-account-legal-entity">法人主体</Label>
<select id="payment-account-legal-entity" className="select-std w-full" value={legalEntityId} disabled={Boolean(editing)} onChange={(event) => setLegalEntityId(event.target.value)}>
<option value="">请选择</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-bank">开户银行</Label>
<select id="payment-account-bank" className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}>
<option value="">请选择</option>
{(banksQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-name">付款方账户名称</Label>
<Input id="payment-account-name" value={accountName} onChange={(event) => setAccountName(event.target.value)} autoComplete="off" />
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-number">付款方客户账号</Label>
<Input id="payment-account-number" type="password" value={accountNumber} onChange={(event) => setAccountNumber(event.target.value)} autoComplete="new-password" />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button type="button" disabled={saveMutation.isPending || !legalEntityId || !bankInstitutionId || !accountName.trim() || !accountNumber.trim()} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : editing ? "确认替换" : "保存账户"}</Button>
{editing ? <Button type="button" variant="secondary" onClick={resetForm}>取消替换</Button> : null}
</div>
</SectionPanel>
) : null}
<SectionPanel title="账户记录">
<div className="space-y-3">
{accounts.map((account) => (
<div key={account.id} className="detail-evidence-card flex flex-wrap items-center justify-between gap-4">
<div>
<div className="font-medium text-zinc-900">{account.legalEntity.name} · {account.accountName}</div>
<div className="mt-1 text-sm text-zinc-600">{account.bankInstitution.name} · {account.accountNumberMasked ?? "-"}</div>
<div className="mt-1 text-xs text-zinc-500">{account.enabled ? "当前启用" : "历史/已停用"}</div>
</div>
<div className="flex flex-wrap gap-2">
{canReveal ? <Button type="button" variant="secondary" onClick={() => { setRevealTarget(account); setRevealReason(""); setRevealedAccount(""); }}>查看完整账号</Button> : null}
{canManage && account.enabled ? <Button type="button" variant="secondary" onClick={() => { setEditing(account); setLegalEntityId(account.legalEntity.id); setBankInstitutionId(account.bankInstitution.id); setAccountName(account.accountName); setAccountNumber(""); }}>替换</Button> : null}
{canManage && account.enabled ? <Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => setDisableTarget(account)}>停用</Button> : null}
</div>
</div>
))}
{!accountsQuery.isLoading && !accounts.length ? <div className="text-sm text-zinc-500">尚未配置企业银行账户。</div> : null}
</div>
</SectionPanel>
</div>
<Dialog open={Boolean(revealTarget)} onOpenChange={(open) => { if (!open) { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); } }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>查看完整企业银行账号</DialogTitle><DialogDescription>本次查看会记录查看人、目标账户、时间和原因。</DialogDescription></DialogHeader>
{revealedAccount ? <div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedAccount}</div> : <div className="mt-4 space-y-2"><Label htmlFor="payment-account-reveal-reason">查看原因</Label><Textarea id="payment-account-reveal-reason" value={revealReason} onChange={(event) => setRevealReason(event.target.value)} maxLength={500} rows={3} /></div>}
<DialogFooter><Button type="button" variant="secondary" onClick={() => { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); }}>关闭</Button>{!revealedAccount ? <Button type="button" disabled={revealReason.trim().length < 2 || revealMutation.isPending} onClick={() => revealMutation.mutate()}>{revealMutation.isPending ? "读取中..." : "确认查看"}</Button> : null}</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(disableTarget)} onOpenChange={(open) => { if (!open) setDisableTarget(null); }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗?</DialogDescription></DialogHeader>
<DialogFooter><Button type="button" variant="secondary" onClick={() => setDisableTarget(null)}>返回</Button><Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => { if (disableTarget) disableMutation.mutate(disableTarget.id); }}>{disableMutation.isPending ? "停用中..." : "确认停用"}</Button></DialogFooter>
</DialogContent>
</Dialog>
</PageScaffold>
);
}
export default PaymentAccountsPage;
+135 -15
查看文件
@@ -5,12 +5,14 @@ import {
cancelPaymentBatch,
createPaymentBatch,
downloadPaymentBatchReceiptAttachment,
downloadPaymentOrder,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
revealPaymentBatchPayeeBank,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
@@ -92,6 +94,28 @@ function resolveError(error: unknown) {
: "操作失败";
}
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
const messages: Record<string, string> = {
status_not_reviewed: "仅待财务付款批次可生成制单",
reimbursement_only: "仅报销付款批次支持制单",
bank_channel_required: "付款渠道不是银行转账",
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
payer_account_unavailable: "冻结的公司付款账号不可用",
payer_bank_institution_missing: "公司付款账户缺少规范化开户银行",
payer_bank_not_supported: "公司付款账户不是建设银行",
items_missing: "批次没有可制单的报销明细",
legacy_payee_snapshot_missing: "未冻结完整收款账号,仅支持人工付款",
payee_name_missing: "冻结的收款人姓名不完整",
payee_bank_name_missing: "冻结的收款开户行名称不完整",
payee_account_masked_missing: "冻结的收款账号摘要不完整",
payee_bank_institution_missing: "未冻结规范化开户银行,仅支持人工付款",
payee_bank_not_supported: "收款账户不是建设银行,首版请人工转账",
};
return `${subject}${messages[blocker.code] ?? "制单资料不完整,请联系管理员核对"}`;
}
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
@@ -100,6 +124,12 @@ export function PaymentBatchesPage() {
const canReviewBatch =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const canExportPaymentOrder =
hasPermission(permissions, "payment.order.export") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const canRevealPayeeBank =
hasPermission(permissions, "employee.bank.read") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
@@ -116,9 +146,12 @@ export function PaymentBatchesPage() {
const [reasonAction, setReasonAction] = useState<
| { kind: "cancel"; batchId: string; batchNo: string }
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
| { kind: "paymentOrder"; batchId: string; batchNo: string; reexport: boolean }
| { kind: "payeeBank"; batchId: string; batchNo: string; itemId: string; entityCode: string }
| null
>(null);
const [actionReason, setActionReason] = useState("");
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
const listQuery = useQuery({
queryKey: ["payment-batches"],
@@ -357,6 +390,37 @@ export function PaymentBatchesPage() {
},
});
const paymentOrderMutation = useMutation({
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
downloadPaymentOrder(batchId, reason),
onSuccess: async (response) => {
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("银行制单已生成,请导入企业网银后核对结果");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const revealPayeeBankMutation = useMutation({
mutationFn: ({ batchId, itemId, reason }: { batchId: string; itemId: string; reason: string }) =>
revealPaymentBatchPayeeBank(batchId, itemId, reason),
onSuccess: ({ bankAccount }) => {
setRevealedPayeeAccount(bankAccount);
window.setTimeout(() => revealPayeeBankMutation.reset(), 0);
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<ListWorkspace>
{downloadReasonDialog}
@@ -507,6 +571,18 @@ export function PaymentBatchesPage() {
复核通过
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
@@ -548,16 +624,41 @@ export function PaymentBatchesPage() {
取消
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-6">
<div className="detail-fact-grid text-sm xl:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{formatMoney(selectedBatch.totalAmount)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款账户</div><div className="detail-fact-card__value">{selectedBatch.paymentAccount ? `${selectedBatch.paymentAccount.accountName} · ${selectedBatch.paymentAccount.accountNumberMasked}` : "未冻结(仅支持人工付款)"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行制单</div><div className="detail-fact-card__value">{selectedBatch.paymentOrderExportCount > 0 ? `已生成 ${selectedBatch.paymentOrderExportCount} 次` : "尚未生成"}</div></div>
</div>
{selectedBatch.status === "reviewed" && selectedBatch.paymentOrderExportBlockers.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900">
<div className="font-medium">当前批次不能生成银行制单,可继续人工付款:</div>
<ul className="mt-1 list-disc space-y-1 pl-5">
{selectedBatch.paymentOrderExportBlockers.map((blocker, index) => (
<li key={`${blocker.code}-${blocker.entityCode ?? "batch"}-${index}`}>{paymentOrderBlockerLabel(blocker)}</li>
))}
</ul>
</div>
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
@@ -582,8 +683,10 @@ export function PaymentBatchesPage() {
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "线下付款并上传银行回单"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
@@ -598,7 +701,9 @@ export function PaymentBatchesPage() {
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
? selectedBatch.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
@@ -626,7 +731,7 @@ export function PaymentBatchesPage() {
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
@@ -896,34 +1001,43 @@ export function PaymentBatchesPage() {
if (!open) {
setReasonAction(null);
setActionReason("");
setRevealedPayeeAccount("");
}
}}
>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
<DialogDescription>
{reasonAction?.kind === "voidReceipt"
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
: reasonAction?.kind === "paymentOrder"
? reasonAction.reexport
? `批次:${reasonAction.batchNo}。重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
: `批次:${reasonAction.batchNo}。文件包含完整银行账号,仅用于导入企业网银。`
: reasonAction?.kind === "payeeBank"
? `报销单:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
: reasonAction
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
: "请填写原因。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
) : <div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>
</div>}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
@@ -931,19 +1045,25 @@ export function PaymentBatchesPage() {
}}>
返回
</Button>
<Button
{!(reasonAction?.kind === "payeeBank" && revealedPayeeAccount) ? <Button
type="button"
variant="destructive"
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
disabled={
actionReason.trim().length < 2 ||
cancelMutation.isPending ||
voidReceiptMutation.isPending
voidReceiptMutation.isPending ||
paymentOrderMutation.isPending ||
revealPayeeBankMutation.isPending
}
onClick={() => {
if (!reasonAction) return;
const reason = actionReason.trim();
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "paymentOrder") {
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "payeeBank") {
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
} else {
voidReceiptMutation.mutate({
batchId: reasonAction.batchId,
@@ -953,8 +1073,8 @@ export function PaymentBatchesPage() {
}
}}
>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
</Button>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
</Button> : null}
</DialogFooter>
</DialogContent>
</Dialog>
+1 -1
查看文件
@@ -52,7 +52,7 @@ const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.vie
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
+8
查看文件
@@ -134,6 +134,14 @@ export function ProfilePage() {
</dl>
</SectionPanel>
<SectionPanel title="收款信息">
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div><dt className="text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
<div><dt className="text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
</dl>
{!me?.profileCompleteness.hasBankAccount ? <p className="mt-3 text-sm text-amber-700">收款信息尚未填写,请联系 HR 或管理员补充。</p> : null}
</SectionPanel>
<SectionPanel title="账号安全">
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
<div>
+1
查看文件
@@ -24,6 +24,7 @@ const ITEMS: ManagementItem[] = [
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区", description: "菜单入口、模块显隐和小团队基线。", group: "系统与审计", anyOf: ["system.settings"] },
{ to: "/audit-logs", icon: "manage_search", title: "审计日志", description: "关键操作留痕和排障。", group: "系统与审计", anyOf: ["system.audit.view"] },
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
{ to: "/system/payment-accounts", icon: "account_balance", title: "企业银行账户", description: "维护付款来源账户、脱敏查看与变更留痕。", group: "功能管理", anyOf: ["payment.account.read", "payment.account.manage"] },
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
];
+11 -1
查看文件
@@ -48,6 +48,7 @@ import {
PayrollPage,
PerformancePage,
PaymentBatchesPage,
PaymentAccountsPage,
PeoplePage,
PeopleStatsPage,
PortalAnnouncementsPage,
@@ -582,6 +583,15 @@ const routeElements = (
}
/>
<Route
path="system/payment-accounts"
element={
<RequirePermission anyOf={["payment.account.read", "payment.account.manage"]}>
{withRouteFallback(<PaymentAccountsPage />)}
</RequirePermission>
}
/>
<Route
path="system/notification-channels"
element={
@@ -603,7 +613,7 @@ const routeElements = (
<Route
path="system/settings"
element={
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "payment.account.read", "payment.account.manage"]}>
{withRouteFallback(<SystemSettingsPage />)}
</RequirePermission>
}
@@ -0,0 +1 @@
export { PaymentAccountsPage as default } from "../../pages/PaymentAccountsPage";
+1
查看文件
@@ -30,6 +30,7 @@ export const NotificationChannelsPage = lazy(() => import("./NotificationChannel
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PaymentAccountsPage = lazy(() => import("./PaymentAccountsPage"));
export const PeoplePage = lazy(() => import("./PeoplePage"));
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
export const PerformancePage = lazy(() => import("./PerformancePage"));
+5 -1
查看文件
@@ -2,7 +2,7 @@ FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache bash curl
RUN apk add --no-cache bash curl python3 py3-pip
FROM base AS client_deps
COPY client/package.json client/package-lock.json ./client/
@@ -16,7 +16,10 @@ RUN cd client && npm run build
FROM base AS server_deps
COPY server/package.json server/package-lock.json ./server/
COPY server/requirements/payment-order-biff8.txt ./server/requirements/payment-order-biff8.txt
COPY server/prisma ./server/prisma
RUN python3 -m venv /opt/payment-order-venv \
&& /opt/payment-order-venv/bin/pip install --disable-pip-version-check --no-cache-dir --require-hashes -r server/requirements/payment-order-biff8.txt
RUN cd server && npm ci
FROM server_deps AS server_build
@@ -28,4 +31,5 @@ COPY --from=client_build /app/client/dist ./client/dist
WORKDIR /app/server
ENV NODE_ENV=production
ENV PAYMENT_ORDER_PYTHON_BIN=/opt/payment-order-venv/bin/python
CMD ["npm", "start"]
+3 -1
查看文件
@@ -58,7 +58,9 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
项目模块 V1 只做轻项目台账:项目状态、负责人/成员、里程碑/任务、风险、交付记录,以及关联报销、采购、合同和付款。项目页面不做完整项目管理软件,不承接甘特图、复杂任务依赖、工时、资源排期、OKR、项目组合管理或大型协同平台;项目页主要负责业务归集、风险提醒和跳转到对应业务详情。
财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要。付款、应付账款和票据页面不做完整财务软件,不承接银行直连真实支付、银行流水自动导入、总账、凭证生成、自动对账或税务申报;真实付款继续在线下银行或既有财务/代账流程完成,OA 只记录状态、证据和交接摘要。OA 不接 ERP,未来也不规划 ERP 接入。
财务台账 V1 只做付款准备、付款批次、企业网银导入制单、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要。制单只是效率工具,不代表付款成功,也不是登记付款完成的门禁;银行回单仍是银行渠道付款的硬证据。付款、应付账款和票据页面不做完整财务软件,不承接银行直连真实支付、银行流水自动导入、总账、会计凭证生成、自动对账或税务申报;真实付款继续在线下银行或既有财务/代账流程完成,OA 只记录状态、证据和交接摘要。OA 不接 ERP,未来也不规划 ERP 接入。
员工收款信息默认只展示开户银行和脱敏尾号,由 HR/管理员维护,员工本人可核对但不自助修改;财务因转账需要查看完整账号时必须填写原因并留审计。付款复核与后续人工转账/制单使用同一份批次冻结收款快照,员工资料事后变化不得漂移已复核批次。公司企业银行账户独立挂在法人主体下,普通财务只读,财务负责人/管理员维护;新增、替换和停用须留审计并通知管理员与负责人。
付款相关动作集中在 `付款` / 财务处理详情执行。报销、采购、合同、项目等列表和普通详情只显示付款状态、回单状态、`查看付款`、`补回单` 或进入付款记录入口,不直接放 `标记已付款`、`取消付款`、`批量付款`、`执行付款`;付款动作前必须核对金额、对象、线下付款说明、证据和回单。
+8
查看文件
@@ -747,3 +747,11 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
- 列表页:`PageHeader + FilterBar + 主列表`
- 详情页:`概览 -> 执行 -> 留痕 -> 归档/合规`
- 若某页面出现两个以上竞争级主按钮,应视为设计回退并在上线前修正。
## 20. 企业银行账户与收款信息
- 部署本能力后,先由管理员在“管理 → 企业银行账户”配置法人主体的建设银行付款账户,再由 HR/管理员在员工档案“收款信息”中选择建设银行并录入工资卡账号;不要通过聊天、工单或仓库传递完整账号。
- 同一法人主体只保留一条当前启用账户。换卡使用“替换”,系统归档旧记录;不再使用时选择“停用”,不要覆盖历史记录。
- 默认页面只显示脱敏尾号。查看公司或员工完整账号必须填写业务原因,系统记录查看审计;查看完成后及时关闭或隐藏。
- 新建银行付款批次会冻结付款方和收款方账号。旧批次没有冻结密文,永久不支持制单,仍按人工转账、上传回单、登记付款完成的路径处理。
- 银行制单仅支持建设银行本行,生成真实 BIFF8 `.xls`。重新下载不会作废旧文件;若旧文件已导入网银,不得重复提交。
- 制单下载不是付款证据。企业网银付款完成后仍须上传银行回单,才能登记付款完成。
@@ -0,0 +1,78 @@
-- Create bank institution master data.
CREATE TABLE "bank_institutions" (
"id" UUID NOT NULL,
"code" VARCHAR(16) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "bank_institutions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "bank_institutions_code_key" ON "bank_institutions"("code");
CREATE INDEX "idx_bank_institutions_status" ON "bank_institutions"("enabled", "archived_at");
-- Create legal-entity-owned company payment accounts.
CREATE TABLE "payment_accounts" (
"id" UUID NOT NULL,
"legal_entity_id" UUID NOT NULL,
"bank_institution_id" UUID NOT NULL,
"account_name" VARCHAR(120) NOT NULL,
"account_number_encrypted" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "payment_accounts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "idx_payment_accounts_legal_entity_status" ON "payment_accounts"("legal_entity_id", "enabled", "archived_at");
CREATE INDEX "idx_payment_accounts_bank_institution" ON "payment_accounts"("bank_institution_id");
CREATE UNIQUE INDEX "payment_accounts_one_active_per_legal_entity_ux"
ON "payment_accounts"("legal_entity_id")
WHERE "enabled" = true AND "archived_at" IS NULL;
ALTER TABLE "payment_accounts"
ADD CONSTRAINT "payment_accounts_legal_entity_id_fkey"
FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "payment_accounts"
ADD CONSTRAINT "payment_accounts_bank_institution_id_fkey"
FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Employee bank identity is intentionally nullable; existing free text is not guessed.
ALTER TABLE "employees" ADD COLUMN "bank_institution_id" UUID;
CREATE INDEX "idx_employees_bank_institution" ON "employees"("bank_institution_id");
ALTER TABLE "employees"
ADD CONSTRAINT "employees_bank_institution_id_fkey"
FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Historical payment batches remain nullable and cannot be order-exported.
ALTER TABLE "payment_batches"
ADD COLUMN "legal_entity_id" UUID,
ADD COLUMN "payment_account_id" UUID,
ADD COLUMN "payment_account_snapshot_json" JSONB,
ADD COLUMN "payment_order_export_count" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "payment_order_last_exported_at" TIMESTAMP(3),
ADD COLUMN "payment_order_last_exported_by" UUID;
CREATE INDEX "idx_payment_batches_legal_entity" ON "payment_batches"("legal_entity_id");
CREATE INDEX "idx_payment_batches_payment_account" ON "payment_batches"("payment_account_id");
CREATE INDEX "idx_payment_batches_order_exported_by" ON "payment_batches"("payment_order_last_exported_by");
ALTER TABLE "payment_batches"
ADD CONSTRAINT "payment_batches_legal_entity_id_fkey"
FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "payment_batches"
ADD CONSTRAINT "payment_batches_payment_account_id_fkey"
FOREIGN KEY ("payment_account_id") REFERENCES "payment_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "payment_batches"
ADD CONSTRAINT "payment_batches_payment_order_last_exported_by_fkey"
FOREIGN KEY ("payment_order_last_exported_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+60
查看文件
@@ -598,6 +598,7 @@ model Employee {
bank_account_encrypted String
bank_name String
bank_institution_id String? @db.Uuid
id_number_encrypted String?
password_hash String
@@ -625,6 +626,7 @@ model Employee {
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
bank_institution BankInstitution? @relation(fields: [bank_institution_id], references: [id], onDelete: SetNull)
direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict)
subordinates Employee[] @relation("EmployeeToManager")
@@ -642,6 +644,7 @@ model Employee {
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
payment_batches_last_exported PaymentBatch[] @relation("PaymentOrderLastExportedBy")
leave_requests LeaveRequest[]
leave_ledgers LeaveLedger[]
wecom_checkin_records WecomCheckinRecord[]
@@ -724,6 +727,7 @@ model Employee {
@@index([department_id])
@@index([legal_entity_id], map: "idx_employees_legal_entity")
@@index([bank_institution_id], map: "idx_employees_bank_institution")
@@index([direct_manager_id])
@@map("employees")
}
@@ -985,12 +989,56 @@ model LegalEntity {
card_authorizations CardAuthorization[]
asset_cards AssetCard[]
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
payment_accounts PaymentAccount[]
payment_batches PaymentBatch[]
@@index([enabled, archived_at], map: "idx_legal_entities_status")
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
@@map("legal_entities")
}
model BankInstitution {
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(16)
name String @db.VarChar(100)
enabled Boolean @default(true)
archived_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employees Employee[]
payment_accounts PaymentAccount[]
@@index([enabled, archived_at], map: "idx_bank_institutions_status")
@@map("bank_institutions")
}
model PaymentAccount {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
bank_institution_id String @db.Uuid
account_name String @db.VarChar(120)
account_number_encrypted String
enabled Boolean @default(true)
archived_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
payment_batches PaymentBatch[]
@@index([legal_entity_id, enabled, archived_at], map: "idx_payment_accounts_legal_entity_status")
@@index([bank_institution_id], map: "idx_payment_accounts_bank_institution")
@@map("payment_accounts")
}
model CostCenter {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
@@ -1856,6 +1904,12 @@ model PaymentBatch {
review_comment String?
secondary_review_comment String?
execution_summary_json Json?
legal_entity_id String? @db.Uuid
payment_account_id String? @db.Uuid
payment_account_snapshot_json Json?
payment_order_export_count Int @default(0)
payment_order_last_exported_at DateTime?
payment_order_last_exported_by String? @db.Uuid
created_at DateTime @default(now())
created_by String? @db.Uuid
@@ -1873,8 +1927,14 @@ model PaymentBatch {
items PaymentBatchItem[]
receipt_attachments PaymentBatchReceiptAttachment[]
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
payment_account PaymentAccount? @relation(fields: [payment_account_id], references: [id], onDelete: SetNull)
payment_order_last_exported_by_employee Employee? @relation("PaymentOrderLastExportedBy", fields: [payment_order_last_exported_by], references: [id], onDelete: SetNull)
@@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created")
@@index([legal_entity_id], map: "idx_payment_batches_legal_entity")
@@index([payment_account_id], map: "idx_payment_batches_payment_account")
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
@@map("payment_batches")
}
@@ -0,0 +1,3 @@
xlrd==2.0.2 --hash=sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9
xlwt==1.3.0 --hash=sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e
xlutils==2.0.0 --hash=sha256:b56640862c030e9d53104e7f1d750135fdfabf6bf55e425e5ac40fdee9dbaeb9
@@ -0,0 +1,59 @@
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { encryptStringOrThrow } = require("../src/services/crypto");
const { generatePaymentOrderXls } = require("../src/services/paymentOrderXls");
const fieldEncryptionKey = Buffer.alloc(32, 7).toString("base64");
const config = { fieldEncryptionKey };
const encrypt = (value) => encryptStringOrThrow(config, value);
const syntheticAccounts = [
"6222000000000012345",
"6222000000000023456",
"6222000000000034567",
"6222000000000045678",
];
const generated = generatePaymentOrderXls(config, {
batch_no: "PB-20260803-001",
payment_account_snapshot_json: {
bankInstitutionCode: "CCB",
accountName: "合成测试公司",
bankName: "中国建设银行",
accountNumberEncrypted: encrypt("6217000000000012345"),
},
items: syntheticAccounts.map((account, index) => ({
id: String(index + 1),
entity_code: `BX-20260802-${String(index + 1).padStart(4, "0")}`,
amount: `${index + 1}.20`,
snapshot_json: {
payee: {
name: `合成员工${index + 1}`,
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
bankAccountEncrypted: encrypt(account),
},
},
})),
});
const pythonBin = process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3";
const verifier = spawnSync(pythonBin, [path.resolve(__dirname, "verify-payment-order-xls.py")], {
input: generated.buffer,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: 15_000,
env: {
PATH: path.isAbsolute(pythonBin) ? path.dirname(pythonBin) : "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
PYTHONDONTWRITEBYTECODE: "1",
PYTHONNOUSERSITE: "1",
},
});
if (verifier.status !== 0 || verifier.error) {
process.stderr.write(verifier.stderr || "[payment-order-xls][FAIL] verifier unavailable\n");
process.exitCode = 1;
} else {
process.stdout.write(verifier.stdout);
}
+183
查看文件
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Independently verify generated BIFF8 data and visual template contracts."""
from __future__ import annotations
from pathlib import Path
import sys
try:
import xlrd
except ImportError:
sys.stderr.write("[payment-order-xls][FAIL] verifier dependency unavailable\n")
raise SystemExit(1)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "src" / "assets" / "payment-order" / "ccb-payment-order-template.xls"
EXPECTED_HEADERS = [
"*序号",
"*付款方客户账号",
"*付款方账户名称",
"*收款方行别代码\n(01-本行 02-国内他行)",
"*收款方客户账号",
"*收款方账户名称",
"收款方开户行名称",
"收款方联行号",
"客户方流水号",
"*金额",
"*用途",
"备注",
"是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)",
"收款人手机号码",
"短信通知附加信息",
]
REQUIRED_HEADER_COLUMNS = (0, 1, 2, 3, 4, 5, 9, 10)
def font_signature(font: xlrd.formatting.Font) -> tuple:
return (
font.name,
font.height,
font.bold,
font.italic,
font.struck_out,
font.outline,
font.shadow,
font.colour_index,
font.weight,
font.escapement,
font.underline_type,
font.family,
font.character_set,
)
def rich_text_signature(book: xlrd.book.Book, sheet: xlrd.sheet.Sheet, row: int, column: int) -> list[tuple]:
text = sheet.cell_value(row, column)
runs = sheet.rich_text_runlist_map.get((row, column), [])
if not runs:
return []
signature = []
if runs[0][0] > 0:
base_font_index = book.xf_list[sheet.cell_xf_index(row, column)].font_index
signature.append((0, runs[0][0], text[:runs[0][0]], font_signature(book.font_list[base_font_index])))
for index, (offset, font_index) in enumerate(runs):
end = runs[index + 1][0] if index + 1 < len(runs) else len(text)
signature.append((offset, end, text[offset:end], font_signature(book.font_list[font_index])))
return signature
def visual_signature(book: xlrd.book.Book, sheet: xlrd.sheet.Sheet, row: int, column: int) -> tuple:
xf = book.xf_list[sheet.cell_xf_index(row, column)]
font = book.font_list[xf.font_index]
alignment = xf.alignment
background = xf.background
border = xf.border
return (
font.name,
font.height,
font.bold,
font.italic,
font.underline_type,
font.colour_index,
alignment.hor_align,
alignment.vert_align,
alignment.text_wrapped,
alignment.rotation,
alignment.shrink_to_fit,
background.fill_pattern,
background.pattern_colour_index,
background.background_colour_index,
border.left_line_style,
border.right_line_style,
border.top_line_style,
border.bottom_line_style,
border.left_colour_index,
border.right_colour_index,
border.top_colour_index,
border.bottom_colour_index,
)
def main() -> int:
stage = "read"
try:
generated_bytes = sys.stdin.buffer.read(16 * 1024 * 1024 + 1)
assert len(generated_bytes) <= 16 * 1024 * 1024
template = xlrd.open_workbook(str(TEMPLATE_PATH), formatting_info=True)
generated = xlrd.open_workbook(file_contents=generated_bytes, formatting_info=True)
stage = "structure"
assert generated.sheet_names() == ["Sheet1", "Sheet2", "Sheet3"]
assert [(sheet.nrows, sheet.ncols) for sheet in generated.sheets()] == [(5, 15), (0, 0), (0, 0)]
source_sheet = template.sheet_by_index(0)
sheet = generated.sheet_by_index(0)
stage = "headers"
assert sheet.row_values(0) == EXPECTED_HEADERS
stage = "header_rich_text"
assert set(sheet.rich_text_runlist_map) == set(source_sheet.rich_text_runlist_map)
for row_column in source_sheet.rich_text_runlist_map:
row, column = row_column
assert rich_text_signature(generated, sheet, row, column) == rich_text_signature(
template,
source_sheet,
row,
column,
)
for column in REQUIRED_HEADER_COLUMNS:
parts = rich_text_signature(generated, sheet, 0, column)
assert parts[0][2] == "*" and parts[0][3][7] == 10
assert "".join(part[2] for part in parts[1:]) == EXPECTED_HEADERS[column][1:]
assert all(part[3][7] == 8 for part in parts[1:])
stage = "column_widths"
stage = "explicit_column_widths"
assert {column: info.width for column, info in sheet.colinfo_map.items()} == {
column: info.width for column, info in source_sheet.colinfo_map.items()
}
stage = "header_height"
assert sheet.rowinfo_map[0].height == source_sheet.rowinfo_map[0].height
for row in range(1, 5):
stage = "data_row_height"
assert sheet.rowinfo_map[row].height == source_sheet.rowinfo_map[1].height
for column in range(15):
stage = "visual_style"
assert visual_signature(generated, sheet, row, column) == visual_signature(template, source_sheet, 1, column)
stage = "cell_types"
for row in range(1, 5):
for column in (0, 1, 2, 3, 4, 5, 6, 8, 10, 11, 12):
stage = f"text_cell_{column}"
assert sheet.cell_type(row, column) == xlrd.XL_CELL_TEXT
for column in (7, 13, 14):
stage = f"empty_cell_{column}"
assert sheet.cell_type(row, column) in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK)
stage = "payer_account_value"
assert sheet.cell_value(1, 1) == "6217000000000012345"
stage = "bank_type_value"
assert sheet.cell_value(1, 3) == "01"
stage = "payee_account_value"
assert sheet.cell_value(1, 4) == "6222000000000012345"
stage = "first_customer_serial_value"
assert sheet.cell_value(1, 8) == "PB-20260803-001-R001"
stage = "last_customer_serial_value"
assert sheet.cell_value(4, 8) == "PB-20260803-001-R004"
stage = "payee_name_value"
assert sheet.cell_value(1, 5) == "合成员工1"
stage = "purpose_value"
assert sheet.cell_value(1, 10) == "报销款"
stage = "sms_flag_value"
assert sheet.cell_value(1, 12) == "0"
for row in range(1, 5):
assert sheet.cell_type(row, 9) == xlrd.XL_CELL_NUMBER
stage = "amount_format"
assert generated.format_map[generated.xf_list[sheet.cell_xf_index(row, 9)].format_key].format_str == "0.00"
sys.stdout.write("[payment-order-xls][PASS] original BIFF8 template data and visual contracts verified\n")
return 0
except Exception:
sys.stderr.write(f"[payment-order-xls][FAIL] BIFF8 template contract mismatch: {stage}\n")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+2
查看文件
@@ -49,6 +49,7 @@ const budgetControlRulesRoutes = require("./routes/budgetControlRules");
const feeControlRoutes = require("./routes/feeControl");
const riskFlagsRoutes = require("./routes/riskFlags");
const paymentBatchesRoutes = require("./routes/paymentBatches");
const paymentAccountsRoutes = require("./routes/paymentAccounts");
const apOperationsRoutes = require("./routes/apOperations");
const portalRoutes = require("./routes/portal");
const searchRoutes = require("./routes/search");
@@ -373,6 +374,7 @@ async function buildApp(dependencies = {}) {
await app.register(feeControlRoutes, { prefix: "/api" });
await app.register(riskFlagsRoutes, { prefix: "/api" });
await app.register(paymentBatchesRoutes, { prefix: "/api" });
await app.register(paymentAccountsRoutes, { prefix: "/api" });
await app.register(apOperationsRoutes, { prefix: "/api" });
await app.register(portalRoutes, { prefix: "/api" });
await app.register(searchRoutes, { prefix: "/api" });
二进制文件未显示。
+7 -1
查看文件
@@ -189,6 +189,9 @@ const ROLE_PERMISSIONS = {
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"expense.view_all",
"procurement.view_all",
"asset.view_all",
@@ -210,6 +213,10 @@ const ROLE_PERMISSIONS = {
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"payment.account.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"expense.view_all",
"expense.finance.override_duplicate",
"procurement.view_all",
@@ -219,7 +226,6 @@ const ROLE_PERMISSIONS = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
"project.view_all",
"attendance.view_all",
+20 -1
查看文件
@@ -3,6 +3,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const NOTIFICATION_PREFERENCES_SCHEMA = z
.object({
@@ -30,7 +31,9 @@ function parseNotificationPreferences(body) {
function buildProfileCompleteness(employee) {
const hasEmergencyContact = Boolean(employee?.emergency_contact_name && employee?.emergency_contact_phone);
const hasBankAccount = Boolean(employee?.bank_account_encrypted && employee?.bank_name);
const hasBankAccount = Boolean(
employee?.bank_account_encrypted && (employee?.bank_institution || employee?.bank_name),
);
const hasAvatar = Boolean(employee?.avatar_path);
const hasIdNumber = Boolean(employee?.id_number_encrypted);
const completedCount = [hasEmergencyContact, hasBankAccount, hasAvatar, hasIdNumber].filter(Boolean).length;
@@ -66,6 +69,7 @@ async function meRoutes(fastify) {
emergency_contact_phone: true,
bank_account_encrypted: true,
bank_name: true,
bank_institution: { select: { id: true, code: true, name: true } },
avatar_path: true,
id_number_encrypted: true,
notification_push_enabled: true,
@@ -76,6 +80,16 @@ async function meRoutes(fastify) {
const roleCodes = (employee?.roles ?? []).map((r) => r.role_code);
const profileCompleteness = buildProfileCompleteness(employee);
let bankAccountMasked = null;
try {
const bankAccount = employee?.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
bankAccountMasked = bankAccount ? maskBankAccount(bankAccount) : null;
} catch (error) {
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500 });
}
return sendData(reply, request, {
id: employee?.id ?? request.employee.id,
employeeNo: employee?.employee_no ?? "",
@@ -87,6 +101,11 @@ async function meRoutes(fastify) {
status: employee?.status ?? "active",
notificationPushEnabled: employee?.notification_push_enabled ?? true,
roleCodes,
bankAccount: {
bankName: employee?.bank_institution?.name ?? employee?.bank_name ?? null,
bankInstitutionCode: employee?.bank_institution?.code ?? null,
accountNumberMasked: bankAccountMasked,
},
profileCompleteness,
wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null,
});
+419
查看文件
@@ -0,0 +1,419 @@
const { z } = require("zod");
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { createNotifications } = require("../services/notifications");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const UUID_SCHEMA = z.string().uuid();
const ACCOUNT_BODY_SCHEMA = z.object({
legalEntityId: UUID_SCHEMA,
bankInstitutionId: UUID_SCHEMA,
accountName: z.string().trim().min(1).max(120),
accountNumber: z.string().trim().regex(/^\d{6,32}$/),
}).strict();
const REVEAL_BODY_SCHEMA = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const BANK_BODY_SCHEMA = z.object({
code: z.string().trim().min(2).max(16).regex(/^[A-Za-z0-9_]+$/),
name: z.string().trim().min(1).max(100),
}).strict();
const BANK_RENAME_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100) }).strict();
function validationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parse(schema, value) {
const result = schema.safeParse(value ?? {});
if (!result.success) {
throw validationError(result.error.flatten());
}
return result.data;
}
function parseId(params) {
const result = UUID_SCHEMA.safeParse(params?.id);
if (!result.success) {
throw validationError({ id: "invalid_uuid" });
}
return result.data;
}
function assertAnyPermission(fastify, request, permissions) {
if (!permissions.some((permission) => fastify.hasPermission(request.employee, permission))) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function decryptAccount(fastify, encrypted) {
try {
return decryptStringOrThrow(fastify.config, encrypted);
} catch (error) {
fastify.log.error({ err: error }, "payment account decryption failed");
throw new AppError({ code: "PAYMENT_ACCOUNT_DECRYPT_FAILED", message: "付款账户读取失败", statusCode: 500 });
}
}
function mapAccount(fastify, record) {
const accountNumber = decryptAccount(fastify, record.account_number_encrypted);
return {
id: record.id,
legalEntity: {
id: record.legal_entity.id,
code: record.legal_entity.code,
name: record.legal_entity.name,
},
bankInstitution: {
id: record.bank_institution.id,
code: record.bank_institution.code,
name: record.bank_institution.name,
},
accountName: record.account_name,
accountNumberMasked: maskBankAccount(accountNumber),
enabled: Boolean(record.enabled && !record.archived_at),
archivedAt: record.archived_at?.toISOString?.() ?? null,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
const ACCOUNT_INCLUDE = {
legal_entity: { select: { id: true, code: true, name: true } },
bank_institution: { select: { id: true, code: true, name: true } },
};
async function ensureReferences(tx, body) {
const [legalEntity, bankInstitution] = await Promise.all([
tx.legalEntity.findFirst({
where: { id: body.legalEntityId, enabled: true, archived_at: null },
select: { id: true, name: true },
}),
tx.bankInstitution.findFirst({
where: { id: body.bankInstitutionId, enabled: true, archived_at: null },
select: { id: true, code: true, name: true },
}),
]);
if (!legalEntity) throw validationError({ legalEntityId: "not_available" }, 409);
if (!bankInstitution) throw validationError({ bankInstitutionId: "not_available" }, 409);
return { legalEntity, bankInstitution };
}
async function notifyAccountChange(tx, account, action) {
const recipients = await tx.employeeRole.findMany({
where: {
role_code: { in: ["admin", "ceo"] },
employee: { status: "active" },
},
select: { employee_id: true },
});
const actionLabel = action === "created" ? "新增" : action === "replaced" ? "替换" : "停用";
await createNotifications(tx, {
recipientEmployeeIds: recipients.map((item) => item.employee_id),
type: "PAYMENT_ACCOUNT_CHANGED",
title: `企业银行账户已${actionLabel}`,
body: `${account.legalEntity.name}的企业银行账户已${actionLabel},当前尾号为${account.accountNumberMasked?.slice(-4) ?? "未提供"}。`,
targetType: "payment_account",
targetId: account.id,
});
}
function handleWriteError(error) {
if (error?.code === "P2002") {
throw new AppError({
code: "PAYMENT_ACCOUNT_ACTIVE_CONFLICT",
message: "该法人主体已有启用的企业银行账户,请使用替换操作",
statusCode: 409,
});
}
throw error;
}
async function paymentAccountsRoutes(fastify) {
fastify.get("/bank-institutions", { preHandler: fastify.authenticate }, async (request, reply) => {
assertAnyPermission(fastify, request, [
"payment.account.read",
"payment.account.manage",
"employee.bank.read",
"employee.bank.manage",
]);
const rows = await fastify.prisma.bankInstitution.findMany({
where: { enabled: true, archived_at: null },
orderBy: [{ name: "asc" }, { code: "asc" }],
select: { id: true, code: true, name: true },
});
return sendData(reply, request, { items: rows });
});
fastify.post(
"/bank-institutions",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const body = parse(BANK_BODY_SCHEMA, request.body);
let bank;
try {
bank = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.bankInstitution.create({
data: {
code: body.code.toUpperCase(),
name: body.name,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true, code: true, name: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BANK_INSTITUTION_CREATED",
targetType: "bank_institution",
targetId: created.id,
extra: { code: created.code },
}),
});
return created;
});
} catch (error) {
if (error?.code === "P2002") {
throw validationError({ code: "already_exists" }, 409);
}
throw error;
}
return sendData(reply.code(201), request, bank);
},
);
fastify.patch(
"/bank-institutions/:id",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const bankId = parseId(request.params);
const body = parse(BANK_RENAME_BODY_SCHEMA, request.body);
const bank = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.bankInstitution.findUnique({
where: { id: bankId },
select: { id: true, code: true, name: true, archived_at: true },
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
const updated = await tx.bankInstitution.update({
where: { id: bankId },
data: { name: body.name, updated_by: request.employee.id },
select: { id: true, code: true, name: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BANK_INSTITUTION_RENAMED",
targetType: "bank_institution",
targetId: bankId,
extra: { code: current.code, beforeName: current.name, afterName: updated.name },
}),
});
return updated;
});
return sendData(reply, request, bank);
},
);
fastify.post(
"/bank-institutions/:id/disable",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const bankId = parseId(request.params);
const result = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.bankInstitution.findUnique({
where: { id: bankId },
select: { id: true, code: true, enabled: true, archived_at: true },
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
const inUse = await tx.paymentAccount.count({
where: { bank_institution_id: bankId, enabled: true, archived_at: null },
});
if (inUse > 0) throw validationError({ bankInstitution: "active_payment_account_exists" }, 409);
const now = new Date();
await tx.bankInstitution.update({
where: { id: bankId },
data: { enabled: false, archived_at: now, updated_by: request.employee.id },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BANK_INSTITUTION_DISABLED",
targetType: "bank_institution",
targetId: bankId,
extra: { code: current.code },
}),
});
return { id: bankId, disabled: true };
});
return sendData(reply, request, result);
},
);
fastify.get(
"/payment-accounts",
{ preHandler: fastify.requirePermission("payment.account.read") },
async (request, reply) => {
const rows = await fastify.prisma.paymentAccount.findMany({
orderBy: [{ legal_entity: { code: "asc" } }, { created_at: "desc" }],
include: ACCOUNT_INCLUDE,
});
return sendData(reply, request, { items: rows.map((row) => mapAccount(fastify, row)) });
},
);
fastify.post(
"/payment-accounts",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const body = parse(ACCOUNT_BODY_SCHEMA, request.body);
let created;
try {
created = await fastify.prisma.$transaction(async (tx) => {
await ensureReferences(tx, body);
const record = await tx.paymentAccount.create({
data: {
legal_entity_id: body.legalEntityId,
bank_institution_id: body.bankInstitutionId,
account_name: body.accountName,
account_number_encrypted: encryptStringOrThrow(fastify.config, body.accountNumber),
created_by: request.employee.id,
updated_by: request.employee.id,
},
include: ACCOUNT_INCLUDE,
});
const mapped = mapAccount(fastify, record);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_CREATED",
targetType: "payment_account",
targetId: record.id,
extra: { legalEntityId: body.legalEntityId, bankInstitutionCode: record.bank_institution.code, accountNumberMasked: mapped.accountNumberMasked },
}),
});
await notifyAccountChange(tx, mapped, "created");
return mapped;
});
} catch (error) {
handleWriteError(error);
}
return sendData(reply.code(201), request, created);
},
);
fastify.post(
"/payment-accounts/:id/replace",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const accountId = parseId(request.params);
const body = parse(ACCOUNT_BODY_SCHEMA, request.body);
let created;
try {
created = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.paymentAccount.findFirst({
where: { id: accountId, enabled: true, archived_at: null },
include: ACCOUNT_INCLUDE,
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (current.legal_entity_id !== body.legalEntityId) {
throw validationError({ legalEntityId: "must_match_current_account" });
}
await ensureReferences(tx, body);
const now = new Date();
await tx.paymentAccount.update({
where: { id: current.id },
data: { enabled: false, archived_at: now, updated_at: now, updated_by: request.employee.id },
});
const next = await tx.paymentAccount.create({
data: {
legal_entity_id: body.legalEntityId,
bank_institution_id: body.bankInstitutionId,
account_name: body.accountName,
account_number_encrypted: encryptStringOrThrow(fastify.config, body.accountNumber),
created_by: request.employee.id,
updated_by: request.employee.id,
},
include: ACCOUNT_INCLUDE,
});
const mapped = mapAccount(fastify, next);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_REPLACED",
targetType: "payment_account",
targetId: next.id,
extra: { previousAccountId: current.id, legalEntityId: body.legalEntityId, accountNumberMasked: mapped.accountNumberMasked },
}),
});
await notifyAccountChange(tx, mapped, "replaced");
return mapped;
});
} catch (error) {
handleWriteError(error);
}
return sendData(reply, request, created);
},
);
fastify.post(
"/payment-accounts/:id/disable",
{ preHandler: fastify.requirePermission("payment.account.manage") },
async (request, reply) => {
const accountId = parseId(request.params);
const result = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.paymentAccount.findFirst({
where: { id: accountId, enabled: true, archived_at: null },
include: ACCOUNT_INCLUDE,
});
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
const mapped = mapAccount(fastify, current);
const now = new Date();
await tx.paymentAccount.update({
where: { id: current.id },
data: { enabled: false, archived_at: now, updated_at: now, updated_by: request.employee.id },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_DISABLED",
targetType: "payment_account",
targetId: current.id,
extra: { legalEntityId: current.legal_entity_id, accountNumberMasked: mapped.accountNumberMasked },
}),
});
await notifyAccountChange(tx, mapped, "disabled");
return { id: current.id, disabled: true };
});
return sendData(reply, request, result);
},
);
fastify.post(
"/payment-accounts/:id/reveal",
{ preHandler: fastify.requirePermission("payment.account.read_full") },
async (request, reply) => {
const accountId = parseId(request.params);
const { reason } = parse(REVEAL_BODY_SCHEMA, request.body);
const account = await fastify.prisma.paymentAccount.findUnique({
where: { id: accountId },
select: { id: true, account_number_encrypted: true },
});
if (!account) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED",
targetType: "payment_account",
targetId: account.id,
extra: { reason },
}),
});
return sendData(reply, request, { accountNumber: decryptAccount(fastify, account.account_number_encrypted) });
},
);
}
module.exports = paymentAccountsRoutes;
+266 -6
查看文件
@@ -18,6 +18,7 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls");
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
buildAttachmentContentDisposition,
@@ -38,6 +39,10 @@ const {
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]);
const PAYMENT_ACCOUNT_INCLUDE = {
legal_entity: { select: { id: true, code: true, name: true } },
bank_institution: { select: { id: true, code: true, name: true } },
};
const LIST_QUERY_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA.optional(),
@@ -251,6 +256,22 @@ function ensureBatchReviewPermission(fastify, request) {
}
}
function ensurePaymentOrderExportPermission(fastify, request) {
const roleCodes = request.employee?.roleCodes ?? [];
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
if (!hasFinanceRole || !fastify.hasPermission(request.employee, "payment.order.export")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensurePayeeBankRevealPermission(fastify, request) {
const roleCodes = request.employee?.roleCodes ?? [];
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
if (!hasFinanceRole || !fastify.hasPermission(request.employee, "employee.bank.read")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function roleCodesOf(employee) {
return (employee?.roles ?? []).map((role) => role.role_code).filter(Boolean);
}
@@ -492,18 +513,27 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
`);
}
function buildPayeeSnapshot(config, owner) {
function buildPayeeSnapshot(config, owner, options = {}) {
const encryptedAccount = owner?.bank_account_encrypted;
const bankAccount = encryptedAccount
? decryptStringOrThrow(config, encryptedAccount)
: null;
let bankAccount = null;
try {
bankAccount = encryptedAccount ? decryptStringOrThrow(config, encryptedAccount) : null;
} catch (error) {
if (options.includeEncrypted) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
}
throw error;
}
if (encryptedAccount && !bankAccount) {
throw new Error("Payee bank account could not be decrypted");
}
return {
name: owner?.name ?? null,
bankName: owner?.bank_name ?? null,
bankInstitutionId: owner?.bank_institution?.id ?? null,
bankInstitutionCode: owner?.bank_institution?.code ?? null,
bankName: owner?.bank_institution?.name ?? owner?.bank_name ?? null,
bankAccountMasked: maskBankAccount(bankAccount),
...(options.includeEncrypted ? { bankAccountEncrypted: encryptedAccount ?? null } : {}),
};
}
@@ -525,8 +555,10 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
owner: {
select: {
name: true,
legal_entity_id: true,
bank_name: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
},
},
@@ -539,6 +571,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
currencyCode: "CNY",
status: item.status,
departmentId: item.department_id ?? null,
legalEntityId: item.owner?.legal_entity_id ?? null,
snapshotJson: {
formNumber: item.form_number ?? null,
description: item.description ?? null,
@@ -547,7 +580,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
payee: buildPayeeSnapshot(config, item.owner),
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true }),
},
}));
}
@@ -662,6 +695,7 @@ async function loadLegacyPayees(tx, batch, config) {
name: true,
bank_name: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
},
},
@@ -669,6 +703,46 @@ async function loadLegacyPayees(tx, batch, config) {
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
}
function readPaymentAccountSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
const accountName = normalizeSnapshotText(snapshot.accountName);
const bankName = normalizeSnapshotText(snapshot.bankName);
const bankInstitutionCode = normalizeSnapshotText(snapshot.bankInstitutionCode);
const accountNumberMasked = normalizeMaskedBankAccount(snapshot.accountNumberMasked);
if (!accountName || !bankName || !bankInstitutionCode || !accountNumberMasked) return null;
return { accountName, bankName, bankInstitutionCode, accountNumberMasked };
}
function paymentOrderExportBlockers(batch) {
const blockers = [];
const add = (code, entityCode) => blockers.push({ code, ...(entityCode ? { entityCode } : {}) });
if (batch.status !== "reviewed") add("status_not_reviewed");
if (batch.entity_type !== "reimbursement") add("reimbursement_only");
if (!batch.payment_account_snapshot_json) {
add("legacy_payment_account_snapshot_missing");
} else {
const payer = readPaymentAccountSnapshot(batch.payment_account_snapshot_json);
if (!payer) add("payment_account_snapshot_invalid");
if (!batch.payment_account_snapshot_json.accountNumberEncrypted) add("payer_account_unavailable");
if (payer && !payer.bankInstitutionCode) add("payer_bank_institution_missing");
else if (payer && payer.bankInstitutionCode !== "CCB") add("payer_bank_not_supported");
}
if (!(batch.items ?? []).length) add("items_missing");
for (const item of batch.items ?? []) {
const entityCode = normalizeSnapshotText(item.entity_code) ?? undefined;
const payee = item.snapshot_json?.payee;
if (resolveItemPaymentChannel(batch, item) !== "bank") add("bank_channel_required", entityCode);
if (!payee?.bankAccountEncrypted) add("legacy_payee_snapshot_missing", entityCode);
if (!payee?.bankInstitutionCode) add("payee_bank_institution_missing", entityCode);
else if (payee.bankInstitutionCode !== "CCB") add("payee_bank_not_supported", entityCode);
const safePayee = readSnapshotPayee(item.snapshot_json);
if (!safePayee?.name) add("payee_name_missing", entityCode);
if (!safePayee?.bankName) add("payee_bank_name_missing", entityCode);
if (!safePayee?.bankAccountMasked) add("payee_account_masked_missing", entityCode);
}
return blockers;
}
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
const includePayee = options.includePayee === true;
const batchRisk = summarizeBatchRisk(batch);
@@ -681,6 +755,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
riskMap.get(entityId).push(item);
}
const paymentOrderExportBlockersValue = paymentOrderExportBlockers(batch);
return {
id: batch.id,
batchNo: batch.batch_no,
@@ -697,6 +772,13 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
secondaryReviewComment: batch.secondary_review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null,
receiptAttachmentCount: batch._count?.receipt_attachments ?? 0,
paymentAccount: readPaymentAccountSnapshot(batch.payment_account_snapshot_json),
paymentOrderExportCount: Number(batch.payment_order_export_count ?? 0),
paymentOrderLastExportedAt: batch.payment_order_last_exported_at?.toISOString?.() ?? null,
paymentOrderLastExportedBy: batch.payment_order_last_exported_by ?? null,
canExportPaymentOrder: paymentOrderExportBlockersValue.length === 0,
paymentOrderExportBlockReason: paymentOrderExportBlockersValue[0]?.code ?? null,
paymentOrderExportBlockers: paymentOrderExportBlockersValue,
createdAt: batch.created_at?.toISOString?.() ?? null,
createdBy: batch.created_by ?? null,
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
@@ -1335,6 +1417,60 @@ async function paymentBatchesRoutes(fastify) {
ensureBatchEntityStatus(body.entityType, snapshot);
}
const isBankBatch = (body.defaultPaymentChannel ?? "bank") === "bank";
let paymentAccount = null;
let paymentAccountSnapshot = null;
let legalEntityId = null;
if (isBankBatch) {
const legalEntityIds = Array.from(
new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean)),
);
if (legalEntityIds.length !== 1 || snapshots.some((snapshot) => !snapshot.legalEntityId)) {
throw buildValidationError({ legalEntity: "single_legal_entity_required" }, 409);
}
legalEntityId = legalEntityIds[0];
const invalidPayee = snapshots.find((snapshot) => {
const payee = snapshot.snapshotJson?.payee;
return payee?.bankInstitutionCode !== "CCB" || !payee?.bankAccountEncrypted;
});
if (invalidPayee) {
throw buildValidationError({
payeeBankAccount: `${invalidPayee.entityCode}:ccb_account_required`,
}, 409);
}
paymentAccount = await tx.paymentAccount.findFirst({
where: {
legal_entity_id: legalEntityId,
enabled: true,
archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
},
include: PAYMENT_ACCOUNT_INCLUDE,
});
if (!paymentAccount) {
throw buildValidationError({ paymentAccount: "active_ccb_account_required" }, 409);
}
let paymentAccountNumber;
try {
paymentAccountNumber = decryptStringOrThrow(
fastify.config,
paymentAccount.account_number_encrypted,
);
} catch (error) {
throw buildValidationError({ paymentAccount: "decrypt_failed" }, 409);
}
paymentAccountSnapshot = {
paymentAccountId: paymentAccount.id,
legalEntityId,
accountName: paymentAccount.account_name,
bankInstitutionId: paymentAccount.bank_institution.id,
bankInstitutionCode: paymentAccount.bank_institution.code,
bankName: paymentAccount.bank_institution.name,
accountNumberEncrypted: paymentAccount.account_number_encrypted,
accountNumberMasked: maskBankAccount(paymentAccountNumber),
};
}
const reservedItems = await tx.paymentBatchItem.findMany({
where: {
entity_id: { in: entityIds },
@@ -1365,6 +1501,9 @@ async function paymentBatchesRoutes(fastify) {
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
default_payment_channel: body.defaultPaymentChannel ?? "bank",
voucher_prefix: normalizeNullableString(body.voucherPrefix),
legal_entity_id: legalEntityId,
payment_account_id: paymentAccount?.id ?? null,
payment_account_snapshot_json: paymentAccountSnapshot,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -1433,6 +1572,127 @@ async function paymentBatchesRoutes(fastify) {
});
});
fastify.post(
"/payment-batches/:id/payment-order",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensurePaymentOrderExportPermission(fastify, request);
const batchId = parseId(request.params?.id);
const { reason } = parseBody(REASON_SCHEMA, request.body);
let result;
try {
result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
const blockers = paymentOrderExportBlockers(batch);
if (blockers.length) {
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
}
const generated = generatePaymentOrderXls(fastify.config, batch);
const exportCount = Number(batch.payment_order_export_count ?? 0) + 1;
const exportedAt = new Date();
await tx.paymentBatch.update({
where: { id: batch.id },
data: {
payment_order_export_count: exportCount,
payment_order_last_exported_at: exportedAt,
payment_order_last_exported_by: request.employee.id,
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: exportCount === 1 ? "PAYMENT_ORDER_EXPORTED" : "PAYMENT_ORDER_REEXPORTED",
targetType: "payment_batch",
targetId: batch.id,
extra: {
batchNo: batch.batch_no,
rowCount: generated.rowCount,
totalAmount: generated.totalAmount,
reason,
exportCount,
},
}),
});
return { ...generated, batchNo: batch.batch_no };
});
} catch (error) {
const resultCode = error instanceof PaymentOrderXlsError
? error.code
: error?.details?.status ?? error?.code ?? "export_failed";
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_ORDER_EXPORT_BLOCKED",
targetType: "payment_batch",
targetId: batchId,
extra: { reason, resultCode },
}),
}).catch((auditError) => {
fastify.log.error({ err: auditError, requestId: request.id }, "payment order block audit failed");
});
if (error instanceof PaymentOrderXlsError) {
throw new AppError({
code: "PAYMENT_ORDER_EXPORT_BLOCKED",
message: "银行制单生成失败",
statusCode: 409,
details: { reason: error.code, ...error.details },
});
}
throw error;
}
const safeBatchNo = String(result.batchNo).replace(/[^A-Za-z0-9-]/g, "_");
reply.header("content-type", "application/vnd.ms-excel");
reply.header("content-disposition", `attachment; filename="payment-order-${safeBatchNo}.xls"`);
reply.header("cache-control", "no-store");
reply.header("x-content-type-options", "nosniff");
return reply.send(result.buffer);
},
);
fastify.post(
"/payment-batches/:id/items/:itemId/payee-bank/reveal",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensurePayeeBankRevealPermission(fastify, request);
const batchId = parseId(request.params?.id);
const itemId = parseId(request.params?.itemId, "itemId");
const { reason } = parseBody(REASON_SCHEMA, request.body);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payee_bank_reveal_not_available" }, 409);
}
const item = batch.items.find((candidate) => candidate.id === itemId);
if (!item) throw buildNotFoundError();
const encryptedAccount = item.snapshot_json?.payee?.bankAccountEncrypted;
if (!encryptedAccount) {
throw buildValidationError({ payeeBankAccount: "legacy_snapshot_missing" }, 409);
}
let bankAccount;
try {
bankAccount = decryptStringOrThrow(fastify.config, encryptedAccount);
} catch (error) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED",
targetType: "payment_batch",
targetId: batch.id,
extra: { paymentBatchItemId: item.id, entityCode: item.entity_code, reason },
}),
});
return sendData(reply, request, { bankAccount });
},
);
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchReadPermission(fastify, request);
await ensureFeatureEnabled(fastify);
+103 -23
查看文件
@@ -97,12 +97,16 @@ const resetPasswordBodySchema = z.object({
const sensitiveBodySchema = z
.object({
bankName: z.string().trim().max(100).nullable().optional(),
bankAccount: z.string().trim().max(100).nullable().optional(),
bankInstitutionId: z.string().uuid().nullable().optional(),
bankAccount: z.string().trim().regex(/^\d{6,32}$/).nullable().optional(),
idNumber: z.string().trim().max(64).nullable().optional(),
})
.strict();
const sensitiveRevealBodySchema = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"];
const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"];
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
@@ -1863,7 +1867,8 @@ async function usersRoutes(fastify, opts = {}) {
},
async (request, reply) => {
const userId = parseUserId(request.params);
const canReadBank = fastify.hasPermission(request.employee, "employee.bank.read")
const canReadBank = request.employee.id === userId
|| fastify.hasPermission(request.employee, "employee.bank.read")
|| fastify.hasPermission(request.employee, "employee.bank.manage");
const canReadIdentity = fastify.hasPermission(request.employee, "employee.identity.manage");
if (!canReadBank && !canReadIdentity) {
@@ -1877,7 +1882,9 @@ async function usersRoutes(fastify, opts = {}) {
...(canReadBank
? {
bank_name: true,
bank_institution_id: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
}
: {}),
...(canReadIdentity ? { id_number_encrypted: true } : {}),
@@ -1895,10 +1902,19 @@ async function usersRoutes(fastify, opts = {}) {
const data = {};
try {
if (canReadBank) {
data.bankName = employee.bank_name ? String(employee.bank_name) : null;
data.bankAccount = employee.bank_account_encrypted
const bankAccount = employee.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
data.bankInstitution = employee.bank_institution
? {
id: employee.bank_institution.id,
code: employee.bank_institution.code,
name: employee.bank_institution.name,
}
: null;
data.bankName = employee.bank_institution?.name
?? (employee.bank_name ? String(employee.bank_name) : null);
data.bankAccountMasked = bankAccount ? maskBankAccount(bankAccount) : null;
}
if (canReadIdentity) {
data.idNumber = employee.id_number_encrypted
@@ -1915,22 +1931,71 @@ async function usersRoutes(fastify, opts = {}) {
});
}
if (canReadIdentity) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_SENSITIVE_VIEWED",
targetType: "employee",
targetId: userId,
extra: { scopes: ["identity"] },
}),
});
}
return sendData(reply, request, data);
},
);
fastify.post(
`${routeBasePath}/:id/sensitive/bank/reveal`,
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const userId = parseUserId(request.params);
const parsed = sensitiveRevealBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const canRevealBank = fastify.hasPermission(request.employee, "employee.bank.read")
|| fastify.hasPermission(request.employee, "employee.bank.manage");
if (!canRevealBank) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: userId },
select: { id: true, bank_account_encrypted: true },
});
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
let bankAccount = null;
try {
bankAccount = employee.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
} catch (error) {
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500, details: {} });
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_SENSITIVE_VIEWED",
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
targetType: "employee",
targetId: userId,
extra: {
scopes: [
...(canReadBank ? ["bank"] : []),
...(canReadIdentity ? ["identity"] : []),
],
},
extra: { reason: parsed.data.reason },
}),
});
return sendData(reply, request, data);
return sendData(reply, request, { bankAccount });
},
);
@@ -1942,7 +2007,7 @@ async function usersRoutes(fastify, opts = {}) {
async (request, reply) => {
const userId = parseUserId(request.params);
const body = parseSensitiveBody(request.body);
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankName")
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankInstitutionId")
|| Object.prototype.hasOwnProperty.call(body, "bankAccount");
const writesIdentity = Object.prototype.hasOwnProperty.call(body, "idNumber");
if (writesBank) {
@@ -1960,7 +2025,7 @@ async function usersRoutes(fastify, opts = {}) {
where: { id: userId },
select: {
id: true,
...(writesBank ? { bank_name: true } : {}),
...(writesBank ? { bank_name: true, bank_institution_id: true } : {}),
},
});
if (!current) {
@@ -1977,13 +2042,28 @@ async function usersRoutes(fastify, opts = {}) {
updated_by: request.employee.id,
};
if (Object.prototype.hasOwnProperty.call(body, "bankName")) {
const nextBankName =
typeof body.bankName === "string" ? body.bankName.trim() : body.bankName;
data.bank_name = nextBankName ? String(nextBankName) : "";
changes.bankName = {
before: current.bank_name ? String(current.bank_name) : null,
after: data.bank_name || null,
if (Object.prototype.hasOwnProperty.call(body, "bankInstitutionId")) {
let bankInstitution = null;
if (body.bankInstitutionId) {
bankInstitution = await tx.bankInstitution.findFirst({
where: { id: body.bankInstitutionId, enabled: true, archived_at: null },
select: { id: true, code: true, name: true },
});
if (!bankInstitution) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { bankInstitutionId: "not_available" },
});
}
}
data.bank_institution_id = bankInstitution?.id ?? null;
data.bank_name = bankInstitution?.name ?? "";
changes.bankInstitution = {
beforeId: current.bank_institution_id ?? null,
afterId: bankInstitution?.id ?? null,
afterCode: bankInstitution?.code ?? null,
};
}
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Fill the fixed blank CCB BIFF8 template without exposing row data."""
from __future__ import annotations
import copy
import hashlib
import io
import json
import math
from pathlib import Path
import sys
try:
import xlrd
import xlwt
from xlutils.filter import process, XLRDReader, XLWTWriter
except ImportError:
sys.stderr.write("[payment-order-template][ERROR] runtime_dependency_unavailable\n")
raise SystemExit(70)
TEMPLATE_SHA256 = "72e83ab6b7cd71263ca00196746de896e02a02390bab5c896dabd76421e40c0e"
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "assets" / "payment-order" / "ccb-payment-order-template.xls"
EXPECTED_SHEETS = ["Sheet1", "Sheet2", "Sheet3"]
COLUMN_COUNT = 15
TEMPLATE_DATA_ROW = 1
MAX_INPUT_BYTES = 2 * 1024 * 1024
MAX_ROWS = 5000
EXPECTED_RICH_TEXT_CELLS = {
(0, 0),
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(0, 5),
(0, 9),
(0, 10),
(0, 12),
}
class TemplateFillError(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def read_payload() -> dict:
payload_bytes = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
if len(payload_bytes) > MAX_INPUT_BYTES:
raise TemplateFillError("input_too_large")
try:
payload = json.loads(payload_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise TemplateFillError("input_invalid") from None
if not isinstance(payload, dict) or set(payload) != {"rows"}:
raise TemplateFillError("input_invalid")
rows = payload.get("rows")
if not isinstance(rows, list) or not rows or len(rows) > MAX_ROWS:
raise TemplateFillError("rows_invalid")
for row in rows:
if not isinstance(row, list) or len(row) != COLUMN_COUNT:
raise TemplateFillError("row_invalid")
for column, value in enumerate(row):
if column == 9:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
raise TemplateFillError("amount_invalid")
elif not isinstance(value, str):
raise TemplateFillError("text_cell_invalid")
return payload
def read_template() -> tuple[bytes, xlrd.book.Book]:
try:
template_bytes = TEMPLATE_PATH.read_bytes()
except OSError:
raise TemplateFillError("template_unavailable") from None
if hashlib.sha256(template_bytes).hexdigest() != TEMPLATE_SHA256:
raise TemplateFillError("template_hash_mismatch")
try:
book = xlrd.open_workbook(file_contents=template_bytes, formatting_info=True)
except Exception:
raise TemplateFillError("template_invalid") from None
if book.sheet_names() != EXPECTED_SHEETS:
raise TemplateFillError("template_structure_mismatch")
sheet = book.sheet_by_index(0)
if sheet.nrows < 4 or sheet.ncols != COLUMN_COUNT:
raise TemplateFillError("template_structure_mismatch")
return template_bytes, book
def copy_template_font(source: xlrd.formatting.Font) -> xlwt.Font:
target = xlwt.Font()
target.height = source.height
target.italic = source.italic
target.struck_out = source.struck_out
target.outline = source.outline
target.shadow = source.shadow
target.colour_index = source.colour_index
target.bold = source.bold
target._weight = source.weight
target.escapement = source.escapement
target.underline = source.underline_type
target.family = source.family
target.charset = source.character_set
target.name = source.name
return target
def restore_template_rich_text(
source_book: xlrd.book.Book,
writer: XLWTWriter,
target_sheet: xlwt.Worksheet,
) -> None:
source_sheet = source_book.sheet_by_index(0)
rich_text_runs = source_sheet.rich_text_runlist_map
if not EXPECTED_RICH_TEXT_CELLS.issubset(rich_text_runs):
raise TemplateFillError("template_structure_mismatch")
for (row, column), runs in sorted(rich_text_runs.items()):
text = source_sheet.cell_value(row, column)
if not isinstance(text, str) or not runs:
raise TemplateFillError("template_structure_mismatch")
parts: list[object] = []
first_offset = runs[0][0]
if first_offset < 0 or first_offset >= len(text):
raise TemplateFillError("template_structure_mismatch")
if first_offset > 0:
parts.append(text[:first_offset])
for index, (offset, font_index) in enumerate(runs):
end = runs[index + 1][0] if index + 1 < len(runs) else len(text)
if offset < first_offset or end <= offset or end > len(text):
raise TemplateFillError("template_structure_mismatch")
try:
source_font = source_book.font_list[font_index]
except IndexError:
raise TemplateFillError("template_structure_mismatch") from None
parts.append((text[offset:end], copy_template_font(source_font)))
style_index = source_sheet.cell_xf_index(row, column)
target_sheet.write_rich_text(row, column, parts, writer.style_list[style_index])
def fill_workbook(rows: list[list[object]], source_book: xlrd.book.Book) -> bytes:
writer = XLWTWriter()
try:
process(XLRDReader(source_book, "ccb-payment-order-template.xls"), writer)
target_book = writer.output[0][1]
source_sheet = source_book.sheet_by_index(0)
target_sheet = target_book.get_sheet(0)
target_sheet._cell_overwrite_ok = True
restore_template_rich_text(source_book, writer, target_sheet)
source_row_info = source_sheet.rowinfo_map.get(TEMPLATE_DATA_ROW)
source_row_height = source_row_info.height if source_row_info else 735
source_row_height_mismatch = source_row_info.height_mismatch if source_row_info else False
for row_offset, values in enumerate(rows):
target_row_index = row_offset + 1
target_row = target_sheet.row(target_row_index)
target_row.height = source_row_height
target_row.height_mismatch = source_row_height_mismatch
for column, value in enumerate(values):
style_index = source_sheet.cell_xf_index(TEMPLATE_DATA_ROW, column)
style = writer.style_list[style_index]
if column == 9:
style = copy.deepcopy(style)
style.num_format_str = "0.00"
target_sheet.write(target_row_index, column, value, style)
output = io.BytesIO()
target_book.save(output)
return output.getvalue()
except TemplateFillError:
raise
except Exception:
raise TemplateFillError("fill_failed") from None
def main() -> int:
try:
payload = read_payload()
_, source_book = read_template()
output = fill_workbook(payload["rows"], source_book)
if not output.startswith(bytes.fromhex("d0cf11e0a1b11ae1")):
raise TemplateFillError("output_invalid")
sys.stdout.buffer.write(output)
return 0
except TemplateFillError as error:
sys.stderr.write(f"[payment-order-template][ERROR] {error.code}\n")
return 70
if __name__ == "__main__":
raise SystemExit(main())
+36
查看文件
@@ -567,6 +567,36 @@ async function seedExpenseTypes(prisma) {
}
}
async function seedBankInstitutions(prisma) {
const existing = await prisma.bankInstitution.findUnique({
where: { code: "CCB" },
select: { id: true },
});
if (existing) {
return { created: 0, preserved: 1 };
}
return prisma.bankInstitution.create({
data: {
code: "CCB",
name: "中国建设银行",
enabled: true,
archived_at: null,
created_by: null,
updated_by: null,
},
}).then(
() => ({ created: 1, preserved: 0 }),
(error) => {
if (error?.code === "P2002") {
return { created: 0, preserved: 1 };
}
throw error;
},
);
}
async function seedApprovalRules(prisma) {
const existingCount = await prisma.approvalRule.count();
if (existingCount > 0) {
@@ -1032,6 +1062,7 @@ async function main() {
await prisma.$connect();
try {
const bankInstitutionSeed = await seedBankInstitutions(prisma);
await seedExpenseTypes(prisma);
const approvalRuleSeed = await seedApprovalRules(prisma);
await seedDocumentSequence(prisma);
@@ -1046,6 +1077,10 @@ async function main() {
// eslint-disable-next-line no-console
console.log("Seeded config data:");
// eslint-disable-next-line no-console
console.log(
`- bank institutions: ${bankInstitutionSeed.created} created / ${bankInstitutionSeed.preserved} preserved`,
);
// eslint-disable-next-line no-console
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
// eslint-disable-next-line no-console
console.log(
@@ -1096,6 +1131,7 @@ module.exports = {
DEFAULT_FEATURE_FLAGS,
ENABLED_EXPENSE_TYPES,
seedApprovalRules,
seedBankInstitutions,
seedExpenseTypes,
seedFeatureFlags,
};
+218
查看文件
@@ -0,0 +1,218 @@
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { decryptStringOrThrow } = require("./crypto");
class PaymentOrderXlsError extends Error {
constructor(code, details = {}) {
super(code);
this.name = "PaymentOrderXlsError";
this.code = code;
this.details = details;
}
}
function buildCustomerSerial(batchNo, rowNumber) {
const serial = `${String(batchNo).trim()}-R${String(rowNumber).padStart(3, "0")}`;
if (serial.length > 32) {
throw new PaymentOrderXlsError("customer_serial_too_long");
}
if (!/^[A-Z0-9-]+$/.test(serial)) {
throw new PaymentOrderXlsError("customer_serial_invalid_characters");
}
return serial;
}
function requiredText(value, code) {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) throw new PaymentOrderXlsError(code);
return normalized;
}
function decryptRequired(config, value, code) {
try {
return requiredText(value ? decryptStringOrThrow(config, value) : "", code);
} catch (error) {
if (error instanceof PaymentOrderXlsError) throw error;
throw new PaymentOrderXlsError(code);
}
}
function buildPaymentOrderRows(config, batch) {
const blockers = [];
const addBlocker = (code, entityCode) => {
blockers.push({ code, ...(entityCode ? { entityCode: String(entityCode) } : {}) });
};
const payer = batch?.payment_account_snapshot_json;
if (!payer || typeof payer !== "object" || Array.isArray(payer)) {
addBlocker("payment_account_snapshot_missing");
}
if (payer && !payer.bankInstitutionCode) {
addBlocker("payer_bank_institution_missing");
} else if (payer && payer.bankInstitutionCode !== "CCB") {
addBlocker("payer_bank_not_supported");
}
let payerAccount = "";
let payerName = "";
if (payer) {
try {
payerAccount = decryptRequired(config, payer.accountNumberEncrypted, "payer_account_unavailable");
} catch (error) {
addBlocker(error.code ?? "payer_account_unavailable");
}
try {
payerName = requiredText(payer.accountName, "payer_name_missing");
} catch (error) {
addBlocker(error.code ?? "payer_name_missing");
}
try {
requiredText(payer.bankName, "payer_bank_name_missing");
} catch (error) {
addBlocker(error.code ?? "payer_bank_name_missing");
}
}
const items = [...(batch.items ?? [])].sort((left, right) =>
String(left.entity_code).localeCompare(String(right.entity_code), "zh-CN")
|| String(left.id).localeCompare(String(right.id)),
);
if (!items.length) addBlocker("items_missing");
const serials = new Set();
let totalAmount = 0;
const preparedRows = items.map((item, index) => {
const payee = item.snapshot_json?.payee;
const entityCodeHint = typeof item.entity_code === "string" && item.entity_code.trim()
? item.entity_code.trim()
: undefined;
if (!payee?.bankInstitutionCode) {
addBlocker("payee_bank_institution_missing", entityCodeHint);
} else if (payee.bankInstitutionCode !== "CCB") {
addBlocker("payee_bank_not_supported", entityCodeHint);
}
let payeeAccount = "";
let payeeName = "";
let payeeBankName = "";
let entityCode = "";
try {
payeeAccount = decryptRequired(config, payee?.bankAccountEncrypted, "payee_account_unavailable");
} catch (error) {
addBlocker(error.code ?? "payee_account_unavailable", entityCodeHint);
}
try {
payeeName = requiredText(payee?.name, "payee_name_missing");
} catch (error) {
addBlocker(error.code ?? "payee_name_missing", entityCodeHint);
}
try {
payeeBankName = requiredText(payee?.bankName, "payee_bank_name_missing");
} catch (error) {
addBlocker(error.code ?? "payee_bank_name_missing", entityCodeHint);
}
try {
entityCode = requiredText(item.entity_code, "entity_code_missing");
} catch (error) {
addBlocker(error.code ?? "entity_code_missing");
}
const amount = Number(item.amount);
if (!Number.isFinite(amount) || amount <= 0) {
addBlocker("amount_invalid", entityCodeHint);
}
let serial = "";
try {
serial = buildCustomerSerial(batch.batch_no, index + 1);
if (serials.has(serial)) addBlocker("customer_serial_duplicate", entityCodeHint);
serials.add(serial);
} catch (error) {
addBlocker(error.code ?? "customer_serial_invalid", entityCodeHint);
}
if (Number.isFinite(amount) && amount > 0) totalAmount += amount;
return { payeeAccount, payeeName, payeeBankName, entityCode, amount, serial };
});
if (blockers.length) {
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
}
const rows = preparedRows.map(({ payeeAccount, payeeName, payeeBankName, entityCode, amount, serial }, index) => {
return [
String(index + 1),
payerAccount,
payerName,
"01",
payeeAccount,
payeeName,
payeeBankName,
"",
serial,
Number(amount.toFixed(2)),
"报销款",
entityCode,
"0",
"",
"",
];
});
return { rows, totalAmount: totalAmount.toFixed(2), serials: [...serials] };
}
const PYTHON_ERROR_CODES = new Set([
"runtime_dependency_unavailable",
"template_unavailable",
"template_hash_mismatch",
"template_invalid",
"template_structure_mismatch",
"input_too_large",
"input_invalid",
"rows_invalid",
"row_invalid",
"amount_invalid",
"text_cell_invalid",
"fill_failed",
"output_invalid",
]);
function mapTemplateProcessError(result) {
const stderr = Buffer.isBuffer(result?.stderr) ? result.stderr.toString("utf8") : String(result?.stderr ?? "");
const matched = stderr.match(/^\[payment-order-template\]\[ERROR\] ([a-z_]+)\s*$/);
const internalCode = matched && PYTHON_ERROR_CODES.has(matched[1]) ? matched[1] : "process_failed";
if (internalCode === "runtime_dependency_unavailable" || result?.error?.code === "ENOENT") {
return "payment_order_generator_unavailable";
}
if (internalCode.startsWith("template_")) return "payment_order_template_unavailable";
return "payment_order_generation_failed";
}
function generatePaymentOrderXls(config, batch, options = {}) {
const { rows, totalAmount, serials } = buildPaymentOrderRows(config, batch);
const pythonBin = options.pythonBin ?? process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3";
const scriptPath = options.scriptPath ?? path.resolve(__dirname, "../scripts/fill-payment-order-template.py");
const maxBuffer = options.maxBuffer ?? 16 * 1024 * 1024;
const result = spawnSync(pythonBin, [scriptPath], {
input: Buffer.from(JSON.stringify({ rows }), "utf8"),
maxBuffer,
timeout: options.timeout ?? 15_000,
windowsHide: true,
env: {
PATH: path.isAbsolute(pythonBin) ? path.dirname(pythonBin) : "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
PYTHONDONTWRITEBYTECODE: "1",
PYTHONNOUSERSITE: "1",
},
});
if (result.status !== 0 || result.error || !Buffer.isBuffer(result.stdout)) {
throw new PaymentOrderXlsError(mapTemplateProcessError(result));
}
const buffer = result.stdout;
if (buffer.length > maxBuffer || buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1") {
throw new PaymentOrderXlsError("payment_order_generation_failed");
}
return { buffer, rowCount: rows.length, totalAmount, serials };
}
module.exports = {
PaymentOrderXlsError,
buildCustomerSerial,
buildPaymentOrderRows,
generatePaymentOrderXls,
};
+1 -1
查看文件
@@ -86,7 +86,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
contractsRegistry: { key: "contractsRegistry", label: "合同", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
featureFlags: { key: "featureFlags", label: "功能管理", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
+11 -2
查看文件
@@ -2,6 +2,9 @@ const Fastify = require('fastify');
const meRoutes = require('../../../src/routes/me');
const { AppError } = require('../../../src/utils/AppError');
const { encryptStringOrThrow } = require('../../../src/services/crypto');
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64');
function createPrismaState(overrides = {}) {
const auditEntries = [];
@@ -18,6 +21,7 @@ function createPrismaState(overrides = {}) {
emergency_contact_phone: null,
bank_account_encrypted: '',
bank_name: '',
bank_institution: null,
avatar_path: null,
id_number_encrypted: null,
notification_push_enabled: true,
@@ -53,6 +57,7 @@ function createPrismaState(overrides = {}) {
async function buildApp(prisma) {
const app = Fastify();
app.decorate('prisma', prisma);
app.decorate('config', { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
app.decorate('authenticate', async (request) => {
if (!request.headers.authorization) {
throw new AppError({
@@ -130,8 +135,12 @@ describe('me routes', () => {
const { prisma } = createPrismaState({
emergency_contact_name: '联系人',
emergency_contact_phone: '13900000000',
bank_account_encrypted: 'ENC',
bank_name: '招商银行',
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
'6222000012345678',
),
bank_name: '中国建设银行',
bank_institution: { id: 'bank-1', code: 'CCB', name: '中国建设银行' },
avatar_path: null,
id_number_encrypted: null,
});
@@ -0,0 +1,108 @@
const Fastify = require("fastify");
const paymentAccountsRoutes = require("../../../src/routes/paymentAccounts");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
const ACCOUNT_ID = "22222222-2222-4222-8222-222222222222";
const LEGAL_ENTITY_ID = "33333333-3333-4333-8333-333333333333";
const BANK_ID = "44444444-4444-4444-8444-444444444444";
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const SYNTHETIC_ACCOUNT = "6217000000000012345";
function buildRecord(overrides = {}) {
return {
id: ACCOUNT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
bank_institution_id: BANK_ID,
account_name: "合成测试公司",
account_number_encrypted: encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_ACCOUNT),
enabled: true,
archived_at: null,
created_at: new Date("2026-08-02T00:00:00.000Z"),
updated_at: new Date("2026-08-02T00:00:00.000Z"),
legal_entity: { id: LEGAL_ENTITY_ID, code: "TEST", name: "合成测试公司" },
bank_institution: { id: BANK_ID, code: "CCB", name: "中国建设银行" },
...overrides,
};
}
async function buildApp(permissionCodes) {
const app = Fastify();
const actor = { id: ACTOR_ID, roleCodes: [], permissionCodes };
const record = buildRecord();
const auditCreate = vi.fn(async () => ({ id: "audit" }));
const notificationCreateMany = vi.fn(async ({ data }) => ({ count: data.length }));
const paymentAccountCreate = vi.fn(async ({ data }) => buildRecord({ ...data }));
const tx = {
legalEntity: { findFirst: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, name: "合成测试公司" })) },
bankInstitution: { findFirst: vi.fn(async () => ({ id: BANK_ID, code: "CCB", name: "中国建设银行" })) },
paymentAccount: {
create: paymentAccountCreate,
update: vi.fn(),
findFirst: vi.fn(async () => record),
},
auditLog: { create: auditCreate },
employeeRole: { findMany: vi.fn(async () => [{ employee_id: ACTOR_ID }]) },
notification: { createMany: notificationCreateMany },
};
app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
app.decorate("prisma", {
bankInstitution: { findMany: vi.fn(async () => [{ id: BANK_ID, code: "CCB", name: "中国建设银行" }]) },
paymentAccount: {
findMany: vi.fn(async () => [record]),
findUnique: vi.fn(async () => ({ id: ACCOUNT_ID, account_number_encrypted: record.account_number_encrypted })),
},
auditLog: { create: auditCreate },
$transaction: vi.fn(async (callback) => callback(tx)),
});
app.decorate("authenticate", async (request) => { request.employee = actor; });
app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permission));
app.decorate("requirePermission", (permission) => async (request) => {
request.employee = actor;
if (!app.hasPermission(actor, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
});
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
await app.register(paymentAccountsRoutes, { prefix: "/api" });
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
}
describe("payment account routes", () => {
it("returns only a masked account on normal reads", async () => {
const testApp = await buildApp(["payment.account.read"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0].accountNumberMasked).toBe("6217***********2345");
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
await testApp.app.close();
});
it("requires a reason before revealing the full account and audits the successful view", async () => {
const testApp = await buildApp(["payment.account.read_full"]);
const missingReason = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: {} });
expect(missingReason.statusCode).toBe(400);
const response = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: { reason: "银行制单核对" } });
expect(response.statusCode).toBe(200);
expect(response.json().data.accountNumber).toBe(SYNTHETIC_ACCOUNT);
expect(testApp.auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED", extra: { reason: "银行制单核对" } }) });
await testApp.app.close();
});
it("creates an encrypted account with masked audit and notifies administrators", async () => {
const testApp = await buildApp(["payment.account.manage"]);
const response = await testApp.app.inject({ method: "POST", url: "/api/payment-accounts", payload: { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT } });
expect(response.statusCode).toBe(201);
expect(testApp.paymentAccountCreate.mock.calls[0][0].data.account_number_encrypted).toMatch(/^v1:/);
expect(JSON.stringify(testApp.auditCreate.mock.calls)).not.toContain(SYNTHETIC_ACCOUNT);
expect(testApp.notificationCreateMany).toHaveBeenCalledOnce();
await testApp.app.close();
});
it("does not grant payment-account access to a system administrator by role implication", async () => {
const testApp = await buildApp(["system.settings", "system.accounts.manage"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
expect(response.statusCode).toBe(403);
await testApp.app.close();
});
});
@@ -51,6 +51,9 @@ const EXECUTED_REIMBURSEMENT_ID = "bbbbbbbb-2222-4222-8222-222222222222";
const OWNER_ID = "77777777-7777-4777-8777-777777777777";
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
const LEGAL_ENTITY_ID = "12121212-1212-4121-8121-121212121212";
const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313";
const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414";
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
@@ -174,7 +177,13 @@ function buildReimbursement(overrides = {}) {
id: OWNER_ID,
employee_no: "EMP001",
name: "测试员工",
bank_name: "测试银行",
legal_entity_id: LEGAL_ENTITY_ID,
bank_name: "中国建设银行",
bank_institution: {
id: BANK_INSTITUTION_ID,
code: "CCB",
name: "中国建设银行",
},
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
@@ -238,6 +247,21 @@ function buildPrisma(options = {}) {
},
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
reservedEntityIds: options.reservedEntityIds ?? [],
paymentAccount: {
id: PAYMENT_ACCOUNT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
bank_institution_id: BANK_INSTITUTION_ID,
account_name: "测试公司",
account_number_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
),
enabled: true,
archived_at: null,
legal_entity: { id: LEGAL_ENTITY_ID, code: "HYB", name: "测试公司" },
bank_institution: { id: BANK_INSTITUTION_ID, code: "CCB", name: "中国建设银行" },
...(options.paymentAccount ?? {}),
},
employees: options.employees ?? [
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
@@ -254,6 +278,9 @@ function buildPrisma(options = {}) {
};
const tx = {
paymentAccount: {
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
},
paymentBatch: {
findUnique: vi.fn(async ({ where }) => {
if (where.id !== state.batch.id) {
@@ -967,9 +994,13 @@ describe("payment batch reimbursement routes", () => {
expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
expect(detailResponse.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([
{ code: "legacy_payment_account_snapshot_missing" },
{ code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" },
]));
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
await app.close();
@@ -1002,7 +1033,7 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
@@ -1150,17 +1181,17 @@ describe("payment batch reimbursement routes", () => {
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
snapshot_json: expect.objectContaining({
payee: {
payee: expect.objectContaining({
name: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
},
}),
}),
}),
});
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
@@ -1210,7 +1241,8 @@ describe("payment batch reimbursement routes", () => {
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
});
expect(response.statusCode).toBe(500);
expect(response.statusCode).toBe(409);
expect(response.json().error.details.payeeBankAccount).toBe("decrypt_failed");
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
@@ -1517,7 +1549,7 @@ describe("payment batch reimbursement routes", () => {
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
expect(detail.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
});
expect(state.batch.items[0].snapshot_json).toEqual({});
@@ -2293,4 +2325,228 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => {
const payerEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
);
const payeeEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx, state } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "payment.order.export"],
roleCodes: ["finance"],
},
batch: {
status: "reviewed",
payment_account_snapshot_json: {
accountName: "合成测试公司",
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
accountNumberMasked: "6217********1234",
accountNumberEncrypted: payerEncrypted,
},
payment_order_export_count: 0,
items: [buildPaymentBatch().items[0],].map((item) => ({
...item,
snapshot_json: {
payee: {
name: "测试员工",
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
bankAccountMasked: "6222********5678",
bankAccountEncrypted: payeeEncrypted,
},
},
})),
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "导入企业网银" },
});
expect(response.statusCode).toBe(200);
expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
expect(response.headers["cache-control"]).toBe("no-store");
expect(state.batch.payment_order_export_count).toBe(1);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }),
});
await app.close();
});
it("does not let an admin without a finance business role export a payment order", async () => {
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["*"],
roleCodes: ["admin"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "权限负向验证" },
});
expect(response.statusCode).toBe(403);
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED" }),
});
await app.close();
});
it.each(["draft", "failed", "cancelled", "executed"])(
"rejects direct payment-order export while the batch is %s",
async (status) => {
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "payment.order.export"],
roleCodes: ["finance"],
},
batch: { status },
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "状态门禁验证" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("status_not_reviewed");
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }),
});
await app.close();
},
);
it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => {
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "payment.order.export"],
roleCodes: ["finance"],
},
batch: { status: "reviewed", payment_account_snapshot_json: null },
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "旧批次尝试" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("legacy_payment_account_snapshot_missing");
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
await app.close();
});
it("rejects direct payment-order export when an item effective channel is not bank", async () => {
const payerEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
);
const payeeEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "payment.order.export"],
roleCodes: ["finance"],
},
batch: {
status: "reviewed",
default_payment_channel: "bank",
payment_account_snapshot_json: {
accountName: "合成测试公司",
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
accountNumberMasked: "6217********1234",
accountNumberEncrypted: payerEncrypted,
},
items: buildPaymentBatch().items.map((item) => ({
...item,
payment_channel_override: "cash",
snapshot_json: {
payee: {
name: "测试员工",
bankName: "中国建设银行",
bankInstitutionCode: "CCB",
bankAccountMasked: "6222********5678",
bankAccountEncrypted: payeeEncrypted,
},
},
})),
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "验证付款渠道门禁" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual(expect.objectContaining({
reason: "bank_channel_required",
blockers: expect.arrayContaining([
{ code: "bank_channel_required", entityCode: "BX-20260429-0001" },
]),
}));
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }),
});
await app.close();
});
it("reveals the frozen payee account to finance only after a reason and audits the view", async () => {
const payeeEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.manage", "employee.bank.read"],
roleCodes: ["finance"],
},
batch: {
status: "reviewed",
items: buildPaymentBatch().items.map((item) => ({
...item,
snapshot_json: { payee: { bankAccountEncrypted: payeeEncrypted } },
})),
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}/payee-bank/reveal`,
headers: { authorization: "Bearer test" },
payload: { reason: "人工转账核对" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.bankAccount).toBe(SYNTHETIC_BANK_ACCOUNT);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED", extra: expect.objectContaining({ reason: "人工转账核对", paymentBatchItemId: ITEM_ID }) }),
});
await app.close();
});
});
@@ -85,15 +85,16 @@ describe("user sensitive field permissions", () => {
await testApp.app.close();
});
it("returns only bank fields to finance and audits the authorized scope", async () => {
it("returns only masked bank fields to finance without a full-account view audit", async () => {
const testApp = await buildApp(["employee.bank.read"]);
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
bankInstitution: null,
bankName: "合成银行",
bankAccount: "6222000012345678",
bankAccountMasked: "6222********5678",
});
expect(response.json().data).not.toHaveProperty("idNumber");
expect(testApp.employeeFindUnique).toHaveBeenCalledWith({
@@ -101,15 +102,12 @@ describe("user sensitive field permissions", () => {
select: {
id: true,
bank_name: true,
bank_institution_id: true,
bank_account_encrypted: true,
bank_institution: { select: { id: true, code: true, name: true } },
},
});
expect(testApp.auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "SYSTEM_USER_SENSITIVE_VIEWED",
extra: { scopes: ["bank"] },
}),
});
expect(testApp.auditCreate).not.toHaveBeenCalled();
await testApp.app.close();
});
@@ -119,7 +117,7 @@ describe("user sensitive field permissions", () => {
const response = await testApp.app.inject({
method: "PATCH",
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
payload: { bankName: "新银行" },
payload: { bankAccount: "6222000099995678" },
});
expect(response.statusCode).toBe(403);
@@ -134,7 +132,7 @@ describe("user sensitive field permissions", () => {
const response = await testApp.app.inject({
method: "PATCH",
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
payload: { bankName: "新银行", idNumber: "110101199001011234" },
payload: { bankAccount: "6222000099995678", idNumber: "110101199001011234" },
});
expect(response.statusCode).toBe(403);
@@ -143,6 +141,33 @@ describe("user sensitive field permissions", () => {
await testApp.app.close();
});
it("reveals a full bank account only after a reason and records the view audit", async () => {
const testApp = await buildApp(["employee.bank.read"]);
const missingReason = await testApp.app.inject({
method: "POST",
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
payload: {},
});
expect(missingReason.statusCode).toBe(400);
expect(testApp.auditCreate).not.toHaveBeenCalled();
const response = await testApp.app.inject({
method: "POST",
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
payload: { reason: "付款核对" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({ bankAccount: "6222000012345678" });
expect(testApp.auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
extra: { reason: "付款核对" },
}),
});
await testApp.app.close();
});
it("writes only authorized fields and keeps plaintext out of the audit", async () => {
const testApp = await buildApp(["employee.bank.manage", "employee.identity.manage"]);
@@ -0,0 +1,174 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const {
PaymentOrderXlsError,
buildCustomerSerial,
generatePaymentOrderXls,
} = require("../../../src/services/paymentOrderXls");
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
const encrypt = (value) => encryptStringOrThrow(config, value);
function buildBatch(overrides = {}) {
return {
batch_no: "PB-20260802-002",
payment_account_snapshot_json: {
bankInstitutionCode: "CCB",
accountName: "合成测试公司",
bankName: "中国建设银行",
accountNumberEncrypted: encrypt("6217000000000012345"),
},
items: [
{
id: "b",
entity_code: "BX-20260802-0002",
amount: "2.00",
snapshot_json: { payee: { name: "乙", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } },
},
{
id: "a",
entity_code: "BX-20260802-0001",
amount: "1.20",
snapshot_json: { payee: { name: "甲", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000012345") } },
},
],
...overrides,
};
}
describe("payment order BIFF8 generator", () => {
it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => {
const first = generatePaymentOrderXls(config, buildBatch());
const second = generatePaymentOrderXls(config, buildBatch());
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
expect(first.rowCount).toBe(2);
expect(first.serials).toEqual(second.serials);
expect(first.totalAmount).toBe("3.20");
});
it("fails the whole export and reports every blocking payee issue", () => {
const batch = buildBatch();
batch.items[1].snapshot_json.payee.bankInstitutionCode = "OTHER";
batch.items[0].snapshot_json.payee.bankAccountEncrypted = null;
try {
generatePaymentOrderXls(config, batch);
throw new Error("expected payment order generation to fail");
} catch (error) {
expect(error).toBeInstanceOf(PaymentOrderXlsError);
expect(error.code).toBe("payee_bank_not_supported");
expect(error.details.blockers).toEqual(expect.arrayContaining([
{ code: "payee_bank_not_supported", entityCode: "BX-20260802-0001" },
{ code: "payee_account_unavailable", entityCode: "BX-20260802-0002" },
]));
}
});
it("does not invent a bank display name when the frozen snapshot is incomplete", () => {
const batch = buildBatch();
delete batch.items[0].snapshot_json.payee.bankName;
expect(() => generatePaymentOrderXls(config, batch)).toThrow(expect.objectContaining({
code: "payee_bank_name_missing",
details: {
blockers: expect.arrayContaining([
{ code: "payee_bank_name_missing", entityCode: "BX-20260802-0002" },
]),
},
}));
});
it("validates customer serial length and character set without truncation", () => {
expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-R001");
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
});
it("fails closed when the fixed Python runtime is unavailable", () => {
expect(() => generatePaymentOrderXls(config, buildBatch(), {
pythonBin: "/definitely-missing/payment-order-python",
})).toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
});
it("fails closed when the fixed Python dependencies are unavailable", () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-dependency-test-"));
try {
const fakePython = path.join(temporaryRoot, "python");
fs.writeFileSync(
fakePython,
"#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n",
{ mode: 0o700 },
);
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }))
.toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
it("fails closed when the controlled template hash drifts", () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-template-test-"));
try {
const scriptDirectory = path.join(temporaryRoot, "src", "scripts");
const assetDirectory = path.join(temporaryRoot, "src", "assets", "payment-order");
fs.mkdirSync(scriptDirectory, { recursive: true });
fs.mkdirSync(assetDirectory, { recursive: true });
const scriptPath = path.resolve(__dirname, "../../../src/scripts/fill-payment-order-template.py");
const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls");
const copiedScriptPath = path.join(scriptDirectory, "fill-payment-order-template.py");
const copiedTemplatePath = path.join(assetDirectory, "ccb-payment-order-template.xls");
fs.copyFileSync(scriptPath, copiedScriptPath);
const template = fs.readFileSync(templatePath);
template[template.length - 1] ^= 1;
fs.writeFileSync(copiedTemplatePath, template);
expect(() => generatePaymentOrderXls(config, buildBatch(), {
scriptPath: copiedScriptPath,
})).toThrow(expect.objectContaining({ code: "payment_order_template_unavailable" }));
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
it("does not copy child stderr or account payload into the exposed error", () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-stderr-test-"));
try {
const fakePython = path.join(temporaryRoot, "python");
fs.writeFileSync(fakePython, "#!/bin/sh\ntee /dev/stderr >/dev/null\nexit 70\n", { mode: 0o700 });
let caught;
try {
generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(PaymentOrderXlsError);
expect(caught.code).toBe("payment_order_generation_failed");
expect(JSON.stringify(caught)).not.toContain("6217000000000012345");
expect(JSON.stringify(caught)).not.toContain("6222000000000012345");
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
it.each([
["timeout", "#!/bin/sh\nwhile :; do :; done\n", { timeout: 20 }],
[
"oversized output",
"#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do printf X; i=$((i + 1)); done\n",
{ maxBuffer: 1024 },
],
])("fails closed on child %s", (_label, script, options) => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-process-test-"));
try {
const fakePython = path.join(temporaryRoot, "python");
fs.writeFileSync(fakePython, script, { mode: 0o700 });
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython, ...options }))
.toThrow(expect.objectContaining({ code: "payment_order_generation_failed" }));
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
});