Compare commits

..
Author SHA1 Message Date
腾讯WorkBuddy 826cb45bb0 B-248: 收口登录错误态、焦点与桌面总宽 2026-08-20 18:03:37 +08:00
腾讯WorkBuddyandCursor 79bd32f46e B-229: 登录页改为桌面双区布局并收紧三档响应式
把居中手机卡改回横向双区,端口选择复用分段控件节奏,避免再叠加一套登录样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:40:22 +08:00
腾讯WorkBuddyandCursor 4add17f2b8 B-44: 返修——768px 超大期末金额与未决状态避让
在 376–900px 五列目录把方向 chip 与期末金额分行,并加 Chromium 盒模型回归,避免 15 位金额盖住未决状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 03:22:13 +08:00
腾讯WorkBuddyandCursor 95ad9c939a B-44: 返修——375px 超大金额降级与审核弹窗必展字段
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:54:32 +08:00
腾讯WorkBuddyandCursor 1fe073066e B-44: 返修——余额目录视觉、八列事件表、抽屉键盘与审核三项决定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 00:37:27 +08:00
腾讯WorkBuddy f16e8aad34 B-44: 返修——多币种对方汇总、镜像科目筛选、冲销独立生效日、创建来源撤销边界与并发幂等 2026-08-19 13:28:28 +08:00
腾讯WorkBuddy d59576739f B-44: fix empty-state retry selector in balance directory 2026-08-19 12:00:59 +08:00
腾讯WorkBuddy 85293b79df B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current
  projections, source claims, subject suggestions, eligible_position_events
- ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen,
  append-only revision chain and rebuildable current projection
- subjects.py: fixed subject mirror, draft suggestion dictionary, explicit
  administrator subject confirmation with expected_revision + idempotency
- manual_records.py: submit, approve new/link, return/exception/reverse,
  candidate hints, idempotent replay and concurrency-safe claims
- positions.py: Decimal aggregation, both-perspective conservation asserts,
  cutoff window, unresolved gross buckets, keyset pagination, evidence
  visibility (visible/masked/missing)
- server.py: admin + company intercompany APIs with tenant isolation (404 on
  cross-tenant reads, 403 on company writes) and auto reconcile wiring
- admin/company portals: balance directory, pair drill-down drawer, evidence
  drawer, subject/manual audit queue, company balance summary
- tests: ledger events, subjects, manual records, positions, HTTP API and
  migration persistence (233 total, all green)
2026-08-19 11:58:25 +08:00
腾讯WorkBuddy f99917321b B-43: M3空参考号不再视为相等,单边intercompany需锁定才可计算 2026-08-18 22:10:27 +08:00
腾讯WorkBuddy df517d4a68 B-43: 双边流水归并与规范事件层——迁移5、匹配引擎、个人过账映射与事件API 2026-08-18 21:06:08 +08:00
腾讯WorkBuddy 486842963e B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据 2026-08-17 01:00:19 +08:00
腾讯WorkBuddy 7f1a93f6a6 B-64: 安全修复——随机初始密码与跨公司重复上传隔离
- 公司账号创建改为随机一次性初始密码,只在创建响应中显示一次,
  密码保证不等于用户名;删除「用户名即初始密码」兼容分支,继续
  强制首次登录改密。
- 跨公司相同字节文件上传只返回通用重复状态:不再返回其他公司的
  原批次 ID、银行、模板、期间、交易数或诊断;同公司重复上传的
  幂等摘要保持可用。
- 补充服务端回归测试,覆盖同公司与跨公司两个分支及随机密码;
  完整测试 85 项全绿,node --check 通过。
2026-08-16 01:50:42 +08:00
腾讯WorkBuddy 545837446c B-39~B-41: 服务端 API 与双端页面接入
- server.py 新增认证、批次/行/导出、公司/账号/别名主数据、审核
  与审计接口,全部在服务端实施租户隔离与权限校验。
- 前端接入真实登录、首次改密、公司账号、账户登记/审核、批次查询
  与上传持久化;移除演示账号密码预填。
- README 更新运行、登录与样本数据政策说明;HANDOFF 同步默认监听
  行为。DESIGN-TOKENS 与样式对齐当前中性石墨玻璃视觉。
2026-08-16 01:49:54 +08:00
腾讯WorkBuddy 51c301d9df B-41: 动态主数据——公司、银行账户、别名与审核轨迹
- 公司、公司账号、银行账户、账户类型、户名/账号别名和生效区间
  全部来自数据库,新增公司无需改代码即可检索与使用。
- 公司端提交账户登记,总账端审核为启用/退回/停用;未启用账户
  不参与所有权识别、上传或覆盖计算。
- 账号归一化、脱敏显示与数据库 UNIQUE 约束,并发提交不产生重复
  有效账户;别名匹配有优先级和有效期。
- 所有主数据变更记录前值、后值、操作人、时间和原因。
- 决策记录见 docs/decisions/004-master-data.md。
2026-08-16 01:49:03 +08:00
腾讯WorkBuddy f7aa4a8d06 B-40: 正式登录与租户隔离——认证、会话、权限与审计
- 管理员/公司两类角色:PBKDF2 密码散列、随机初始密码、首次改密、
  停用、重置密码与吊销会话。
- 会话只存令牌摘要,8 小时绝对过期;登录失败同 (账号,IP) 限流。
- 公司账号服务端绑定唯一公司;读取/上传/导出/主数据/审核接口逐项
  服务端授权,跨公司访问返回 404 而非 403。
- 审计日志记录登录、改密、上传、导出、建公司、建/停/启用户。
- 决策记录见 docs/decisions/003-auth.md。
2026-08-16 01:48:58 +08:00
腾讯WorkBuddy 763a940683 B-39: 数据库持久化与不可变导入
- 版本化 SQLite 迁移(向前/回滚)与连接助手。
- 导入管线:上传文件按 SHA-256 内容哈希不可变保存,文件/行/批次
  幂等;重复上传返回 duplicate 并复用已有批次,不产生第二份事实。
- 每条规范化源行反查文件、工作表、原始行号与模板版本。
- 解析失败保留 exception/failed 批次与诊断,不产生已确认交易。
- 决策记录见 docs/decisions/002-persistence.md。
2026-08-16 01:48:53 +08:00
腾讯WorkBuddy c292fb791d B-38: 样本数据安全——运行数据目录隔离
运行时数据、真实银行上传文件与导出文件一律不进仓库;样本仅限
流水模板/ 中已脱敏的六份。新增 data/、uploads/、exports/、日志与
PID 文件的忽略规则。
2026-08-16 01:48:49 +08:00
56 changed files with 21317 additions and 805 deletions
-13
View File
@@ -1,13 +0,0 @@
.git
.gitignore
.impeccable
.venv
__pycache__
*.py[cod]
*.xls
*.xlsx
screenshots
tests
docs
design-system
流水模板
-1
View File
@@ -1 +0,0 @@
APP_PORT=4173
+17
View File
@@ -2,3 +2,20 @@ __pycache__/
*.py[cod] *.py[cod]
.venv/ .venv/
.tmp-*/ .tmp-*/
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
data/
uploads/
exports/
*.local
server.pid
server.out.log
server.err.log
nul
# 本地协作工具运行目录
.multica/
.opencode/
.agent_context/
.kimi/
+6 -6
View File
@@ -26,12 +26,12 @@ Body type is `13px/1.55`. Page headlines use `clamp(26px, 3vw, 35px)` with `1.15
| Token | Value | Use | | Token | Value | Use |
| --- | --- | --- | | --- | --- | --- |
| `--color-bg` | `#050706` | Continuous application canvas | | `--color-bg` | `#050505` | Continuous application canvas |
| `--color-bg-soft` | `#090d0b` | Soft near-black tonal alternative | | `--color-bg-soft` | `#0a0a0a` | Soft near-black tonal alternative |
| `--color-nav` | `#090c0b` | Navigation frame fallback | | `--color-nav` | `#0a0a0a` | Navigation frame fallback |
| `--color-surface` | `rgba(20, 26, 23, 0.82)` | Primary graphite glass | | `--color-surface` | `rgba(22, 22, 22, 0.82)` | Primary neutral graphite glass |
| `--color-surface-solid` | `#131916` | Opaque graphite fallback | | `--color-surface-solid` | `#161616` | Opaque graphite fallback |
| `--color-surface-raised` | `#18201c` | Raised or denser surface | | `--color-surface-raised` | `#1c1c1c` | Raised or denser surface |
| `--color-surface-muted` | `rgba(255, 255, 255, 0.035)` | Quiet inset field and row treatment | | `--color-surface-muted` | `rgba(255, 255, 255, 0.035)` | Quiet inset field and row treatment |
### Structure and Ink ### Structure and Ink
-27
View File
@@ -1,27 +0,0 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/src \
APP_HOST=0.0.0.0 \
APP_PORT=4173
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir --requirement requirements.txt
COPY server.py ./
COPY src ./src
COPY web ./web
USER app
EXPOSE 4173
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 \
CMD ["python", "-c", "import os, urllib.request; port = os.getenv('APP_PORT', '4173'); urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=2)"]
CMD ["python", "server.py"]
+24 -16
View File
@@ -19,36 +19,44 @@ The original Excel file is never modified.
```powershell ```powershell
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
$env:PYTHONPATH = "src" $env:PYTHONPATH = "src"
python -m bank_importer.db # 创建/迁移 SQLite 数据库(默认 data/app.db
python -m bank_importer.cli "流水模板" python -m bank_importer.cli "流水模板"
python -m unittest discover -s tests -v python -m unittest discover -s tests -v
python server.py python server.py
``` ```
## Docker 部署 数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按
SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。
同一公司重复上传返回原批次摘要;不同公司相同内容只返回通用重复状态,
不暴露其他公司的批次标识或摘要。
技术决策见 `docs/decisions/002-persistence.md`
Windows PowerShell 一键启动: ## 登录与账号
```powershell 系统已启用真实身份认证(决策见 `docs/decisions/003-auth.md`):
.\start-docker.ps1
```
Linux 或 macOS 一键启动: - 首次启动若无管理员,自动创建引导管理员:用户名取环境变量
`APP_ADMIN_USERNAME`(默认 `group-admin`),密码取
`APP_BOOTSTRAP_ADMIN_PASSWORD`;未设置该变量则生成随机初始密码并
只在控制台打印一次。
- 引导管理员及管理员创建的公司账号首次登录都必须修改密码。
- 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或
`/api/admin/companies``/api/admin/users` 动态创建;
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
```sh 访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
./start-docker.sh
```
默认访问 `http://localhost:4173/`。如需更换宿主机端口,可运行
`.\start-docker.ps1 -Port 8080`,或运行 `APP_PORT=8080 ./start-docker.sh`
停止服务使用 `docker compose down`,查看日志使用 `docker compose logs -f app`
访问地址:
- 登录入口:`http://127.0.0.1:4173/` - 登录入口:`http://127.0.0.1:4173/`
- 总账管理端:`http://127.0.0.1:4173/admin.html` - 总账管理端:`http://127.0.0.1:4173/admin.html`
- 公司业务端:`http://127.0.0.1:4173/company.html` - 公司业务端:`http://127.0.0.1:4173/company.html`
公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器。当前前端是交互原型,公司、账号、期初、提醒和导入确认仅保存在当前浏览器页面中,尚未接入数据库和正式身份认证 公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器;解析结果、原始文件、批次和源行会持久化到 SQLite,并按登录账号绑定的公司隔离。当前前端是交互原型,期初、提醒、往来匹配等业务状态仅保存在当前浏览器页面中,尚未接入数据库。
## 样本数据政策
`流水模板/` 中的六份银行样本(中信、农行、工行、建行、河南农商、郑州银行)已经项目所有者(白栖知,2026-08-08)书面确认:**基于真实银行流水脱敏后的测试数据**,可以保留在仓库中作为解析器 fixture。新增银行样本的审核要求:必须先脱敏(户名、账号、对方信息等),由项目所有者确认后方可提交;真实生产流水、真实上传文件和导出文件一律不进仓库(见 `.gitignore` 中的 `data/``uploads/``exports/` 规则)。
The CLI prints batch summaries only. It does not print sensitive transaction The CLI prints batch summaries only. It does not print sensitive transaction
details. details.
-17
View File
@@ -1,17 +0,0 @@
services:
app:
build:
context: .
image: caiwuzongzhang:local
ports:
- "${APP_PORT:-4173}:4173"
environment:
APP_HOST: 0.0.0.0
APP_PORT: 4173
init: true
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -160,7 +160,7 @@ bank_importer 解析内核
### 测试与运行 ### 测试与运行
1. 现有 6 项测试只覆盖解析内核,没有 `/api/parse`、认证、导入持久化、匹配、核算、权限或端到端测试。 1. 现有 6 项测试只覆盖解析内核,没有 `/api/parse`、认证、导入持久化、匹配、核算、权限或端到端测试。
2. `server.py` 基于标准库开发服务器,绑定 `127.0.0.1`,没有生产部署、TLS、日志、备份或监控方案。 2. `server.py` 基于标准库开发服务器,默认绑定 `0.0.0.0`(可用 `APP_HOST` 覆盖),没有生产部署、TLS、日志、备份或监控方案。
3. `流水模板/` 已进入 Git 历史;必须确认均为合成或已脱敏数据,真实生产流水不得继续提交到仓库。 3. `流水模板/` 已进入 Git 历史;必须确认均为合成或已脱敏数据,真实生产流水不得继续提交到仓库。
## 6. 已作出的重要技术决策及原因 ## 6. 已作出的重要技术决策及原因
+44
View File
@@ -0,0 +1,44 @@
# 002 持久化层技术决策
对应 IssueB-39`docs/issues/002-p0-persistence-and-immutable-imports.md`)。
## 数据库:SQLitePython 标准库 `sqlite3`
- 单文件、事务完整、零新增依赖,与当前标准库服务器和离线内网部署环境匹配。
- 外键约束、CHECK 约束、唯一约束和触发器均可用,足以承载不可变证据模型。
- 金额以 `TEXT` 保存 `Decimal` 的原始字符串,读取时还原为 `Decimal`
绝不经过二进制浮点。
- 后续若并发写入成为瓶颈,可开启 WAL 或平迁 PostgreSQL;迁移版本表
`schema_migrations` 不绑定具体引擎方言之外的特性。
## 迁移工具:仓库内置版本化迁移器(`src/bank_importer/db.py`
- 每条迁移包含 `up` / `down` 两段 SQL,按版本号顺序执行并记录在
`schema_migrations` 表中;重复执行无副作用。
- 不引入 Alembic 等外部工具:当前模型规模小,内置迁移器保持零依赖,
且回滚路径明确(`python -m bank_importer.db <db路径> --rollback-to <版本>`)。
- 服务启动时自动执行 `migrate`,空数据库即可完整建表。
## 文件存储:内容寻址的本地文件系统(`data/files/`
- 上传文件先计算 SHA-256,再写入 `data/files/<哈希前两位>/<完整哈希>.<扩展名>`
- 发布采用临时文件 + 硬链接:目标要么完整出现、要么不存在,且永不覆盖。
- 相同内容(即使文件名不同)只保存一份文件、一条 `source_files` 记录。
- `data/` 已加入 `.gitignore`,真实流水不进入版本库。
## 幂等与状态机
- 文件级幂等:`source_files.sha256` 唯一约束。重复上传插入一条
`status='duplicate'` 的批次审计记录,指向首个批次,不产生第二份事实。
- 行级幂等:`source_rows``UNIQUE (sheet_batch_id, source_row)`
- 批次状态:`parsing → parsed | exception | failed`,另有 `duplicate`
失败/异常批次保留 `import_exceptions` 诊断(阶段、消息、原始文件名),
不产生任何已确认源行。
- 事务边界:文件落盘后,`source_files` + 批次 + 工作表批次 + 源行在单个
SQLite 事务内提交;解析失败时批次状态与异常记录同样在事务内落库。
## 不可变性
- `source_files``sheet_batches``source_rows` 三张表由数据库触发器禁止
`UPDATE``DELETE`,任何修改只能走后续阶段的冲销/审计调整流程。
- 该约束由 `tests/test_persistence.py` 自动化验证。
+66
View File
@@ -0,0 +1,66 @@
# 003 身份认证与租户隔离技术决策
对应 IssueB-40`docs/issues/003-p0-auth-and-tenant-isolation.md`)。
## 密码散列:标准库 PBKDF2-HMAC-SHA256
- 只使用 Python 标准库(`hashlib.pbkdf2_hmac` + `hmac.compare_digest` +
`secrets`),不引入 bcrypt/argon2 等第三方依赖,与离线内网部署约束一致。
- 260 000 次迭代、16 字节随机盐,存储格式
`pbkdf2_sha256$<迭代数>$<盐hex>$<摘要hex>`,自描述、可平滑升级参数。
- 明文密码永不入库、永不进日志、永不写入 `audit_log`
`password_hash` 字段从不出现在任何 API 响应中(有测试断言)。
## 会话:数据库保存令牌摘要,8 小时绝对过期
- 令牌为 `secrets.token_urlsafe(32)`,数据库只存其 SHA-256 摘要;
数据库泄露不直接暴露可用令牌。
- 绝对过期 8 小时(一个工作班次),不做滑动续期,语义简单可测。
- 会话可吊销:退出登录、停用账号、重置密码都会立即吊销该用户全部会话。
- Cookie 名 `cw_session``HttpOnly; SameSite=Lax; Path=/`
当前是纯 HTTP 的局域网部署,**刻意不加 `Secure`**(加了浏览器会直接拒发);
若未来上 HTTPS,应补上 `Secure` 并配置反向代理。
## 登录限流:同一 (账号, IP) 10 分钟内失败 5 次即锁定
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
且在窗口内不再记录新尝试,行为确定、可测试。
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
## 角色与公司绑定
- `users.role``admin` / `company`;数据库 CHECK 约束强制
公司账号必须绑定公司、管理员不得绑定公司。
- 登录时前端选择工作端口(portal),服务端校验 portal 与角色一致,
不匹配返回 403「账号与该工作端口不匹配」。
- 管理员创建公司账号时,初始密码为随机一次性密码并置
`must_change_password=1`;首次登录必须改密,改密前所有业务 API 返回 403。
初始密码只在创建成功响应中显示一次,永不入库明文、永不写日志;
创建用户的审计 detail 只记录公司,不重复账号名。
- 「重置密码」同样生成随机一次性密码并吊销既有会话。
## 租户隔离在服务端强制,404 优于 403
- 公司用户的批次列表、行明细、CSV 导出、上传归属全部由服务端按
会话中的 `company_id` 过滤;请求体/参数里的 `company_id` 对公司用户
一律忽略(跨公司上传防护)。
- 访问他公司批次返回 404 而非 403:不暴露「该批次存在但属于别人」这一事实,
避免 IDOR 探测。导出时显式指定他公司 `company_id` 仍返回 403
因为用户已声明知道该公司存在,此时给出明确拒绝更有操作性。
- `GET /admin.html``/company.html` 的 302 跳转只是 UX 层引导,
不是权限边界;真正的边界全部在 API 上。
## 引导管理员(bootstrap
- 服务启动迁移后若无任何 admin 账号,创建 `APP_ADMIN_USERNAME`
(默认 `group-admin`);密码取 `APP_BOOTSTRAP_ADMIN_PASSWORD`
未设置则生成随机初始密码并**只打印一次到 stdout**(不写日志文件),
`must_change_password=1`
- 不在迁移或代码中预置任何公司或用户;公司与公司账号全部由管理员
通过 `/api/admin/companies``/api/admin/users` 动态创建。
## 审计
- `audit_log` 记录登录成功/失败、退出、改密、上传、导出(含公司范围与行数)、
建公司、建/停/启用户、重置密码等动作,含操作者、目标、IP、时间。
- 初始密码、新旧密码均不进入审计内容。
+47
View File
@@ -0,0 +1,47 @@
# 004 动态主数据技术决策
对应 IssueB-41`docs/issues/004-p1-dynamic-master-data.md`)。
## 账户状态机与生效区间
- `bank_accounts.status``pending → active → disabled`,审核退回为
`returned`;退回后同公司重新提交复用同一行并回到 `pending`,不产生
第二条记录。停用这个动作只对 `active` 开放,退回只对 `pending` 开放,
越界操作返回 409。
- 生效区间 `effective_from` / `effective_to`(含当天):公司提交时填写
期望启用日,管理员审核通过时确认;停用时写入 `effective_to`(默认当天)。
- 两个判定函数分开:
- `is_usable`(上传选择、当期操作):仅 `active` 且在生效区间内。
- `is_identifiable`(历史流水所有权识别):`active``disabled` 且在
生效区间内——停用只终止未来参与,不改写历史归属。
## 账号规范化与唯一性
- 账号入库前统一规范化:去空格/短横线、全角数字转半角,只存 6–32 位
数字的规范形式;唯一性由数据库 `UNIQUE(account_number)` 保证,并发
提交必然只有一个成功(有并发测试)。
- 公司端任何响应只含脱敏形式 `****尾四位`;完整账号只在总账管理端
(授权审计视图)返回。审计日志 detail 也只写脱敏账号。
## 别名匹配优先级
- `account_aliases` 支持户名(`name`)与账号(`account`)两类别名,
各自带生效区间和 `priority` 列。
- 匹配优先级确定:精确账号(0)< 账号别名(1000+priority<
户名别名(2000+priority);同层内按 `priority` 再按账户 id 排序。
别名只在自身生效区间且账户 `is_identifiable` 时参与匹配。
## 审计轨迹
- 所有主数据变更写入 `master_data_changes`:实体、动作、前值 JSON、
后值 JSON、原因、操作人、时间。公司创建、账户提交/退回重提/
审核/停用、别名创建全部覆盖,管理员可按实体查询。
## 前端边界
- 公司下拉(往来查询、审核筛选、流水筛选、提醒、期初)和公司表格全部
`/api/admin/companies` 渲染,新增公司无需改代码即可被检索。
- 公司端账户目录、上传账户选择、手工记录资金来源只来自
`/api/company/accounts`,且只有 `usable` 账户进入上传选择。
- 手工记录仍是浏览器 localStorage 演示数据(属 Issue 007/012 范围),
账户登记已完全切换到服务端。
@@ -0,0 +1,63 @@
# 005 规范转账事件与双边归并技术决策
对应 IssueB-43/B-83/B-114`docs/issues/006-p1-canonical-transfer-matching.md`)。
## 分层模型
- 银行源行是不可变观察;匹配层从不修改 `source_rows`
- 新增“不可变决定日志 + 可重建当前投影”的规范事件层:
- `transfer_match_decisions` 等证据表只增不改(UPDATE/DELETE 被触发器拒绝)。
- `current_transfer_decisions``transfer_observation_claims` 是可更新、可重建的
当前投影,`source_row_id` 主键保证一条观察不可能同时属于两个当前事件。
- B-44 余额计算只读 `eligible_intercompany_events` 视图,只含
`active + current + classification='intercompany'`,且单边事件必须
`locked = 1`(管理员按证据确认)才可计算;paired 事件不受锁定状态限制;
单边内部、待审、同公司调拨、外部事件全部排除。
## 已确认业务口径(B-114
1. 单边流水即使双方公司都能由批准账号唯一确认,也先进入未决金额,不进入 B-44
已确认往来余额;只有完成双边归并(`intercompany` + `paired`)或管理员按证据确认
`intercompany` + locked 单边)后才可计算。
2. 跨日事件经济日期取付款方 outgoing 银行入账时间,与导入顺序无关。
3. 自动窗口 v1:非空参考号相同且账号镜像 <= 3 自然日(M1);无参考号精确账号镜像
<= 1 自然日(M2);别名/个人映射镜像 <= 1 自然日且参考号或摘要相等(M3)。
超窗或同层多候选全部进入人工审核,禁止按行号、导入顺序或名字破平局。
4. 个人过账映射绑定具体账号、生效区间、允许方向和代表公司,必须管理员批准;
姓名只作辅助证据,不能单独定案。
## 参与方解析
- 本方:源行 `own_account` 精确命中生效期内已批准账户(`own_exact`);缺失时才用
上传时持久化的批准上传账户(`upload_account`),且必须与批次公司一致。冲突进入
审核,不以 `company_id` 或户名反推。
- 对方:严格按账号层解析——精确账号 -> 已审核账号别名 -> 已批准且在窗口内、方向
允许的个人过账映射;每层唯一最佳结果才继续。户名/户名别名只用于一致性/冲突证据,
冲突降级到审核。
- 方向:`expense > 0 且 income = 0` 为 outgoing`income > 0 且 expense = 0`
incoming;双正/双零/负数/缺币种进入 `unresolved`。金额用 `Decimal` 精确相等,
不设手续费容差。
## 匹配与状态机
- 候选硬门槛:双方工作表 confirmed、方向相反、金额与币种相等、公司端点互为反向。
- M1/M2/M3 唯一候选自动 paired;R1(端点可确认但镜像/参考号证据不足)与 R2(同层
多候选)进入 `needs_review`;待审行不能被其他自动匹配抢占。
- 状态:`unresolved -> internal_single -> matched`、`needs_review -> matched |
same_company_transfer | external`;单边确认两端同公司立即为同公司调拨。
- 幂等:rule_version + 当前观察集合 + 证据未变时零写入;locked 人工决定自动重跑
跳过;纠错只能通过 `reverse``mode='reversal'` 新记录)+ 原因,之后允许重跑。
## 事务、并发与回滚
- “确认工作表 + 为新增确认行建立/更新事件 + audit”在同一个服务事务,任一失败整体
回滚,不存在 confirmed 但未匹配的半成品。
- 独立重跑使用 `BEGIN IMMEDIATE`,按 `source_row_id` 升序写,SQLite 30 秒 busy
timeout 保留;投影可从只增日志重建(`rebuild_current_projection`)。
- migration down 只用于测试/上线前回退;生产产生决定后默认保留 migration 5 与审计
数据,不执行破坏性 down。
## Decimal
金额全程以规范十进制字符串存储和比较,任何路径都不转 `float``100.0` 与 `100.00`
视为相等,`100.00` 与 `100.01` 不匹配。
@@ -0,0 +1,87 @@
# 006: Intercompany positions, subject review and drill-down evidence (B-44)
Status: accepted (2026-08-19)
## Decision
The intercompany ledger adds one canonical layer above B-43's eligible events:
- **Append-only ledger events.** A canonical `ledger_event` carries an
append-only `ledger_event_revisions` chain. A B-43 eligible bank event first
becomes a `pending_subject` revision; an administrator confirms the subject
into a `confirmed` revision. Corrections are never in-place edits — a
reversal or adjustment is a *new* ledger event with its own effective date,
and the original event keeps its history so earlier cutoffs are not
rewritten.
- **Manual records as immutable submitted facts.** Companies submit
`manual_records`; only an administrator-approved record becomes a confirmed
ledger event (`approve_new`) or joins one (`approve_link`). Returned,
exception and pending records never affect a balance and never leak to the
counterparty. Approved facts change only through `reverse` (a new opposite
event) — the original is never edited.
- **One perspective, fixed mirror.** Subjects are stored from one participating
company's perspective (`receivable/payable/other_receivable/other_payable`);
the other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the
two companies can never book conflicting subjects.
- **Subjects are confirmed, never auto-posted.** Bank summary/purpose text only
feeds a deterministic *suggestion* dictionary (`subject-suggest-draft-v1`,
not group-approved). Without an approved trade dictionary every bank event
stays in subject review until an administrator confirms.
- **Decimal-only aggregation.** All money is stored as TEXT decimal strings and
aggregated with Python `Decimal`. SQLite `SUM`, JavaScript `Number` and
Python `float` never touch financial math. Different currencies are
aggregated and displayed separately; nothing is converted to a group total.
- **Conservation is asserted.** For every company pair and currency the two
perspectives must mirror exactly (`C_A == -C_B`); a violation raises a
calculation exception instead of rendering an unbalanced number.
- **Unresolved is absolute gross.** Unresolved amounts are summed by absolute
value per currency (never netted), broken down by reason
(`subject_review`, `unmatched_single`, `manual_pending`), with count and
gross amount exposed on every balance response.
- **B-45 boundary.** Until the B-45 opening balance exists, every response
returns `opening.status=unavailable`, `opening.amount=null` and
`result.kind=period_net_change`; the UI labels this "期间净变动", never
"期末余额".
## Background
B-43 produces `eligible_intercompany_events` as the only bank-event entry
point. Before B-44 there was no canonical financial event, no statutory
subject, no manual-record approval, and no server-side balance API. The
revision-chain design is inherited from `transfer_match_decisions` in
migration 5 and from the append-only audit posture of the rest of the system.
## Consequences
- **Positive:** balances are deterministic, conservable, auditable and
drillable from a group directory down to bank source rows; manual records
cannot double count; corrections never mutate evidence; company portals are
tenant-scoped on the server.
- **Negative:** pending bank events and returned/exception manual records are
intentionally invisible to counterparties, which can surprise cashiers who
expect symmetric disclosure; subject confirmation is manual until a
group-approved dictionary exists.
- **Operational:** migration 6 is forward-only for production once
approvals/revisions exist; pre-production it can be rolled back with
`--rollback-to 5`. Read aggregation runs against the rebuildable current
projection (no day snapshots yet); if B-45 monthly close needs them, immutable
monthly snapshots can be added behind the same API contract.
## Files
- `src/bank_importer/ledger_events.py` — event lifecycle, revision chain,
bank reconciliation, reversal/adjustment/reopen, projection rebuild.
- `src/bank_importer/subjects.py` — subject constants/mirror, suggestion
dictionary, `confirm_subject`.
- `src/bank_importer/manual_records.py` — submission, approval
(new/link), return/exception/reverse, candidate hints, idempotency.
- `src/bank_importer/positions.py` — Decimal aggregation, directories, pairs,
events, evidence visibility, unresolved buckets, keyset pagination.
- `server.py``/api/admin/intercompany/*` and `/api/company/intercompany/*`
plus `/api/admin/subject-reviews` and `/api/admin/manual-records`.
- `db.py` migration 6 — `manual_records`, `manual_record_decisions`,
`ledger_events`, `ledger_event_revisions`, current-pointer projections,
source claim tables, `ledger_subject_suggestions`, `eligible_position_events`.
- Tests: `test_ledger_events.py`, `test_manual_records.py`,
`test_positions.py`, `test_positions_api.py`, extended
`test_persistence.py`.
+2877 -62
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
"""Authentication, sessions, rate limiting and audit logging.
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib ``hashlib.pbkdf2_hmac``)
and per-user random salts; plaintext passwords are never stored or logged.
Session tokens are random URL-safe strings; only their SHA-256 digest is
persisted, so a database leak does not expose usable tokens. Every login
attempt and every privileged action lands in ``audit_log``. The reasoning
behind these choices is recorded in ``docs/decisions/003-auth.md``.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
import sqlite3
import string
from .db import utc_now
MIN_PASSWORD_LENGTH = 8
PBKDF2_ITERATIONS = 260_000
SESSION_TTL_HOURS = 8
RATE_LIMIT_MAX_FAILURES = 5
RATE_LIMIT_WINDOW_MINUTES = 10
INITIAL_PASSWORD_LENGTH = 12
def hash_password(password: str) -> str:
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS
)
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
"""Constant-time check of ``password`` against a stored hash string."""
try:
scheme, iterations, salt_hex, hash_hex = stored.split("$")
if scheme != "pbkdf2_sha256":
return False
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(hash_hex)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, int(iterations)
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(digest, expected)
def generate_initial_password(exclude: str | None = None) -> str:
"""Generate a 12-char initial password with upper, lower and digit chars.
When ``exclude`` is given, the result is guaranteed to differ from it
(case-insensitive) so a fresh account never starts with a password equal
to its own username.
"""
alphabet = string.ascii_letters + string.digits
while True:
password = "".join(
secrets.choice(alphabet) for _ in range(INITIAL_PASSWORD_LENGTH)
)
if (
any(char.isupper() for char in password)
and any(char.islower() for char in password)
and any(char.isdigit() for char in password)
and (exclude is None or password.lower() != exclude.lower())
):
return password
def validate_password_policy(password: str, username: str) -> str | None:
"""Return an error message when ``password`` violates policy, else None."""
if len(password) < MIN_PASSWORD_LENGTH:
return f"密码长度至少为 {MIN_PASSWORD_LENGTH} 位。"
if password.lower() == username.lower():
return "密码不能与账号相同。"
if not any(char.isalpha() for char in password) or not any(
char.isdigit() for char in password
):
return "密码必须同时包含字母和数字。"
return None
def create_user(
connection: sqlite3.Connection,
username: str,
password: str,
role: str,
company_id: int | None = None,
must_change_password: bool = True,
) -> int:
"""Create a user, enforcing the role/company binding rules. Returns the id."""
username = username.strip()
if not username:
raise ValueError("用户名不能为空。")
if role not in ("admin", "company"):
raise ValueError("角色必须是 admin 或 company。")
if role == "company":
if company_id is None:
raise ValueError("公司账号必须绑定公司。")
company = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
raise ValueError("绑定的公司不存在。")
elif company_id is not None:
raise ValueError("管理员账号不能绑定公司。")
now = utc_now()
try:
with connection:
cursor = connection.execute(
"""
INSERT INTO users (
username, password_hash, role, company_id,
must_change_password, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
username,
hash_password(password),
role,
company_id,
1 if must_change_password else 0,
now,
now,
),
)
except sqlite3.IntegrityError as exc:
raise ValueError("用户名已存在。") from exc
return int(cursor.lastrowid)
def authenticate(
connection: sqlite3.Connection, username: str, password: str, ip: str
) -> tuple[sqlite3.Row | None, str | None]:
"""Verify credentials; returns ``(user_row, None)`` or ``(None, reason)``.
``reason`` is one of ``rate_limited``, ``disabled``, ``bad_credentials``.
Every non-rate-limited attempt is recorded in ``login_attempts`` and
``audit_log``; the password itself is never stored anywhere.
"""
window_start = (
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
).isoformat()
failures = connection.execute(
"""
SELECT COUNT(*) AS n FROM login_attempts
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
""",
(username, ip, window_start),
).fetchone()
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
return None, "rate_limited"
user = connection.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
if user is not None and user["status"] == "disabled":
_record_attempt(connection, username, ip, success=False)
audit(
connection,
"login_failed",
actor=user,
detail="账号已停用",
ip=ip,
)
return None, "disabled"
if user is None or not verify_password(password, user["password_hash"]):
_record_attempt(connection, username, ip, success=False)
audit(connection, "login_failed", actor=user, detail="账号或密码不正确", ip=ip)
return None, "bad_credentials"
_record_attempt(connection, username, ip, success=True)
audit(connection, "login_success", actor=user, ip=ip)
return user, None
def create_session(
connection: sqlite3.Connection, user_id: int, ttl_hours: int = SESSION_TTL_HOURS
) -> str:
"""Create a session with absolute expiry; returns the raw token."""
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
now = datetime.now(timezone.utc)
with connection:
connection.execute(
"""
INSERT INTO sessions (token_hash, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(
token_hash,
user_id,
now.isoformat(),
(now + timedelta(hours=ttl_hours)).isoformat(),
),
)
return token
def resolve_session(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None:
"""Return the user row for a live session token, else None.
Expired or revoked sessions and disabled users are all rejected.
"""
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
return connection.execute(
"""
SELECT u.*, s.id AS session_id
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.revoked_at IS NULL
AND s.expires_at > ?
AND u.status = 'active'
""",
(token_hash, utc_now()),
).fetchone()
def revoke_session(connection: sqlite3.Connection, token: str) -> None:
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
with connection:
connection.execute(
"UPDATE sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL",
(utc_now(), token_hash),
)
def revoke_user_sessions(connection: sqlite3.Connection, user_id: int) -> None:
with connection:
connection.execute(
"UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
(utc_now(), user_id),
)
def change_password(
connection: sqlite3.Connection,
user_id: int,
old_password: str,
new_password: str,
) -> str | None:
"""Change a user's password; returns an error message or None on success."""
user = connection.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
if user is None:
return "用户不存在。"
if not verify_password(old_password, user["password_hash"]):
return "原密码不正确。"
error = validate_password_policy(new_password, user["username"])
if error is not None:
return error
with connection:
connection.execute(
"""
UPDATE users
SET password_hash = ?, must_change_password = 0, updated_at = ?
WHERE id = ?
""",
(hash_password(new_password), utc_now(), user_id),
)
audit(connection, "password_change", actor=user, target=f"user:{user_id}")
return None
def audit(
connection: sqlite3.Connection,
action: str,
actor: sqlite3.Row | None = None,
target: str | None = None,
detail: str | None = None,
ip: str | None = None,
) -> None:
"""Append an audit log entry. Never pass passwords in ``detail``."""
with connection:
connection.execute(
"""
INSERT INTO audit_log (
actor_user_id, actor_username, action, target, detail, ip, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
actor["id"] if actor is not None else None,
actor["username"] if actor is not None else None,
action,
target,
detail,
ip,
utc_now(),
),
)
def _record_attempt(
connection: sqlite3.Connection, username: str, ip: str, success: bool
) -> None:
with connection:
connection.execute(
"""
INSERT INTO login_attempts (username, ip, success, created_at)
VALUES (?, ?, ?, ?)
""",
(username, ip, 1 if success else 0, utc_now()),
)
+903
View File
@@ -0,0 +1,903 @@
"""SQLite persistence: versioned migrations and connection helpers.
The database, migration runner and file storage choices are recorded in
``docs/decisions/002-persistence.md``. Migrations are plain SQL applied in
version order; each records itself in ``schema_migrations`` so re-running
``migrate`` on an existing database is a no-op. Every migration ships a
``down`` script so ``rollback`` can walk backwards for recovery and tests.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import sqlite3
DEFAULT_DB_PATH = Path("data/app.db")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class Migration:
version: int
name: str
up: str
down: str
MIGRATIONS: tuple[Migration, ...] = (
Migration(
version=1,
name="0001_core_persistence",
up="""
CREATE TABLE companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE bank_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies (id),
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
bank_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'disabled')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE source_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL UNIQUE,
original_filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
storage_path TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE import_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_file_id INTEGER NOT NULL REFERENCES source_files (id),
status TEXT NOT NULL
CHECK (status IN ('parsing', 'parsed', 'exception', 'failed', 'duplicate')),
duplicate_of_id INTEGER REFERENCES import_batches (id),
diagnostics TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE sheet_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
sheet_name TEXT NOT NULL,
bank_name TEXT NOT NULL,
template_id TEXT NOT NULL,
template_version INTEGER NOT NULL,
header_row INTEGER NOT NULL,
own_account TEXT,
own_name TEXT,
period_start TEXT,
period_end TEXT,
transaction_count INTEGER NOT NULL,
warnings TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
UNIQUE (import_batch_id, sheet_name)
);
CREATE TABLE source_rows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_batch_id INTEGER NOT NULL REFERENCES sheet_batches (id),
source_row INTEGER NOT NULL,
transaction_at TEXT NOT NULL,
income TEXT NOT NULL,
expense TEXT NOT NULL,
balance TEXT,
own_account TEXT,
own_name TEXT,
counterparty_account TEXT,
counterparty_name TEXT,
counterparty_bank TEXT,
summary TEXT,
purpose TEXT,
reference TEXT,
currency TEXT,
created_at TEXT NOT NULL,
UNIQUE (sheet_batch_id, source_row)
);
CREATE TABLE import_exceptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
stage TEXT NOT NULL,
message TEXT NOT NULL,
diagnostics TEXT,
created_at TEXT NOT NULL
);
CREATE TRIGGER source_files_no_update BEFORE UPDATE ON source_files
BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
CREATE TRIGGER source_files_no_delete BEFORE DELETE ON source_files
BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
CREATE TRIGGER sheet_batches_no_update BEFORE UPDATE ON sheet_batches
BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
CREATE TRIGGER sheet_batches_no_delete BEFORE DELETE ON sheet_batches
BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
CREATE TRIGGER source_rows_no_update BEFORE UPDATE ON source_rows
BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
CREATE TRIGGER source_rows_no_delete BEFORE DELETE ON source_rows
BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
""",
down="""
DROP TRIGGER IF EXISTS source_rows_no_delete;
DROP TRIGGER IF EXISTS source_rows_no_update;
DROP TRIGGER IF EXISTS sheet_batches_no_delete;
DROP TRIGGER IF EXISTS sheet_batches_no_update;
DROP TRIGGER IF EXISTS source_files_no_delete;
DROP TRIGGER IF EXISTS source_files_no_update;
DROP TABLE IF EXISTS import_exceptions;
DROP TABLE IF EXISTS source_rows;
DROP TABLE IF EXISTS sheet_batches;
DROP TABLE IF EXISTS import_batches;
DROP TABLE IF EXISTS source_files;
DROP TABLE IF EXISTS bank_accounts;
DROP TABLE IF EXISTS companies;
""",
),
Migration(
version=2,
name="0002_auth_and_sessions",
up="""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'company')),
company_id INTEGER REFERENCES companies (id),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role != 'company' OR company_id IS NOT NULL),
CHECK (role != 'admin' OR company_id IS NULL)
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users (id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip TEXT NOT NULL,
success INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
ip TEXT,
created_at TEXT NOT NULL
);
ALTER TABLE import_batches ADD COLUMN company_id INTEGER REFERENCES companies (id);
""",
down="""
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS login_attempts;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
ALTER TABLE import_batches DROP COLUMN company_id;
""",
),
Migration(
version=3,
name="0003_dynamic_master_data",
# bank_accounts is rebuilt (SQLite cannot alter CHECK constraints):
# status gains 'returned', and the account gains type, effective
# interval and review fields. account_number stays UNIQUE and stores
# the normalized digits-only form (see master_data.normalize).
up="""
CREATE TABLE bank_accounts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
bank_name TEXT NOT NULL DEFAULT '',
account_type TEXT NOT NULL DEFAULT '一般户'
CHECK (account_type IN ('基本户', '一般户', '专用户')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
effective_from TEXT,
effective_to TEXT,
submitted_by INTEGER REFERENCES users (id),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO bank_accounts_new (
id, company_id, account_number, account_name, bank_name,
status, created_at, updated_at
)
SELECT id, company_id, account_number, account_name,
COALESCE(bank_name, ''), status, created_at, updated_at
FROM bank_accounts WHERE company_id IS NOT NULL;
DROP TABLE bank_accounts;
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
CREATE TABLE account_aliases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
alias_kind TEXT NOT NULL CHECK (alias_kind IN ('name', 'account')),
alias_value TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
effective_from TEXT,
effective_to TEXT,
created_by INTEGER REFERENCES users (id),
created_at TEXT NOT NULL,
UNIQUE (bank_account_id, alias_kind, alias_value)
);
CREATE TABLE master_data_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
ALTER TABLE companies ADD COLUMN credit_code TEXT;
ALTER TABLE companies ADD COLUMN cashier_name TEXT;
ALTER TABLE companies ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'preparing', 'disabled'));
""",
down="""
ALTER TABLE companies DROP COLUMN status;
ALTER TABLE companies DROP COLUMN cashier_name;
ALTER TABLE companies DROP COLUMN credit_code;
DROP TABLE IF EXISTS master_data_changes;
DROP TABLE IF EXISTS account_aliases;
CREATE TABLE bank_accounts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies (id),
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
bank_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'disabled')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO bank_accounts_new (
id, company_id, account_number, account_name, bank_name,
status, created_at, updated_at
)
SELECT id, company_id, account_number, account_name, bank_name,
CASE WHEN status = 'returned' THEN 'pending' ELSE status END,
created_at, updated_at
FROM bank_accounts;
DROP TABLE bank_accounts;
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
""",
),
Migration(
version=4,
name="0004_per_sheet_reviews",
# Per-worksheet lifecycle: the parse outcome (parsed/exception/ignored)
# is immutable evidence captured at import time; the human decision
# (pending/confirmed/ignored) is the audit-gated gate that lets a
# worksheet participate in later matching and calculation.
up="""
CREATE TABLE sheet_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
sheet_name TEXT NOT NULL,
outcome TEXT NOT NULL
CHECK (outcome IN ('parsed', 'exception', 'ignored')),
message TEXT,
scanned_rows INTEGER,
candidate_headers TEXT,
sheet_batch_id INTEGER REFERENCES sheet_batches (id),
review_status TEXT NOT NULL DEFAULT 'pending'
CHECK (review_status IN ('pending', 'confirmed', 'ignored')),
review_reason TEXT,
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
created_at TEXT NOT NULL,
UNIQUE (import_batch_id, sheet_name)
);
CREATE INDEX idx_sheet_reviews_batch ON sheet_reviews (import_batch_id);
CREATE TRIGGER sheet_reviews_evidence_immutable BEFORE UPDATE ON sheet_reviews
BEGIN
SELECT RAISE (ABORT, 'sheet_reviews parse evidence is immutable')
WHERE OLD.outcome != NEW.outcome
OR OLD.message IS NOT NEW.message
OR OLD.scanned_rows IS NOT NEW.scanned_rows
OR OLD.candidate_headers IS NOT NEW.candidate_headers
OR OLD.sheet_batch_id IS NOT NEW.sheet_batch_id
OR OLD.import_batch_id IS NOT NEW.import_batch_id
OR OLD.sheet_name IS NOT NEW.sheet_name;
END;
""",
down="""
DROP TRIGGER IF EXISTS sheet_reviews_evidence_immutable;
DROP INDEX IF EXISTS idx_sheet_reviews_batch;
DROP TABLE IF EXISTS sheet_reviews;
""",
),
Migration(
version=5,
name="0005_canonical_transfer_matching",
# Canonical transfer event layer (B-43). Immutable source rows stay
# untouched; every matching decision, its participant/observation
# bindings and its candidate evidence are append-only history, and the
# current decision + row claims are a rebuildable projection that the
# B-44 balance layer reads through the eligible view.
up="""
ALTER TABLE import_batches ADD COLUMN upload_bank_account_id INTEGER REFERENCES bank_accounts (id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias', 'personal_transit_mapping')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
CREATE TABLE personal_transit_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
represented_company_id INTEGER NOT NULL REFERENCES companies (id),
allowed_direction TEXT NOT NULL
CHECK (allowed_direction IN ('outgoing', 'incoming', 'both')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
effective_from TEXT,
effective_to TEXT,
submitted_by INTEGER REFERENCES users (id),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE canonical_transfer_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lifecycle TEXT NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('active', 'superseded')),
created_at TEXT NOT NULL
);
CREATE TABLE transfer_match_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id),
revision INTEGER NOT NULL,
classification TEXT NOT NULL CHECK (classification IN (
'unresolved', 'needs_review', 'internal_single',
'intercompany', 'same_company', 'external'
)),
pairing TEXT NOT NULL
CHECK (pairing IN ('single', 'paired', 'not_applicable')),
amount TEXT,
currency TEXT,
effective_at TEXT,
mode TEXT NOT NULL CHECK (mode IN ('auto', 'manual', 'reversal')),
rule_version TEXT,
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
reason TEXT,
idempotency_key TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_decision_id INTEGER REFERENCES transfer_match_decisions (id),
created_at TEXT NOT NULL,
UNIQUE (event_id, revision)
);
CREATE TABLE transfer_decision_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
source_row_id INTEGER NOT NULL REFERENCES source_rows (id),
role TEXT NOT NULL CHECK (role IN ('outgoing', 'incoming')),
created_at TEXT NOT NULL,
UNIQUE (decision_id, source_row_id)
);
CREATE TABLE transfer_decision_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
role TEXT NOT NULL CHECK (role IN ('payer', 'payee')),
company_id INTEGER REFERENCES companies (id),
bank_account_id INTEGER REFERENCES bank_accounts (id),
resolve_method TEXT NOT NULL CHECK (resolve_method IN (
'own_exact', 'upload_account', 'counterparty_exact',
'account_alias', 'personal_mapping', 'manual'
)),
alias_id INTEGER REFERENCES account_aliases (id),
mapping_id INTEGER REFERENCES personal_transit_mappings (id),
evidence TEXT,
created_at TEXT NOT NULL,
UNIQUE (decision_id, role)
);
CREATE TABLE transfer_match_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
source_row_id INTEGER NOT NULL REFERENCES source_rows (id),
rule_tier TEXT NOT NULL,
date_diff_days INTEGER,
account_mirror INTEGER NOT NULL DEFAULT 0,
reference_match TEXT,
summary_match INTEGER NOT NULL DEFAULT 0,
accepted INTEGER,
reason TEXT,
rule_version TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE current_transfer_decisions (
event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
decision_id INTEGER NOT NULL UNIQUE REFERENCES transfer_match_decisions (id)
);
CREATE TABLE transfer_observation_claims (
source_row_id INTEGER PRIMARY KEY REFERENCES source_rows (id),
event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id),
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id)
);
CREATE INDEX idx_obs_decision ON transfer_decision_observations (decision_id);
CREATE INDEX idx_participants_decision ON transfer_decision_participants (decision_id);
CREATE INDEX idx_candidates_decision ON transfer_match_candidates (decision_id);
CREATE INDEX idx_claims_event ON transfer_observation_claims (event_id);
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1);
CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events
BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END;
CREATE TRIGGER canonical_transfer_events_no_update BEFORE UPDATE ON canonical_transfer_events
BEGIN
SELECT RAISE (ABORT, 'canonical_transfer_events only allow lifecycle changes')
WHERE OLD.lifecycle = NEW.lifecycle
OR OLD.id IS NOT NEW.id
OR OLD.created_at IS NOT NEW.created_at;
END;
CREATE TRIGGER transfer_match_decisions_no_update BEFORE UPDATE ON transfer_match_decisions
BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END;
CREATE TRIGGER transfer_match_decisions_no_delete BEFORE DELETE ON transfer_match_decisions
BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END;
CREATE TRIGGER transfer_decision_observations_no_update BEFORE UPDATE ON transfer_decision_observations
BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END;
CREATE TRIGGER transfer_decision_observations_no_delete BEFORE DELETE ON transfer_decision_observations
BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END;
CREATE TRIGGER transfer_decision_participants_no_update BEFORE UPDATE ON transfer_decision_participants
BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END;
CREATE TRIGGER transfer_decision_participants_no_delete BEFORE DELETE ON transfer_decision_participants
BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END;
CREATE TRIGGER transfer_match_candidates_no_update BEFORE UPDATE ON transfer_match_candidates
BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END;
CREATE TRIGGER transfer_match_candidates_no_delete BEFORE DELETE ON transfer_match_candidates
BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END;
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
DROP TABLE IF EXISTS transfer_observation_claims;
DROP TABLE IF EXISTS current_transfer_decisions;
DROP TABLE IF EXISTS transfer_match_candidates;
DROP TABLE IF EXISTS transfer_decision_participants;
DROP TABLE IF EXISTS transfer_decision_observations;
DROP TABLE IF EXISTS transfer_match_decisions;
DROP TABLE IF EXISTS canonical_transfer_events;
DROP TABLE IF EXISTS personal_transit_mappings;
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
""",
),
Migration(
version=6,
name="0006_intercompany_ledger_events",
# B-44 canonical intercompany ledger layer. Manual records and the
# ledger event revision chain are append-only facts; current pointers
# (current revision per ledger event / manual decision, source claims)
# are rebuildable projections. Bank events enter only through
# ``eligible_intercompany_events``; nothing here rewrites bank rows.
up="""
CREATE TABLE manual_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
counterparty_company_id INTEGER NOT NULL REFERENCES companies (id),
occurred_at TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('outgoing', 'incoming')),
amount TEXT NOT NULL,
amount_scale INTEGER NOT NULL,
currency TEXT NOT NULL,
funding_source TEXT NOT NULL CHECK (funding_source IN (
'approved_bank_account', 'personal_transit', 'other'
)),
bank_account_id INTEGER REFERENCES bank_accounts (id),
personal_transit_mapping_id INTEGER REFERENCES personal_transit_mappings (id),
related_source_row_id INTEGER REFERENCES source_rows (id),
requested_subject TEXT NOT NULL CHECK (requested_subject IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
summary TEXT,
reason TEXT,
evidence_json TEXT,
request_key TEXT NOT NULL,
supersedes_record_id INTEGER REFERENCES manual_records (id),
submitted_by INTEGER REFERENCES users (id),
created_at TEXT NOT NULL,
UNIQUE (company_id, request_key),
CHECK (counterparty_company_id != company_id)
);
CREATE TABLE manual_record_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL REFERENCES manual_records (id),
revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN (
'pending', 'approved', 'returned', 'exception', 'reversed'
)),
action TEXT NOT NULL,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
idempotency_key TEXT,
supersedes_decision_id INTEGER REFERENCES manual_record_decisions (id),
created_at TEXT NOT NULL,
UNIQUE (record_id, revision)
);
CREATE TABLE current_manual_record_decisions (
record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
decision_id INTEGER NOT NULL UNIQUE REFERENCES manual_record_decisions (id)
);
CREATE TABLE ledger_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lifecycle TEXT NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('active', 'superseded')),
created_at TEXT NOT NULL
);
CREATE TABLE ledger_event_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending_subject', 'confirmed')),
effective_at TEXT NOT NULL,
amount TEXT NOT NULL,
amount_scale INTEGER NOT NULL,
currency TEXT NOT NULL,
payer_company_id INTEGER NOT NULL REFERENCES companies (id),
payee_company_id INTEGER NOT NULL REFERENCES companies (id),
perspective_company_id INTEGER REFERENCES companies (id),
subject_code TEXT CHECK (subject_code IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
source_kind TEXT NOT NULL CHECK (source_kind IN ('bank', 'manual', 'adjustment')),
source_revision_token TEXT,
posting_kind TEXT NOT NULL CHECK (posting_kind IN (
'normal', 'reversal', 'adjustment'
)),
reverses_ledger_event_id INTEGER REFERENCES ledger_events (id),
adjusts_ledger_event_id INTEGER REFERENCES ledger_events (id),
rule_version TEXT,
evidence_json TEXT,
idempotency_key TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
reason TEXT,
supersedes_revision_id INTEGER REFERENCES ledger_event_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (ledger_event_id, revision),
CHECK (payer_company_id != payee_company_id),
CHECK (state = 'confirmed' OR subject_code IS NULL),
CHECK (state = 'confirmed' OR perspective_company_id IS NULL),
CHECK (
state != 'confirmed'
OR (perspective_company_id IS NOT NULL AND subject_code IS NOT NULL)
)
);
CREATE TABLE current_ledger_event_revisions (
ledger_event_id INTEGER PRIMARY KEY REFERENCES ledger_events (id),
revision_id INTEGER NOT NULL UNIQUE REFERENCES ledger_event_revisions (id)
);
CREATE TABLE ledger_event_bank_sources (
bank_event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
UNIQUE (ledger_event_id, bank_event_id)
);
CREATE TABLE ledger_event_manual_sources (
manual_record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
UNIQUE (ledger_event_id, manual_record_id)
);
CREATE TABLE ledger_subject_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
source_revision_id INTEGER NOT NULL REFERENCES ledger_event_revisions (id),
suggested_perspective_company_id INTEGER NOT NULL REFERENCES companies (id),
suggested_subject_code TEXT NOT NULL CHECK (suggested_subject_code IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
rule_version TEXT,
evidence_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_ledger_revisions_event ON ledger_event_revisions (ledger_event_id, revision);
CREATE INDEX idx_ledger_revisions_effective ON ledger_event_revisions (effective_at);
CREATE INDEX idx_ledger_revisions_pair_currency
ON ledger_event_revisions (payer_company_id, payee_company_id, currency);
CREATE INDEX idx_ledger_revisions_state
ON ledger_event_revisions (state, effective_at);
CREATE INDEX idx_manual_records_company ON manual_records (company_id, occurred_at);
CREATE INDEX idx_manual_decisions_record ON manual_record_decisions (record_id, revision);
CREATE INDEX idx_manual_decisions_state ON manual_record_decisions (state);
CREATE INDEX idx_subject_suggestions_event ON ledger_subject_suggestions (ledger_event_id);
CREATE VIEW eligible_position_events AS
SELECT le.id AS ledger_event_id,
cur.revision_id AS ledger_revision_id,
r.effective_at AS effective_at,
r.amount AS amount, r.amount_scale AS amount_scale,
r.currency AS currency,
r.payer_company_id AS payer_company_id,
r.payee_company_id AS payee_company_id,
r.perspective_company_id AS perspective_company_id,
r.subject_code AS subject_code,
r.source_kind AS source_kind, r.posting_kind AS posting_kind,
r.reverses_ledger_event_id AS reverses_ledger_event_id,
r.adjusts_ledger_event_id AS adjusts_ledger_event_id,
COALESCE(
(SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = le.id LIMIT 1),
(SELECT ms.manual_record_id FROM ledger_event_manual_sources ms
WHERE ms.ledger_event_id = le.id LIMIT 1)
) AS source_id,
((SELECT COUNT(*) FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = le.id)
+ (SELECT COUNT(*) FROM ledger_event_manual_sources ms
WHERE ms.ledger_event_id = le.id)) AS evidence_count
FROM ledger_events le
JOIN current_ledger_event_revisions cur ON cur.ledger_event_id = le.id
JOIN ledger_event_revisions r ON r.id = cur.revision_id
WHERE le.lifecycle = 'active' AND r.state = 'confirmed';
CREATE TRIGGER ledger_events_no_delete BEFORE DELETE ON ledger_events
BEGIN SELECT RAISE (ABORT, 'ledger_events rows are immutable'); END;
CREATE TRIGGER ledger_events_no_update BEFORE UPDATE ON ledger_events
BEGIN
SELECT RAISE (ABORT, 'ledger_events only allow lifecycle changes')
WHERE OLD.lifecycle = NEW.lifecycle
OR OLD.id IS NOT NEW.id
OR OLD.created_at IS NOT NEW.created_at;
END;
CREATE TRIGGER ledger_event_revisions_no_update BEFORE UPDATE ON ledger_event_revisions
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
CREATE TRIGGER ledger_event_revisions_no_delete BEFORE DELETE ON ledger_event_revisions
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
CREATE TRIGGER ledger_subject_suggestions_no_update BEFORE UPDATE ON ledger_subject_suggestions
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
CREATE TRIGGER ledger_subject_suggestions_no_delete BEFORE DELETE ON ledger_subject_suggestions
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
CREATE TRIGGER manual_records_no_update BEFORE UPDATE ON manual_records
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
CREATE TRIGGER manual_records_no_delete BEFORE DELETE ON manual_records
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
CREATE TRIGGER manual_record_decisions_no_update BEFORE UPDATE ON manual_record_decisions
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
CREATE TRIGGER manual_record_decisions_no_delete BEFORE DELETE ON manual_record_decisions
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
""",
down="""
DROP VIEW IF EXISTS eligible_position_events;
DROP TRIGGER IF EXISTS manual_record_decisions_no_delete;
DROP TRIGGER IF EXISTS manual_record_decisions_no_update;
DROP TRIGGER IF EXISTS manual_records_no_delete;
DROP TRIGGER IF EXISTS manual_records_no_update;
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_delete;
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_update;
DROP TRIGGER IF EXISTS ledger_event_revisions_no_delete;
DROP TRIGGER IF EXISTS ledger_event_revisions_no_update;
DROP TRIGGER IF EXISTS ledger_events_no_update;
DROP TRIGGER IF EXISTS ledger_events_no_delete;
DROP TABLE IF EXISTS ledger_subject_suggestions;
DROP TABLE IF EXISTS ledger_event_manual_sources;
DROP TABLE IF EXISTS ledger_event_bank_sources;
DROP TABLE IF EXISTS current_ledger_event_revisions;
DROP TABLE IF EXISTS ledger_event_revisions;
DROP TABLE IF EXISTS ledger_events;
DROP TABLE IF EXISTS current_manual_record_decisions;
DROP TABLE IF EXISTS manual_record_decisions;
DROP TABLE IF EXISTS manual_records;
""",
),
)
def connect(path: str | Path) -> sqlite3.Connection:
db_path = Path(path)
if str(db_path) != ":memory:":
db_path.parent.mkdir(parents=True, exist_ok=True)
# Long busy timeout so concurrent uploads/confirmations wait for the
# single SQLite writer instead of surfacing "database is locked" 500s.
connection = sqlite3.connect(str(db_path), timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def applied_versions(connection: sqlite3.Connection) -> list[int]:
exists = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'"
).fetchone()
if not exists:
return []
rows = connection.execute(
"SELECT version FROM schema_migrations ORDER BY version"
).fetchall()
return [row["version"] for row in rows]
def migrate(connection: sqlite3.Connection) -> list[int]:
"""Apply every pending migration; returns the versions applied now."""
connection.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)
"""
)
applied = set(applied_versions(connection))
newly_applied: list[int] = []
for migration in MIGRATIONS:
if migration.version in applied:
continue
with connection:
connection.executescript(migration.up)
connection.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
(migration.version, migration.name, utc_now()),
)
newly_applied.append(migration.version)
return newly_applied
def rollback(connection: sqlite3.Connection, target_version: int = 0) -> list[int]:
"""Reverse migrations above ``target_version``; returns reversed versions."""
applied = applied_versions(connection)
reversed_versions: list[int] = []
for migration in sorted(MIGRATIONS, key=lambda item: item.version, reverse=True):
if migration.version <= target_version or migration.version not in applied:
continue
with connection:
connection.executescript(migration.down)
connection.execute(
"DELETE FROM schema_migrations WHERE version = ?",
(migration.version,),
)
reversed_versions.append(migration.version)
return reversed_versions
def main() -> int:
parser = argparse.ArgumentParser(description="Apply or roll back database migrations.")
parser.add_argument(
"db_path",
type=Path,
nargs="?",
default=DEFAULT_DB_PATH,
help="SQLite database path (default: data/app.db)",
)
parser.add_argument(
"--rollback-to",
type=int,
default=None,
metavar="VERSION",
help="Reverse migrations above VERSION instead of migrating forward",
)
args = parser.parse_args()
connection = connect(args.db_path)
try:
if args.rollback_to is None:
applied = migrate(connection)
print(f"applied migrations: {applied or 'none (already up to date)'}")
else:
reversed_versions = rollback(connection, args.rollback_to)
print(f"reversed migrations: {reversed_versions or 'none'}")
finally:
connection.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+671
View File
@@ -0,0 +1,671 @@
"""Immutable statement import pipeline with per-worksheet review lifecycle.
Every uploaded file is hashed (SHA-256) and written once to content-addressed
storage before parsing. A repeated upload of identical bytes never creates a
second set of facts: it records a ``duplicate`` batch that points at the
original batch. Parse failures keep the batch and its diagnostics as an
``exception`` batch without producing any confirmed source rows.
Each worksheet is parsed independently into a :class:`SheetResult`
(``parsed`` / ``exception`` / ``ignored``) that is persisted in
``sheet_reviews`` as immutable evidence. Whether a parsed sheet may take part
in later matching and calculation is a separate, auditable human decision
(``review_status``): only ``confirmed`` sheets participate.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import tempfile
from . import auth
from . import ledger_events
from . import matching
from .db import utc_now
from .models import SheetResult, StatementBatch
from .parser import StatementParseError, analyze_workbook
from .reader import CorruptWorkbookError
class SheetReviewError(ValueError):
"""A review decision cannot be applied to the worksheet (mapped to 409)."""
@dataclass(frozen=True)
class ImportResult:
batch_id: int
status: str # parsed | duplicate | exception
sha256: str
source_file_id: int
batches: tuple[StatementBatch, ...] = ()
sheets: tuple[SheetResult, ...] = ()
message: str | None = None
duplicate_same_company: bool = False # only meaningful when status == 'duplicate'
def import_statement(
connection: sqlite3.Connection,
storage_dir: str | Path,
original_filename: str,
content: bytes,
company_id: int | None = None,
upload_bank_account_id: int | None = None,
) -> ImportResult:
sha256 = hashlib.sha256(content).hexdigest()
existing_file = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing_file is not None:
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256)
try:
source_file_id, batch_id = _create_batch_records(
connection, sha256, original_filename, len(content), stored_path,
company_id, upload_bank_account_id,
)
except sqlite3.IntegrityError:
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
# identical content was already persisted by another request, so this
# upload is a duplicate. The content-addressed file already exists.
existing = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing is not None:
return _record_duplicate(connection, existing["id"], sha256, company_id)
raise
return _parse_and_persist(
connection, stored_path, sha256, original_filename, source_file_id, batch_id
)
def import_statement_path(
connection: sqlite3.Connection,
storage_dir: str | Path,
original_filename: str,
upload_path: str | Path,
company_id: int | None = None,
upload_bank_account_id: int | None = None,
) -> ImportResult:
"""Import from an already-downloaded upload file (streaming-friendly).
``upload_path`` names the temp file the multipart handler streamed to
disk; it is hashed incrementally and either discarded (duplicate) or
published into the content-addressed store. The upload file itself is
never modified. The approved ``upload_bank_account_id`` used at upload time
is persisted on the batch so ownership can be resolved later even when the
source rows carry no own account.
"""
source = Path(upload_path)
sha256 = _file_sha256(source)
existing_file = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing_file is not None:
source.unlink(missing_ok=True)
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
stored_path = _publish_immutable(Path(storage_dir), original_filename, source, sha256)
size = stored_path.stat().st_size
try:
source_file_id, batch_id = _create_batch_records(
connection, sha256, original_filename, size, stored_path,
company_id, upload_bank_account_id,
)
except sqlite3.IntegrityError:
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
# identical content was already persisted by another request, so this
# upload is a duplicate. The content-addressed file already exists.
existing = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing is not None:
return _record_duplicate(connection, existing["id"], sha256, company_id)
raise
return _parse_and_persist(
connection, stored_path, sha256, original_filename, source_file_id, batch_id
)
def _create_batch_records(
connection: sqlite3.Connection,
sha256: str,
original_filename: str,
size_bytes: int,
stored_path: Path,
company_id: int | None,
upload_bank_account_id: int | None,
) -> tuple[int, int]:
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(sha256, original_filename, size_bytes, str(stored_path), now),
)
source_file_id = cursor.lastrowid
cursor = connection.execute(
"""
INSERT INTO import_batches (source_file_id, status, company_id, upload_bank_account_id, created_at, updated_at)
VALUES (?, 'parsing', ?, ?, ?, ?)
""",
(source_file_id, company_id, upload_bank_account_id, now, now),
)
batch_id = cursor.lastrowid
return source_file_id, batch_id
def _parse_and_persist(
connection: sqlite3.Connection,
stored_path: Path,
sha256: str,
original_filename: str,
source_file_id: int,
batch_id: int,
) -> ImportResult:
try:
sheets = analyze_workbook(stored_path)
except CorruptWorkbookError as exc:
message = _clean_message(str(exc), stored_path, original_filename)
with connection:
_insert_exception(
connection, batch_id, "parse", message, original_filename,
diagnostics={"original_filename": original_filename},
)
_set_batch_status(connection, batch_id, "exception")
return ImportResult(batch_id, "exception", sha256, source_file_id, message=message)
except Exception as exc:
message = f"文件解析失败,请检查文件是否完整。({type(exc).__name__}"
with connection:
_insert_exception(
connection, batch_id, "internal", message, original_filename,
diagnostics={"original_filename": original_filename},
)
_set_batch_status(connection, batch_id, "failed")
raise
with connection:
parsed_batches: list[StatementBatch] = []
for sheet in sheets:
if sheet.outcome == "parsed" and sheet.batch is not None:
sheet_batch_id = _insert_sheet_batch(connection, batch_id, sheet.batch)
_insert_sheet_review(connection, batch_id, sheet, sheet_batch_id)
parsed_batches.append(sheet.batch)
else:
_insert_sheet_review(connection, batch_id, sheet, None)
if not parsed_batches:
message = _whole_file_message(original_filename, sheets)
with connection:
_insert_exception(
connection, batch_id, "parse", message, original_filename,
diagnostics=_exception_diagnostics(original_filename, sheets),
)
_set_batch_status(connection, batch_id, "exception")
return ImportResult(
batch_id, "exception", sha256, source_file_id,
message=message, sheets=sheets,
)
_set_batch_status(connection, batch_id, "parsed")
return ImportResult(
batch_id, "parsed", sha256, source_file_id,
batches=tuple(parsed_batches), sheets=sheets,
)
def _record_duplicate(
connection: sqlite3.Connection,
source_file_id: int,
sha256: str,
company_id: int | None = None,
) -> ImportResult:
original = connection.execute(
"""
SELECT id, company_id FROM import_batches
WHERE source_file_id = ? AND status = 'parsed'
ORDER BY id LIMIT 1
""",
(source_file_id,),
).fetchone()
if original is None:
original = connection.execute(
"""
SELECT id, company_id FROM import_batches
WHERE source_file_id = ? AND status != 'duplicate'
ORDER BY id LIMIT 1
""",
(source_file_id,),
).fetchone()
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO import_batches
(source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at)
VALUES (?, 'duplicate', ?, ?, ?, ?, ?)
""",
(
source_file_id,
original["id"],
company_id,
json.dumps({"note": "内容哈希相同,复用已有批次,不产生第二份事实。"}, ensure_ascii=False),
now,
now,
),
)
# A duplicate is only "same company" when the uploader belongs to the
# same tenant that owns the original batch. Cross-company duplicates
# must stay opaque: the caller gets the new duplicate batch id (which
# belongs to its own company) but never the other company's batch id.
same_company = original["company_id"] == company_id
if same_company:
batch_id = original["id"]
else:
batch_id = int(cursor.lastrowid)
return ImportResult(
batch_id,
"duplicate",
sha256,
source_file_id,
message="相同内容的文件已导入,本次按重复上传处理。",
duplicate_same_company=same_company,
)
def _store_immutable(
storage_dir: Path, original_filename: str, content: bytes, sha256: str
) -> Path:
suffix = Path(original_filename).suffix.lower()
target_dir = storage_dir / sha256[:2]
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{sha256}{suffix}"
# Publish via a unique temporary file + hard link: the content-addressed
# target either appears complete or not at all, and is never overwritten.
# The temp name is unique per writer, so concurrent uploads of the same
# bytes cannot corrupt each other's staging file (B-43 baseline fix).
fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(content)
try:
os.link(temp_path, target)
except FileExistsError:
# Content-addressed name means identical bytes; never overwrite.
pass
finally:
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
return target
def _publish_immutable(
storage_dir: Path, original_filename: str, upload_path: Path, sha256: str
) -> Path:
suffix = Path(original_filename).suffix.lower()
target_dir = storage_dir / sha256[:2]
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{sha256}{suffix}"
fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
try:
with os.fdopen(fd, "wb") as handle:
# Copy keeps the same-filesystem guarantee even if the upload temp
# lives elsewhere; the hard link then publishes atomically. The
# staging file is unique per writer, so two concurrent uploads of
# the same bytes never corrupt each other's copy.
with upload_path.open("rb") as source:
shutil.copyfileobj(source, handle, length=1024 * 1024)
try:
os.link(temp_path, target)
except FileExistsError:
pass
finally:
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
upload_path.unlink(missing_ok=True)
return target
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _insert_sheet_batch(
connection: sqlite3.Connection, batch_id: int, batch: StatementBatch
) -> int:
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, own_account, own_name, period_start, period_end,
transaction_count, warnings, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
batch_id,
batch.sheet_name,
batch.bank_name,
batch.template_id,
batch.template_version,
batch.header_row,
batch.own_account,
batch.own_name,
batch.period_start.isoformat() if batch.period_start else None,
batch.period_end.isoformat() if batch.period_end else None,
len(batch.transactions),
json.dumps(list(batch.warnings), ensure_ascii=False),
now,
),
)
sheet_batch_id = cursor.lastrowid
for transaction in batch.transactions:
connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense, balance,
own_account, own_name, counterparty_account, counterparty_name,
counterparty_bank, summary, purpose, reference, currency, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
sheet_batch_id,
transaction.source_row,
transaction.transaction_at.isoformat(),
str(transaction.income),
str(transaction.expense),
str(transaction.balance) if transaction.balance is not None else None,
transaction.own_account,
transaction.own_name,
transaction.counterparty_account,
transaction.counterparty_name,
transaction.counterparty_bank,
transaction.summary,
transaction.purpose,
transaction.reference,
transaction.currency,
now,
),
)
return sheet_batch_id
def _insert_sheet_review(
connection: sqlite3.Connection,
batch_id: int,
sheet: SheetResult,
sheet_batch_id: int | None,
) -> None:
# Parse-side ignored sheets are terminal: there is nothing to confirm, so
# their review lifecycle is closed with a machine reason.
if sheet.outcome == "ignored":
review_status = "ignored"
review_reason = "空表或无有效内容,自动忽略。"
else:
review_status = "pending"
review_reason = None
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, message, scanned_rows,
candidate_headers, sheet_batch_id, review_status, review_reason, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
batch_id,
sheet.sheet_name,
sheet.outcome,
sheet.message,
sheet.scanned_rows,
json.dumps(list(sheet.candidate_headers), ensure_ascii=False)
if sheet.candidate_headers
else None,
sheet_batch_id,
review_status,
review_reason,
utc_now(),
),
)
def _insert_exception(
connection: sqlite3.Connection,
batch_id: int,
stage: str,
message: str,
original_filename: str,
*,
diagnostics: dict[str, object] | None = None,
) -> None:
payload = {"original_filename": original_filename}
if diagnostics:
payload.update(diagnostics)
connection.execute(
"""
INSERT INTO import_exceptions (import_batch_id, stage, message, diagnostics, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
batch_id,
stage,
message,
json.dumps(payload, ensure_ascii=False),
utc_now(),
),
)
def _set_batch_status(connection: sqlite3.Connection, batch_id: int, status: str) -> None:
connection.execute(
"UPDATE import_batches SET status = ?, updated_at = ? WHERE id = ?",
(status, utc_now(), batch_id),
)
def _whole_file_message(original_filename: str, sheets: tuple[SheetResult, ...]) -> str:
detail = "".join(
f"{sheet.sheet_name}{sheet.message}" for sheet in sheets if sheet.message
)
return f"文件「{original_filename}」没有可确认的工作表。{detail}"
def _exception_diagnostics(
original_filename: str, sheets: tuple[SheetResult, ...]
) -> dict[str, object]:
return {
"original_filename": original_filename,
"sheets": [
{
"sheet_name": sheet.sheet_name,
"outcome": sheet.outcome,
"message": sheet.message,
"scanned_rows": sheet.scanned_rows,
"candidate_headers": list(sheet.candidate_headers),
}
for sheet in sheets
],
}
def _clean_message(message: str, stored_path: Path, original_filename: str) -> str:
return message.replace(str(stored_path), original_filename).replace(
stored_path.name, original_filename
)
# ---------------------------------------------------------------------------
# Per-sheet review lifecycle
# ---------------------------------------------------------------------------
def sheet_review_rows(connection: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
return connection.execute(
"""
SELECT r.id, r.sheet_name, r.outcome, r.message, r.scanned_rows,
r.candidate_headers, r.review_status, r.review_reason,
r.reviewed_by, r.reviewed_at,
s.bank_name, s.template_id, s.header_row, s.own_account, s.own_name,
s.period_start, s.period_end, s.transaction_count, s.warnings
FROM sheet_reviews r
LEFT JOIN sheet_batches s ON s.id = r.sheet_batch_id
WHERE r.import_batch_id = ?
ORDER BY r.id
""",
(batch_id,),
).fetchall()
def scoped_batch(
connection: sqlite3.Connection, batch_id: int, company_id: int | None
) -> sqlite3.Row | None:
"""Load a batch, scoping company users to their own tenant.
``company_id is None`` (admin) sees any batch; a company user only sees
batches owned by that company. Returns None when out of scope so callers
can answer 404 without leaking the batch's existence.
"""
if company_id is None:
return connection.execute(
"SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
).fetchone()
return connection.execute(
"SELECT id, company_id FROM import_batches WHERE id = ? AND company_id = ?",
(batch_id, company_id),
).fetchone()
def review_sheets(
connection: sqlite3.Connection,
batch_id: int,
sheet_names: list[str],
decision: str,
actor: sqlite3.Row | None,
company_id: int | None,
reason: str | None = None,
) -> dict[str, object]:
"""Apply ``confirm`` or ``ignore`` to whole worksheets atomically.
Multi-sheet writes run in one transaction; a failure rolls back every
sheet in the request. Idempotent repeats of the same decision succeed
without touching the audit log; changing a settled decision is a
conflict. Returns ``{"updated": [...], "already": [...]}``.
"""
if decision not in ("confirm", "ignore"):
raise ValueError("decision 必须是 confirm 或 ignore。")
names = [str(name).strip() for name in sheet_names]
if not names:
raise ValueError("必须至少指定一个工作表。")
reason = (reason or "").strip() or None
if decision == "ignore" and not reason:
raise ValueError("忽略工作表必须填写原因。")
batch = scoped_batch(connection, batch_id, company_id)
if batch is None:
raise LookupError("批次不存在。")
placeholders = ",".join("?" for _ in names)
rows = connection.execute(
f"""
SELECT * FROM sheet_reviews
WHERE import_batch_id = ? AND sheet_name IN ({placeholders})
""",
(batch_id, *names),
).fetchall()
found = {row["sheet_name"] for row in rows}
missing = [name for name in names if name not in found]
if missing:
raise ValueError(f"工作表不存在或不属于该批次:{''.join(missing)}")
target = "confirmed" if decision == "confirm" else "ignored"
now = utc_now()
updated: list[str] = []
already: list[str] = []
matching_result: dict[str, object] | None = None
# BEGIN IMMEDIATE serializes concurrent confirmations: the write lock is
# taken before the read, so two cashiers confirming matching worksheets can
# never derive their match decisions from stale snapshots.
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
for row in rows:
if row["review_status"] == target:
already.append(row["sheet_name"])
continue
if decision == "confirm" and row["outcome"] != "parsed":
raise SheetReviewError(
f"工作表「{row['sheet_name']}」没有可确认的交易,只能忽略或保留待处理。"
)
if row["review_status"] != "pending":
raise SheetReviewError(
f"工作表「{row['sheet_name']}」已处理,不能更改决定。"
)
cursor = connection.execute(
"""
UPDATE sheet_reviews
SET review_status = ?, review_reason = ?, reviewed_by = ?, reviewed_at = ?
WHERE id = ? AND review_status = 'pending'
""",
(target, reason, actor["id"] if actor is not None else None, now, row["id"]),
)
if cursor.rowcount:
updated.append(row["sheet_name"])
# Confirming a worksheet and reconciling its rows must succeed or fail
# together: any matching failure rolls the confirmation back, so there
# is never a "confirmed but unmatched" half state.
if updated and decision == "confirm":
placeholders = ",".join("?" for _ in updated)
confirmed_rows = connection.execute(
f"""
SELECT r.id FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
WHERE rv.sheet_name IN ({placeholders})
AND rv.import_batch_id = ?
ORDER BY r.id
""",
(*updated, batch_id),
).fetchall()
matching_result = matching.reconcile_rows(
connection,
[item["id"] for item in confirmed_rows],
actor=actor,
)
ledger_events.reconcile_bank_events(connection, actor=actor)
if began:
connection.commit()
except Exception:
if began:
connection.rollback()
raise
if updated:
auth.audit(
connection,
f"sheet_{decision}",
actor=actor,
target=f"batch:{batch_id}",
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
)
payload: dict[str, object] = {"updated": updated, "already": already}
if matching_result is not None:
payload["matching"] = matching_result
return payload
+684
View File
@@ -0,0 +1,684 @@
"""Canonical intercompany ledger events and the revision chain (B-44).
Bank source rows and approved manual records are immutable evidence. This
module turns them into one canonical ledger event each through ``reconcile_*``
functions and an append-only revision chain. Corrections are never in-place
edits: a reversal or adjustment is a new ledger event with its own effective
date, and the original event keeps its history so no earlier cutoff is
rewritten. Current revisions and source claims are rebuildable projections.
Only B-43 ``eligible_intercompany_events`` feeds bank facts here; same-company
transfers, external transactions, unresolved rows and unlocked single
observations never reach the confirmed balance.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .subjects import MIRROR, SUBJECTS, mirror_subject
class LedgerConflictError(ValueError):
"""A revision/claim/idempotency conflict (mapped to HTTP 409)."""
class LedgerInputError(ValueError):
"""Invalid input for a ledger operation (mapped to HTTP 400/422)."""
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
def amount_scale(amount: object) -> int:
"""Decimal places of a decimal-string amount, never negative."""
try:
exponent = Decimal(str(amount)).as_tuple().exponent
except InvalidOperation:
return 0
return max(0, -int(exponent))
def parse_amount(amount: object) -> Decimal:
"""Parse a positive, valid decimal-string amount."""
try:
value = Decimal(str(amount))
except InvalidOperation:
raise LedgerInputError("金额不是有效的十进制数。") from None
if not value.is_finite() or value <= 0:
raise LedgerInputError("金额必须大于零。")
return value
def _company_exists(connection: sqlite3.Connection, company_id: int, label: str) -> None:
row = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if row is None:
raise LedgerInputError(f"{label}指向的公司不存在。")
# ---------------------------------------------------------------------------
# Revision helpers
# ---------------------------------------------------------------------------
def _ensure_transaction(connection: sqlite3.Connection) -> bool:
"""Begin an immediate transaction unless one is already open.
Write helpers may run standalone (they own the transaction) or nested
inside a caller's transaction (e.g. the sheet-confirm flow); nested calls
never start their own commit.
"""
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
return began
def current_revision(connection: sqlite3.Connection, ledger_event_id: int) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT r.* FROM current_ledger_event_revisions c
JOIN ledger_event_revisions r ON r.id = c.revision_id
WHERE c.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
def _event_lifecycle(connection: sqlite3.Connection, ledger_event_id: int) -> str | None:
row = connection.execute(
"SELECT lifecycle FROM ledger_events WHERE id = ?", (ledger_event_id,)
).fetchone()
return row["lifecycle"] if row is not None else None
def _next_revision_number(connection: sqlite3.Connection, ledger_event_id: int) -> int:
row = connection.execute(
"SELECT COALESCE(MAX(revision), 0) AS m FROM ledger_event_revisions WHERE ledger_event_id = ?",
(ledger_event_id,),
).fetchone()
return int(row["m"]) + 1
def create_event(
connection: sqlite3.Connection,
*,
state: str,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int | None,
subject_code: str | None,
source_kind: str,
source_revision_token: str | None,
posting_kind: str,
reverses_ledger_event_id: int | None = None,
adjusts_ledger_event_id: int | None = None,
rule_version: str | None = None,
evidence_json: str | None = None,
idempotency_key: str | None = None,
actor: sqlite3.Row | None = None,
reason: str | None = None,
supersedes_revision_id: int | None = None,
) -> tuple[int, int]:
"""Insert a new ledger event with one revision. Returns ``(event_id, revision_id)``."""
if state == "confirmed":
if perspective_company_id is None or subject_code is None:
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
if subject_code not in SUBJECTS:
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if int(payer_company_id) == int(payee_company_id):
raise LedgerInputError("付款公司与收款公司不能相同。")
if perspective_company_id is not None and perspective_company_id not in (
int(payer_company_id), int(payee_company_id),
):
raise LedgerInputError("视角公司必须是事件参与方。")
began = _ensure_transaction(connection)
try:
now = utc_now()
cursor = connection.execute(
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
)
event_id = int(cursor.lastrowid)
revision_id = append_revision(
connection,
event_id,
state=state,
effective_at=effective_at,
amount=amount,
currency=currency,
payer_company_id=payer_company_id,
payee_company_id=payee_company_id,
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind=source_kind,
source_revision_token=source_revision_token,
posting_kind=posting_kind,
reverses_ledger_event_id=reverses_ledger_event_id,
adjusts_ledger_event_id=adjusts_ledger_event_id,
rule_version=rule_version,
evidence_json=evidence_json,
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
supersedes_revision_id=supersedes_revision_id,
)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return event_id, revision_id
def append_revision(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
state: str,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int | None,
subject_code: str | None,
source_kind: str,
source_revision_token: str | None,
posting_kind: str,
reverses_ledger_event_id: int | None = None,
adjusts_ledger_event_id: int | None = None,
rule_version: str | None = None,
evidence_json: str | None = None,
idempotency_key: str | None = None,
actor: sqlite3.Row | None = None,
reason: str | None = None,
supersedes_revision_id: int | None = None,
) -> int:
"""Append one immutable revision and repoint the current projection."""
if _event_lifecycle(connection, ledger_event_id) != "active":
raise LedgerConflictError("该事件已停用,不能追加修订。")
if state == "confirmed":
if perspective_company_id is None or subject_code is None:
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
if subject_code not in SUBJECTS:
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if perspective_company_id not in (int(payer_company_id), int(payee_company_id)):
raise LedgerInputError("视角公司必须是事件参与方。")
elif state != "pending_subject":
raise LedgerInputError("事件状态必须是 pending_subject 或 confirmed。")
revision = _next_revision_number(connection, ledger_event_id)
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO ledger_event_revisions (
ledger_event_id, revision, state, effective_at, amount,
amount_scale, currency, payer_company_id, payee_company_id,
perspective_company_id, subject_code, source_kind,
source_revision_token, posting_kind, reverses_ledger_event_id,
adjusts_ledger_event_id, rule_version, evidence_json,
idempotency_key, actor_user_id, actor_username, reason,
supersedes_revision_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ledger_event_id, revision, state, effective_at, amount,
amount_scale(amount), currency, payer_company_id, payee_company_id,
perspective_company_id, subject_code, source_kind,
source_revision_token, posting_kind, reverses_ledger_event_id,
adjusts_ledger_event_id, rule_version, evidence_json,
idempotency_key,
actor["id"] if actor is not None else None,
actor["username"] if actor is not None else None,
reason, supersedes_revision_id, now,
),
)
connection.execute(
"""
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
VALUES (?, ?)
""",
(ledger_event_id, cursor.lastrowid),
)
return int(cursor.lastrowid)
# ---------------------------------------------------------------------------
# Source claims
# ---------------------------------------------------------------------------
def bank_source_claim(connection: sqlite3.Connection, bank_event_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM ledger_event_bank_sources WHERE bank_event_id = ?",
(bank_event_id,),
).fetchone()
def manual_source_claim(connection: sqlite3.Connection, manual_record_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(manual_record_id,),
).fetchone()
def _facts_of(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object]:
revision = current_revision(connection, ledger_event_id)
if revision is None:
return {}
return {
"amount": revision["amount"],
"currency": revision["currency"],
"effective_at": revision["effective_at"],
"payer_company_id": revision["payer_company_id"],
"payee_company_id": revision["payee_company_id"],
}
def _eligible_facts(event: sqlite3.Row) -> dict[str, object]:
return {
"amount": event["amount"],
"currency": event["currency"],
"effective_at": event["effective_at"],
"payer_company_id": event["payer_company_id"],
"payee_company_id": event["payee_company_id"],
}
def _has_reversal(connection: sqlite3.Connection, original_event_id: int) -> bool:
row = connection.execute(
"""
SELECT 1 FROM ledger_event_revisions r
JOIN current_ledger_event_revisions c ON c.revision_id = r.id
WHERE r.reverses_ledger_event_id = ? AND r.posting_kind = 'reversal'
LIMIT 1
""",
(original_event_id,),
).fetchone()
return row is not None
def create_reversal(
connection: sqlite3.Connection,
original_event_id: int,
*,
source_kind: str,
source_revision_token: str | None = None,
effective_at: str | None = None,
reason: str,
actor: sqlite3.Row | None,
idempotency_key: str | None = None,
rule_version: str | None = None,
) -> tuple[int, int]:
"""Create an equal-amount, opposite-direction reversal as a new ledger event.
The subject mirrors the original (应收<->应付, 其他应收<->其他应付). ``effective_at``
defaults to the original event's effective date so an earlier cutoff keeps
the original impact and later cutoffs see the net zero. The original event
is never modified or deleted.
"""
original = current_revision(connection, original_event_id)
if original is None:
raise LedgerConflictError("原事件不存在或没有当前修订。")
if original["state"] != "confirmed":
raise LedgerInputError("只有已确认事件才能生成冲销。")
perspective = mirror_perspective(original)
if effective_at is None:
effective_at = original["effective_at"]
return create_event(
connection,
state="confirmed",
effective_at=effective_at,
amount=original["amount"],
currency=original["currency"],
payer_company_id=original["payee_company_id"],
payee_company_id=original["payer_company_id"],
perspective_company_id=perspective,
subject_code=mirror_subject(original["subject_code"]),
source_kind=source_kind,
source_revision_token=source_revision_token,
posting_kind="reversal",
reverses_ledger_event_id=original_event_id,
rule_version=rule_version or original["rule_version"],
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
)
def mirror_perspective(revision: sqlite3.Row) -> int:
"""The counterparty company from ``revision``'s perspective."""
perspective = int(revision["perspective_company_id"])
if perspective == int(revision["payer_company_id"]):
return int(revision["payee_company_id"])
return int(revision["payer_company_id"])
def create_adjustment(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int,
subject_code: str,
reason: str,
actor: sqlite3.Row,
idempotency_key: str | None = None,
rule_version: str | None = None,
) -> tuple[int, int]:
"""Create an audit adjustment event; the original event stays unchanged."""
return create_event(
connection,
state="confirmed",
effective_at=effective_at,
amount=str(parse_amount(amount)),
currency=currency,
payer_company_id=payer_company_id,
payee_company_id=payee_company_id,
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind="adjustment",
source_revision_token=None,
posting_kind="adjustment",
adjusts_ledger_event_id=ledger_event_id,
rule_version=rule_version or SUBJECT_RULE_VERSION,
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
)
def reopen_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
reason: str,
actor: sqlite3.Row,
idempotency_key: str | None = None,
) -> tuple[int, int]:
"""Reverse a confirmed event and re-open it for subject re-review.
Creates an equal-amount reversal plus a fresh ``pending_subject`` event
that re-claims the original bank source, so the administrator can confirm
a corrected subject. The original event and its reversal keep history.
"""
current = current_revision(connection, ledger_event_id)
if current is None or current["state"] != "confirmed":
raise LedgerConflictError("只有已确认事件可以重新进入科目审核。")
bank_claim = connection.execute(
"SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ?",
(ledger_event_id,),
).fetchone()
if bank_claim is None:
raise LedgerInputError(
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
)
if not _has_reversal(connection, ledger_event_id):
create_reversal(
connection, ledger_event_id,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
reason="科目复核:原确认事件冲销",
actor=actor,
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
rule_version=current["rule_version"],
)
ev = connection.execute(
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
(bank_claim["bank_event_id"],),
).fetchone()
if ev is None:
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
event_id, revision_id = _create_bank_event(
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
replacing_claim=bank_claim,
)
return event_id, revision_id
# ---------------------------------------------------------------------------
# Bank event reconciliation
# ---------------------------------------------------------------------------
def reconcile_bank_events(
connection: sqlite3.Connection, actor: sqlite3.Row | None = None
) -> dict[str, object]:
"""Reconcile the current eligible intercompany events into ledger events.
Idempotent: first sight creates a ``pending_subject`` event; a changed B-43
decision updates a still-pending event's revision, or (for a confirmed
event) creates a reversal plus a fresh pending event. A source that left
the eligible set with a confirmed impact gets one reversal. Runs inside the
caller's transaction when one is open, otherwise in its own transaction.
"""
began = _ensure_transaction(connection)
try:
eligible = {
row["event_id"]: row
for row in connection.execute(
"SELECT * FROM eligible_intercompany_events"
).fetchall()
}
claims = {
row["bank_event_id"]: row
for row in connection.execute(
"SELECT * FROM ledger_event_bank_sources"
).fetchall()
}
stats = {
"created": 0, "updated_pending": 0, "reversal": 0,
"reopened": 0, "unchanged": 0, "sources": len(eligible),
}
for bank_event_id, event in sorted(eligible.items()):
claim = claims.get(bank_event_id)
if claim is None:
_create_bank_event(
connection, event, actor, reason="B-43 事件首次入账,待确认科目"
)
stats["created"] += 1
continue
current = current_revision(connection, claim["ledger_event_id"])
if current is None or _facts_of(connection, claim["ledger_event_id"]) != _eligible_facts(event):
if current is not None and current["state"] == "confirmed":
if not _has_reversal(connection, claim["ledger_event_id"]):
create_reversal(
connection, claim["ledger_event_id"],
source_kind="bank",
source_revision_token=event["decision_id"],
reason="B-43 事件事实变更,原确认事件冲销",
actor=actor,
)
stats["reversal"] += 1
_create_bank_event(
connection, event, actor,
reason="B-43 事件事实变更后重新入账,待确认科目",
replacing_claim=claim,
)
stats["reopened"] += 1
elif current is None or current["state"] == "pending_subject":
_append_bank_pending_revision(
connection, claim["ledger_event_id"], event, actor
)
stats["updated_pending"] += 1
else:
stats["unchanged"] += 1
else:
stats["unchanged"] += 1
for bank_event_id, claim in sorted(claims.items()):
if bank_event_id in eligible:
continue
current = current_revision(connection, claim["ledger_event_id"])
if current is not None and current["state"] == "confirmed":
if not _has_reversal(connection, claim["ledger_event_id"]):
create_reversal(
connection, claim["ledger_event_id"],
source_kind="bank",
source_revision_token=None,
reason="B-43 事件不再纳入往来,原确认事件冲销",
actor=actor,
)
stats["reversal"] += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return stats
def _create_bank_event(
connection: sqlite3.Connection,
event: sqlite3.Row,
actor: sqlite3.Row | None,
*,
reason: str,
replacing_claim: sqlite3.Row | None = None,
) -> tuple[int, int]:
event_id, revision_id = create_event(
connection,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason=reason,
)
if replacing_claim is not None:
connection.execute(
"""
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
WHERE bank_event_id = ?
""",
(event_id, event["event_id"]),
)
else:
connection.execute(
"""
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
VALUES (?, ?)
""",
(event["event_id"], event_id),
)
from .subjects import store_suggestions
store_suggestions(connection, event_id)
return event_id, revision_id
def _append_bank_pending_revision(
connection: sqlite3.Connection,
ledger_event_id: int,
event: sqlite3.Row,
actor: sqlite3.Row | None,
) -> int:
current = current_revision(connection, ledger_event_id)
revision_id = append_revision(
connection,
ledger_event_id,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason="B-43 事件事实更新,追加待审修订",
supersedes_revision_id=current["id"] if current is not None else None,
)
from .subjects import store_suggestions
store_suggestions(connection, ledger_event_id)
return revision_id
# ---------------------------------------------------------------------------
# Projection rebuild
# ---------------------------------------------------------------------------
def rebuild_current_ledger_projection(connection: sqlite3.Connection) -> int:
"""Rebuild current ledger revisions from the append-only log."""
began = _ensure_transaction(connection)
try:
connection.execute("DELETE FROM current_ledger_event_revisions")
rows = connection.execute(
"""
SELECT e.id AS ledger_event_id,
(SELECT r2.id FROM ledger_event_revisions r2
WHERE r2.ledger_event_id = e.id
ORDER BY r2.revision DESC LIMIT 1) AS latest_id
FROM ledger_events e
WHERE e.lifecycle = 'active'
""",
).fetchall()
rebuilt = 0
for row in rows:
if row["latest_id"] is None:
continue
connection.execute(
"""
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
VALUES (?, ?)
""",
(row["ledger_event_id"], row["latest_id"]),
)
rebuilt += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return rebuilt
+818
View File
@@ -0,0 +1,818 @@
"""Manual evidence records, administrator approval and audit-safe reversal.
Manual records are immutable submitted facts. Only an approved record becomes
a canonical ledger event (``approve_new``) or joins one (``approve_link``);
returned/exception/pending records never affect a balance and never leak to
the counterparty. Approved facts change only through a ``reverse`` decision
that creates an opposite new event (or detaches a linked claim) — the original
is never edited. Idempotency keys and UNIQUE claims prevent double counting.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .ledger_events import (
LedgerConflictError,
LedgerInputError,
create_event,
create_reversal,
current_revision,
manual_source_claim,
)
from .subjects import SUBJECTS
MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed")
FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other")
DATE_KEYS = ("occurred_at",)
class ManualConflictError(ValueError):
"""A claim/idempotency/revision conflict (mapped to HTTP 409)."""
class ManualInputError(ValueError):
"""Invalid manual record input (mapped to HTTP 400/422)."""
def _parse_amount(amount: object) -> Decimal:
try:
value = Decimal(str(amount))
except InvalidOperation:
raise ManualInputError("金额不是有效的十进制数。") from None
if not value.is_finite() or value <= 0:
raise ManualInputError("金额必须大于零。")
return value
def _validate_date(value: object, field: str) -> str:
text = str(value or "").strip()
if len(text) < 10:
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。")
try:
datetime.fromisoformat(text[:10])
except ValueError:
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None
return text
def _business_today() -> str:
"""Shanghai business date (the default reversal effective date)."""
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
# ---------------------------------------------------------------------------
# Submit
# ---------------------------------------------------------------------------
def submit(
connection: sqlite3.Connection,
*,
company_id: int,
counterparty_company_id: int,
occurred_at: str,
direction: str,
amount: str,
currency: str,
funding_source: str,
requested_subject: str,
request_key: str,
actor: sqlite3.Row,
bank_account_id: object = None,
personal_transit_mapping_id: object = None,
related_source_row_id: object = None,
summary: object = None,
reason: object = None,
evidence: object = None,
supersedes_record_id: object = None,
) -> dict[str, object]:
"""Submit one manual record for review. Idempotent on ``(company_id, request_key)``."""
request_key = str(request_key or "").strip()
if not request_key:
raise ManualInputError("必须提供提交幂等键 request_key。")
if int(company_id) == int(counterparty_company_id):
raise ManualInputError("对方公司不能与本公司相同。")
if direction not in ("outgoing", "incoming"):
raise ManualInputError("方向必须是 outgoing 或 incoming。")
if funding_source not in FUNDING_SOURCES:
raise ManualInputError(f"资金来源必须是:{''.join(FUNDING_SOURCES)}")
if requested_subject not in SUBJECTS:
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
_validate_date(occurred_at, "业务日期")
currency = str(currency or "").strip()
if not currency:
raise ManualInputError("币种不能为空。")
amount = str(_parse_amount(amount))
for label, raw in (
("company_id", company_id), ("counterparty_company_id", counterparty_company_id),
):
row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone()
if row is None:
raise ManualInputError(f"{label} 指向的公司不存在。")
bank_account_id = _resolve_account_ref(
connection, bank_account_id, company_id, "银行账户"
)
mapping_id = _resolve_account_ref(
connection, personal_transit_mapping_id, company_id, "个人过账映射"
)
related_row = None
if related_source_row_id not in (None, ""):
related_row = connection.execute(
"SELECT r.id, b.company_id FROM source_rows r "
"JOIN sheet_batches s ON s.id = r.sheet_batch_id "
"JOIN import_batches b ON b.id = s.import_batch_id "
"WHERE r.id = ?",
(int(related_source_row_id),),
).fetchone()
if related_row is None:
raise ManualInputError("关联银行源行不存在。")
if funding_source == "approved_bank_account" and bank_account_id is None:
raise ManualInputError("资金来源为已批准账户时必须指定银行账户。")
if funding_source == "personal_transit" and mapping_id is None:
raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。")
supersedes_id = None
if supersedes_record_id not in (None, ""):
parent = connection.execute(
"SELECT id, company_id FROM manual_records WHERE id = ?",
(int(supersedes_record_id),),
).fetchone()
if parent is None or parent["company_id"] != int(company_id):
raise ManualInputError("supersedes_record_id 无效。")
supersedes_id = int(supersedes_record_id)
now = utc_now()
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
# Re-check inside the write transaction: concurrent identical submits
# serialize here, so a replay is found before any INSERT.
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _record_payload(connection, existing["id"], idempotent_replay=True)
try:
cursor = connection.execute(
"""
INSERT INTO manual_records (
company_id, counterparty_company_id, occurred_at, direction,
amount, amount_scale, currency, funding_source, bank_account_id,
personal_transit_mapping_id, related_source_row_id,
requested_subject, summary, reason, evidence_json, request_key,
supersedes_record_id, submitted_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(company_id), int(counterparty_company_id), occurred_at, direction,
amount, _scale_of(amount), currency, funding_source, bank_account_id,
mapping_id, related_row["id"] if related_row is not None else None,
requested_subject, str(summary or "") or None,
str(reason or "") or None,
json.dumps(evidence, ensure_ascii=False) if evidence else None,
request_key, supersedes_id,
actor["id"], now,
),
)
except sqlite3.IntegrityError:
# A concurrent identical submit won the race and committed first;
# surface the existing record idempotently instead of a UNIQUE 500.
if began:
connection.rollback()
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
return _record_payload(connection, existing["id"], idempotent_replay=True)
raise
record_id = int(cursor.lastrowid)
_append_decision(
connection, record_id, state="pending", action="submit",
reason=str(reason or "") or None, actor=actor,
)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _record_payload(connection, record_id)
def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None:
if raw in (None, ""):
return None
row = connection.execute(
"SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),)
).fetchone()
if row is None:
raise ManualInputError(f"{label}不存在。")
if row["company_id"] != int(company_id):
raise ManualInputError(f"{label}必须属于提交公司。")
return int(raw)
def _scale_of(amount: str) -> int:
exponent = Decimal(amount).as_tuple().exponent
return max(0, -int(exponent))
# ---------------------------------------------------------------------------
# Decisions
# ---------------------------------------------------------------------------
def _append_decision(
connection: sqlite3.Connection,
record_id: int,
*,
state: str,
action: str,
reason: str | None,
actor: sqlite3.Row,
idempotency_key: str | None = None,
supersedes_decision_id: int | None = None,
) -> int:
row = connection.execute(
"SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?",
(record_id,),
).fetchone()
revision = int(row["m"]) + 1
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO manual_record_decisions (
record_id, revision, state, action, reason, actor_user_id,
actor_username, idempotency_key, supersedes_decision_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
record_id, revision, state, action, reason,
actor["id"], actor["username"], idempotency_key,
supersedes_decision_id, now,
),
)
decision_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
VALUES (?, ?)
""",
(record_id, decision_id),
)
return decision_id
def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT d.* FROM current_manual_record_decisions c
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE c.record_id = ?
""",
(record_id,),
).fetchone()
def decide(
connection: sqlite3.Connection,
record_id: int,
action: str,
*,
reason: str,
expected_decision_id: int | None,
request_key: str | None,
actor: sqlite3.Row,
subject_code: object = None,
target_ledger_event_id: object = None,
effective_at: object = None,
) -> dict[str, object]:
"""Apply an administrator decision to a manual record.
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
existing ledger event without adding a second economic impact; ``return``
and ``exception`` never produce a balance; ``reverse`` creates an opposite
reversal event (or detaches a linked claim) with an independent business
effective date — explicit ``effective_at`` or the approval business day.
Replays return the earlier outcome via ``idempotency_key``.
"""
reason = (reason or "").strip()
if not reason:
raise ManualInputError("必须填写审核原因。")
if action not in ("approve_new", "approve_link", "return", "exception", "reverse"):
raise ManualInputError("未知的审核决定类型。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if record is None:
raise ManualConflictError("手工记录不存在。")
if request_key:
existing = connection.execute(
"SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?",
(record_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _decision_payload(connection, record_id, existing["id"])
current = _current_decision(connection, record_id)
if current is None:
raise ManualConflictError("该记录没有当前状态。")
if expected_decision_id is not None and int(expected_decision_id) != current["id"]:
raise ManualConflictError("记录已发生变更,请刷新后重试。")
if action == "approve_new":
outcome = _approve_new(
connection, record, current, actor, subject_code, reason, request_key
)
elif action == "approve_link":
outcome = _approve_link(
connection, record, current, actor, target_ledger_event_id, reason,
request_key,
)
elif action == "return":
if current["state"] != "pending":
raise ManualConflictError("只有待复核的记录可以退回。")
decision_id = _append_decision(
connection, record_id, state="returned", action=action,
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
outcome = {"decision_id": decision_id, "ledger_event_id": None}
elif action == "exception":
if current["state"] != "pending":
raise ManualConflictError("只有待复核的记录可以转为异常。")
decision_id = _append_decision(
connection, record_id, state="exception", action=action,
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
outcome = {"decision_id": decision_id, "ledger_event_id": None}
else: # reverse
if current["state"] != "approved":
raise ManualConflictError("只有已批准记录可以冲销。")
outcome = _reverse(
connection, record, current, actor, request_key, reason,
effective_at=effective_at,
)
_store_audit(connection, record, current, action, outcome, reason, actor)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _decision_payload(connection, record_id, outcome["decision_id"])
def _approve_new(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
subject_code: object,
reason: str,
idempotency_key: str | None,
) -> dict[str, object]:
subject = str(subject_code or record["requested_subject"] or "")
if subject not in SUBJECTS:
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if record["direction"] == "outgoing":
payer, payee = record["company_id"], record["counterparty_company_id"]
else:
payer, payee = record["counterparty_company_id"], record["company_id"]
event_id, _revision_id = create_event(
connection,
state="confirmed",
effective_at=record["occurred_at"],
amount=record["amount"],
currency=record["currency"],
payer_company_id=payer,
payee_company_id=payee,
perspective_company_id=record["company_id"],
subject_code=subject,
source_kind="manual",
source_revision_token=None,
posting_kind="normal",
rule_version="manual-record-v1",
evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False),
actor=actor,
reason=reason,
)
decision_id = _append_decision(
connection, record["id"], state="approved", action="approve_new",
reason=reason, actor=actor,
idempotency_key=idempotency_key,
supersedes_decision_id=current["id"],
)
connection.execute(
"""
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
VALUES (?, ?)
""",
(record["id"], event_id),
)
return {"decision_id": decision_id, "ledger_event_id": event_id}
def _approve_link(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
target_ledger_event_id: object,
reason: str,
idempotency_key: str | None,
) -> dict[str, object]:
if target_ledger_event_id in (None, ""):
raise ManualInputError("approve_link 必须指定目标往来事件。")
target = connection.execute(
"SELECT id, lifecycle FROM ledger_events WHERE id = ?",
(int(target_ledger_event_id),),
).fetchone()
if target is None or target["lifecycle"] != "active":
raise ManualConflictError("目标往来事件不存在。")
if manual_source_claim(connection, record["id"]) is not None:
raise ManualConflictError("该手工记录已关联往来事件。")
decision_id = _append_decision(
connection, record["id"], state="approved", action="approve_link",
reason=reason, actor=actor,
idempotency_key=idempotency_key,
supersedes_decision_id=current["id"],
)
connection.execute(
"""
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
VALUES (?, ?)
""",
(record["id"], int(target_ledger_event_id)),
)
return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)}
def _reverse(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
request_key: str | None,
reason: str,
effective_at: object = None,
) -> dict[str, object]:
claim = manual_source_claim(connection, record["id"])
if claim is None:
raise ManualConflictError("该记录尚未关联往来事件,无法冲销。")
event_id = claim["ledger_event_id"]
revision = current_revision(connection, event_id)
if revision is None:
raise ManualConflictError("关联的往来事件没有当前修订。")
if _is_manual_creation_source(revision, record["id"]):
# This record created the event (approve_new): it added the economic
# impact, so reversing it must always produce an equal-amount reversal
# event — even when other manual evidence was later linked onto the
# same event. The original impact must not survive in balances.
if effective_at is not None and str(effective_at).strip():
effective_at = _validate_date(effective_at, "冲销生效日")
else:
effective_at = _business_today()
create_reversal(
connection,
event_id,
source_kind="manual",
source_revision_token=str(record["id"]),
effective_at=effective_at,
reason=reason,
actor=actor,
idempotency_key=request_key,
rule_version="manual-record-v1",
)
else:
# The manual was linked evidence (approve_link) on an event it never
# created: it added no second impact, so reversing detaches the claim
# and the underlying economic impact stays.
connection.execute(
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(record["id"],),
)
decision_id = _append_decision(
connection, record["id"], state="reversed", action="reverse",
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
return {"decision_id": decision_id, "ledger_event_id": None}
def _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool:
"""True when ``manual_record_id`` created the event via ``approve_new``.
The creation source is recorded in the event's immutable revision
``evidence_json``; a linked evidence record is never the creation source
and carries no second economic impact.
"""
if revision["source_kind"] != "manual":
return False
try:
evidence = json.loads(revision["evidence_json"] or "{}")
except (TypeError, ValueError):
return False
return evidence.get("manual_record_id") == manual_record_id
def _store_audit(
connection, record, current, action, outcome, reason, actor
) -> None:
from .auth import audit
audit(
connection,
f"manual_{action}",
actor=actor,
target=f"manual_record:{record['id']}",
detail=(
f"decision:{outcome['decision_id']};"
f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}"
),
)
# ---------------------------------------------------------------------------
# Candidates and queries
# ---------------------------------------------------------------------------
def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]:
"""Deterministic hints shown before approval; never auto-merged."""
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if record is None:
return []
wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing"
date_prefix = str(record["occurred_at"])[:10]
candidates: list[dict[str, object]] = []
bank_rows = connection.execute(
"""
SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing,
e.payer_company_id, e.payee_company_id, e.decision_id
FROM eligible_intercompany_events e
WHERE (e.payer_company_id = ? AND e.payee_company_id = ?)
OR (e.payer_company_id = ? AND e.payee_company_id = ?)
ORDER BY e.event_id
""",
(
record["company_id"], record["counterparty_company_id"],
record["counterparty_company_id"], record["company_id"],
),
).fetchall()
for row in bank_rows:
if row["amount"] != record["amount"] or row["currency"] != record["currency"]:
continue
event_direction = (
"outgoing" if row["payer_company_id"] == record["company_id"] else "incoming"
)
if event_direction != wanted_direction:
continue
candidates.append(
{
"kind": "bank_event",
"ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]),
"bank_event_id": row["event_id"],
"decision_id": row["decision_id"],
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"pairing": row["pairing"],
"hint": "已存在匹配的银行规范事件,建议关联",
}
)
manual_rows = connection.execute(
"""
SELECT m.id, m.company_id, m.counterparty_company_id, m.direction,
m.amount, m.currency, m.occurred_at, d.state
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE m.id != ? AND m.amount = ? AND m.currency = ?
AND (
(m.company_id = ? AND m.counterparty_company_id = ?)
OR (m.company_id = ? AND m.counterparty_company_id = ?)
)
ORDER BY m.id
""",
(
record["id"], record["amount"], record["currency"],
record["company_id"], record["counterparty_company_id"],
record["counterparty_company_id"], record["company_id"],
),
).fetchall()
for row in manual_rows:
if row["direction"] != wanted_direction:
continue
if row["state"] not in ("approved", "pending"):
continue
candidates.append(
{
"kind": "manual_record",
"ledger_event_id": None,
"manual_record_id": row["id"],
"amount": row["amount"],
"currency": row["currency"],
"occurred_at": row["occurred_at"],
"state": row["state"],
"hint": "存在方向相反的同额手工记录,建议核对后关联",
}
)
return candidates
def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None:
claim = connection.execute(
"SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?",
(bank_event_id,),
).fetchone()
return claim["ledger_event_id"] if claim is not None else None
def list_records(
connection: sqlite3.Connection,
*,
company_id: int | None = None,
state: str | None = None,
limit: int = 100,
) -> list[sqlite3.Row]:
conditions: list[str] = []
params: list[object] = []
if company_id is not None:
conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)")
params.extend([company_id, company_id])
if state is not None:
if state not in MANUAL_STATES:
raise ManualInputError("无效的记录状态。")
conditions.append("d.state = ?")
params.append(state)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
return connection.execute(
f"""
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
d.action AS action, d.reason AS decision_reason,
d.actor_username AS decision_actor, d.created_at AS decision_at,
c.name AS company_name, cc.name AS counterparty_company_name,
u.username AS submitted_by_username
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
LEFT JOIN companies c ON c.id = m.company_id
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
LEFT JOIN users u ON u.id = m.submitted_by
{where}
ORDER BY m.id DESC
LIMIT ?
""",
(*params, max(1, int(limit))),
).fetchall()
def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int:
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
connection.execute("DELETE FROM current_manual_record_decisions")
rows = connection.execute(
"""
SELECT m.id AS record_id,
(SELECT d2.id FROM manual_record_decisions d2
WHERE d2.record_id = m.id
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
FROM manual_records m
"""
).fetchall()
rebuilt = 0
for row in rows:
if row["latest_id"] is None:
continue
connection.execute(
"""
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
VALUES (?, ?)
""",
(row["record_id"], row["latest_id"]),
)
rebuilt += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return rebuilt
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]:
rows = list_records(connection, limit=1000)
row = next((item for item in rows if item["id"] == record_id), None)
if row is None:
raise ManualInputError("手工记录不存在。")
payload = _row_payload(connection, row)
if idempotent_replay:
payload["idempotent_replay"] = True
return payload
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {}
return {
"id": row["id"],
"company_id": row["company_id"],
"company_name": row["company_name"],
"counterparty_company_id": row["counterparty_company_id"],
"counterparty_company_name": row["counterparty_company_name"],
"occurred_at": row["occurred_at"],
"direction": row["direction"],
"amount": row["amount"],
"currency": row["currency"],
"funding_source": row["funding_source"],
"bank_account_id": row["bank_account_id"],
"personal_transit_mapping_id": row["personal_transit_mapping_id"],
"related_source_row_id": row["related_source_row_id"],
"requested_subject": row["requested_subject"],
"summary": row["summary"],
"reason": row["reason"],
"request_key": row["request_key"],
"supersedes_record_id": row["supersedes_record_id"],
"submitted_by": row["submitted_by"],
"submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None,
"attachment_name": evidence.get("attachment_name"),
"created_at": row["created_at"],
"state": row["state"],
"decision_id": row["decision_id"],
"decision_revision": row["decision_revision"],
"decision_action": row["action"],
"decision_reason": row["decision_reason"],
"decision_actor": row["decision_actor"],
"decision_at": row["decision_at"],
"candidates": find_candidates(connection, row["id"]),
}
def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]:
row = connection.execute(
"""
SELECT d.* FROM manual_record_decisions d
WHERE d.id = ?
""",
(decision_id,),
).fetchone()
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
claim = manual_source_claim(connection, record_id)
return {
"record_id": record_id,
"decision_id": decision_id,
"revision": row["revision"],
"state": row["state"],
"action": row["action"],
"reason": row["reason"],
"actor_username": row["actor_username"],
"created_at": row["created_at"],
"ledger_event_id": claim["ledger_event_id"] if claim is not None else None,
"requested_subject": record["requested_subject"],
"amount": record["amount"],
"currency": record["currency"],
"occurred_at": record["occurred_at"],
}
+570
View File
@@ -0,0 +1,570 @@
"""Dynamic master data: companies, bank accounts, aliases and audit trail.
Companies, cashier logins and bank accounts live in the database instead of
being hard-coded in the UI. Bank account numbers are stored normalized
(digits only) under a database UNIQUE constraint, so two concurrent
submissions can never produce two usable accounts for the same number.
Company-side registrations are requests: only administrator-approved
(``active``) accounts inside their effective interval may identify ownership,
accept uploads or take part in coverage calculation. Every change is recorded
in ``master_data_changes`` with before/after values, actor, time and reason.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
import json
import re
import sqlite3
from .db import utc_now
ACCOUNT_TYPES = ("基本户", "一般户", "专用户")
ACCOUNT_STATUSES = ("pending", "active", "returned", "disabled")
ALIAS_KINDS = ("name", "account")
# Deterministic alias match priority: an exact account-number hit always
# beats an account alias, which always beats a name alias. The per-alias
# ``priority`` column only orders matches within the same tier.
PRIORITY_EXACT_ACCOUNT = 0
PRIORITY_ACCOUNT_ALIAS = 1000
PRIORITY_NAME_ALIAS = 2000
_ACCOUNT_NUMBER_PATTERN = re.compile(r"^[0-9]{6,32}$")
_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_FULL_WIDTH_DIGITS = str.maketrans("0123456789", "0123456789")
class ConflictError(ValueError):
"""A uniqueness or ownership conflict (mapped to HTTP 409)."""
def utc_today() -> str:
return datetime.now(timezone.utc).date().isoformat()
def normalize_account_number(raw: object) -> str:
"""Normalize an account number to digits only; raise ValueError if invalid.
Spaces, dashes and full-width digits entered by cashiers or emitted by
bank exports collapse to one canonical form, which is what the UNIQUE
constraint and all matching operate on.
"""
text = str(raw or "").translate(_FULL_WIDTH_DIGITS)
text = re.sub(r"[\s\--‒–—]+", "", text)
if not _ACCOUNT_NUMBER_PATTERN.fullmatch(text):
raise ValueError("银行账号须为 6-32 位数字(可含空格或短横线分隔)。")
return text
def mask_account_number(number: str) -> str:
"""Masked display form; the full number stays server-side."""
if len(number) <= 4:
return f"****{number}"
return f"****{number[-4:]}"
def validate_date(value: object, field: str, *, required: bool = False) -> str | None:
text = str(value or "").strip()
if not text:
if required:
raise ValueError(f"{field}不能为空。")
return None
if not _DATE_PATTERN.fullmatch(text):
raise ValueError(f"{field}须为 YYYY-MM-DD 格式。")
try:
date.fromisoformat(text)
except ValueError:
raise ValueError(f"{field}不是有效日期。") from None
return text
def validate_account_type(value: object) -> str:
text = str(value or "").strip() or "一般户"
if text not in ACCOUNT_TYPES:
raise ValueError(f"账户类型必须是:{''.join(ACCOUNT_TYPES)}")
return text
def _snapshot(row: sqlite3.Row | None) -> dict[str, object] | None:
if row is None:
return None
return {key: row[key] for key in row.keys()}
def record_change(
connection: sqlite3.Connection,
entity_type: str,
entity_id: int,
action: str,
before: dict[str, object] | None,
after: dict[str, object] | None,
reason: str | None,
actor: sqlite3.Row | None,
) -> None:
"""Append a before/after audit entry for a master data change."""
connection.execute(
"""
INSERT INTO master_data_changes (
entity_type, entity_id, action, before_json, after_json,
reason, actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
entity_type,
entity_id,
action,
json.dumps(before, ensure_ascii=False) if before is not None else None,
json.dumps(after, ensure_ascii=False) if after is not None else None,
reason,
actor["id"] if actor is not None else None,
actor["username"] if actor is not None else None,
utc_now(),
),
)
# ---------------------------------------------------------------------------
# Companies
# ---------------------------------------------------------------------------
def create_company(
connection: sqlite3.Connection,
name: str,
credit_code: str | None,
cashier_name: str | None,
actor: sqlite3.Row | None,
) -> int:
"""Create a company master record; returns the new id."""
name = name.strip()
if not name:
raise ValueError("公司名称不能为空。")
now = utc_now()
try:
with connection:
cursor = connection.execute(
"""
INSERT INTO companies (
name, credit_code, cashier_name, status, created_at, updated_at
) VALUES (?, ?, ?, 'active', ?, ?)
""",
(name, (credit_code or "").strip() or None,
(cashier_name or "").strip() or None, now, now),
)
except sqlite3.IntegrityError as exc:
raise ConflictError("公司名称已存在。") from exc
company_id = int(cursor.lastrowid)
with connection:
record_change(
connection, "company", company_id, "create",
None, {"name": name, "credit_code": credit_code or None,
"cashier_name": cashier_name or None, "status": "active"},
None, actor,
)
return company_id
# ---------------------------------------------------------------------------
# Bank accounts
# ---------------------------------------------------------------------------
def get_account(connection: sqlite3.Connection, account_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM bank_accounts WHERE id = ?", (account_id,)
).fetchone()
def submit_bank_account(
connection: sqlite3.Connection,
*,
company_id: int,
bank_name: str,
account_type: object,
account_number: object,
account_name: object = None,
start_date: object = None,
actor: sqlite3.Row | None,
) -> sqlite3.Row:
"""Register an account for review; returns the resulting account row.
A normalized number already present is never duplicated: resubmitting a
returned request from the same company reopens that same row as pending;
any other existing row is a conflict. The UNIQUE constraint on
``account_number`` is the final guard for concurrent submissions.
"""
number = normalize_account_number(account_number)
bank = str(bank_name or "").strip()
if not bank:
raise ValueError("开户银行不能为空。")
kind = validate_account_type(account_type)
holder = str(account_name or "").strip() or None
requested_from = validate_date(start_date, "启用日期")
now = utc_now()
existing = connection.execute(
"SELECT * FROM bank_accounts WHERE account_number = ?", (number,)
).fetchone()
if existing is None:
try:
with connection:
cursor = connection.execute(
"""
INSERT INTO bank_accounts (
company_id, account_number, account_name, bank_name,
account_type, status, effective_from, submitted_by,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
""",
(company_id, number, holder, bank, kind,
requested_from, actor["id"] if actor else None, now, now),
)
except sqlite3.IntegrityError as exc:
# Lost a concurrent-insert race on the UNIQUE constraint.
raise ConflictError("该银行账号已登记,请等待现有申请处理。") from exc
account_id = int(cursor.lastrowid)
with connection:
record_change(
connection, "bank_account", account_id, "submit", None,
{"company_id": company_id, "account_number": number,
"bank_name": bank, "account_type": kind, "status": "pending",
"effective_from": requested_from},
None, actor,
)
return get_account(connection, account_id)
if existing["status"] == "returned" and existing["company_id"] == company_id:
before = _snapshot(existing)
with connection:
connection.execute(
"""
UPDATE bank_accounts
SET account_name = ?, bank_name = ?, account_type = ?,
status = 'pending', effective_from = ?,
submitted_by = ?, reviewed_by = NULL, reviewed_at = NULL,
review_reason = NULL, updated_at = ?
WHERE id = ? AND status = 'returned'
""",
(holder, bank, kind, requested_from,
actor["id"] if actor else None, now, existing["id"]),
)
record_change(
connection, "bank_account", existing["id"], "resubmit",
before, {"account_number": number, "bank_name": bank,
"account_type": kind, "status": "pending",
"effective_from": requested_from},
"退回后重新提交", actor,
)
return get_account(connection, existing["id"])
if existing["company_id"] == company_id:
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
def review_bank_account(
connection: sqlite3.Connection,
account_id: int,
decision: str,
reason: str | None,
actor: sqlite3.Row,
*,
effective_from: object = None,
effective_to: object = None,
) -> sqlite3.Row:
"""Approve, return or disable a registration; returns the updated row."""
account = get_account(connection, account_id)
if account is None:
raise LookupError("账户不存在。")
reason = (reason or "").strip() or None
today = utc_today()
before = _snapshot(account)
if decision == "approve":
if account["status"] != "pending":
raise ConflictError("只有待复核的账户可以审核通过。")
start = validate_date(effective_from, "启用日期") or account["effective_from"] or today
with connection:
connection.execute(
"""
UPDATE bank_accounts
SET status = 'active', effective_from = ?, effective_to = NULL,
reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ?
WHERE id = ?
""",
(start, actor["id"], utc_now(), reason, utc_now(), account_id),
)
record_change(
connection, "bank_account", account_id, "approve", before,
{"status": "active", "effective_from": start}, reason, actor,
)
elif decision == "return":
if account["status"] != "pending":
raise ConflictError("只有待复核的账户可以退回。")
if reason is None:
raise ValueError("退回必须填写原因。")
with connection:
connection.execute(
"""
UPDATE bank_accounts
SET status = 'returned', reviewed_by = ?, reviewed_at = ?,
review_reason = ?, updated_at = ?
WHERE id = ?
""",
(actor["id"], utc_now(), reason, utc_now(), account_id),
)
record_change(
connection, "bank_account", account_id, "return", before,
{"status": "returned"}, reason, actor,
)
elif decision == "disable":
if account["status"] != "active":
raise ConflictError("只有已启用的账户可以停用。")
if reason is None:
raise ValueError("停用必须填写原因。")
end = validate_date(effective_to, "停用日期") or today
with connection:
connection.execute(
"""
UPDATE bank_accounts
SET status = 'disabled', effective_to = ?,
reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ?
WHERE id = ?
""",
(end, actor["id"], utc_now(), reason, utc_now(), account_id),
)
record_change(
connection, "bank_account", account_id, "disable", before,
{"status": "disabled", "effective_to": end}, reason, actor,
)
else:
raise ValueError("审核决定必须是 approve、return 或 disable。")
return get_account(connection, account_id)
def in_effective_window(account: sqlite3.Row, on_date: str) -> bool:
"""True when ``on_date`` falls inside the account's effective interval.
``effective_to`` is the last participating day (inclusive).
"""
if account["effective_from"] and on_date < account["effective_from"]:
return False
if account["effective_to"] and on_date > account["effective_to"]:
return False
return True
def is_usable(account: sqlite3.Row, on_date: str) -> bool:
"""True when the account may accept uploads / be selected on a date."""
return account["status"] == "active" and in_effective_window(account, on_date)
def is_identifiable(account: sqlite3.Row, on_date: str) -> bool:
"""True when the account may resolve ownership for a transaction date.
A disabled account keeps identifying historical rows inside its effective
window — disabling stops future participation, it never rewrites history.
"""
return account["status"] in ("active", "disabled") and in_effective_window(
account, on_date
)
def list_accounts(
connection: sqlite3.Connection,
company_id: int | None = None,
status: str | None = None,
) -> list[sqlite3.Row]:
conditions: list[str] = []
params: list[object] = []
if company_id is not None:
conditions.append("a.company_id = ?")
params.append(company_id)
if status is not None:
if status not in ACCOUNT_STATUSES:
raise ValueError("无效的账户状态。")
conditions.append("a.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
return connection.execute(
f"""
SELECT a.*, c.name AS company_name
FROM bank_accounts a
JOIN companies c ON c.id = a.company_id
{where}
ORDER BY a.id
""",
params,
).fetchall()
def usable_accounts(
connection: sqlite3.Connection, company_id: int, on_date: str | None = None
) -> list[sqlite3.Row]:
"""Accounts allowed to upload / identify ownership for the company today."""
day = on_date or utc_today()
return [
account
for account in list_accounts(connection, company_id=company_id)
if is_usable(account, day)
]
# ---------------------------------------------------------------------------
# Aliases
# ---------------------------------------------------------------------------
def add_alias(
connection: sqlite3.Connection,
account_id: int,
alias_kind: str,
alias_value: object,
priority: object = None,
effective_from: object = None,
effective_to: object = None,
actor: sqlite3.Row | None = None,
) -> int:
"""Attach a name/account alias with an effective interval; returns the id."""
if get_account(connection, account_id) is None:
raise LookupError("账户不存在。")
if alias_kind not in ALIAS_KINDS:
raise ValueError("别名类型必须是 name 或 account。")
if alias_kind == "account":
value = normalize_account_number(alias_value)
else:
value = re.sub(r"\s+", "", str(alias_value or ""))
if not value:
raise ValueError("户名别名不能为空。")
try:
rank = int(priority) if priority not in (None, "") else 100
except (TypeError, ValueError):
raise ValueError("优先级必须是整数。") from None
start = validate_date(effective_from, "别名生效日期")
end = validate_date(effective_to, "别名失效日期")
if start and end and end < start:
raise ValueError("别名失效日期不能早于生效日期。")
try:
with connection:
cursor = connection.execute(
"""
INSERT INTO account_aliases (
bank_account_id, alias_kind, alias_value, priority,
effective_from, effective_to, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(account_id, alias_kind, value, rank, start, end,
actor["id"] if actor else None, utc_now()),
)
except sqlite3.IntegrityError as exc:
raise ConflictError("该账户下相同别名已存在。") from exc
alias_id = int(cursor.lastrowid)
with connection:
record_change(
connection, "account_alias", alias_id, "create", None,
{"bank_account_id": account_id, "alias_kind": alias_kind,
"alias_value": value, "priority": rank,
"effective_from": start, "effective_to": end},
None, actor,
)
return alias_id
def list_aliases(connection: sqlite3.Connection, account_id: int) -> list[sqlite3.Row]:
return connection.execute(
"SELECT * FROM account_aliases WHERE bank_account_id = ? ORDER BY id",
(account_id,),
).fetchall()
def match_account(
connection: sqlite3.Connection,
*,
account_number: object = None,
name: object = None,
on_date: str | None = None,
) -> list[dict[str, object]]:
"""Resolve an observed counterparty to operating, in-window accounts.
Deterministic priority: exact account number < account alias < name
alias; within one tier the alias ``priority`` column orders the hits.
Pending and returned accounts never match; disabled accounts still match
transaction dates inside their effective window (see ``is_identifiable``).
"""
day = on_date or utc_today()
hits: list[tuple[int, sqlite3.Row, str]] = []
number = str(account_number or "").strip()
if number:
try:
normalized = normalize_account_number(number)
except ValueError:
normalized = None
if normalized is not None:
exact = connection.execute(
"SELECT * FROM bank_accounts WHERE account_number = ?",
(normalized,),
).fetchone()
if exact is not None and is_identifiable(exact, day):
hits.append((PRIORITY_EXACT_ACCOUNT, exact, "account_exact"))
alias_rows = connection.execute(
"""
SELECT a.*, al.priority AS alias_priority,
al.effective_from AS alias_from, al.effective_to AS alias_to
FROM account_aliases al
JOIN bank_accounts a ON a.id = al.bank_account_id
WHERE al.alias_kind = 'account' AND al.alias_value = ?
""",
(normalized,),
).fetchall()
for alias in alias_rows:
if _alias_in_window(alias, day) and is_identifiable(alias, day):
hits.append(
(PRIORITY_ACCOUNT_ALIAS + alias["alias_priority"], alias, "account_alias")
)
normalized_name = re.sub(r"\s+", "", str(name or ""))
if normalized_name:
alias_rows = connection.execute(
"""
SELECT a.*, al.priority AS alias_priority,
al.effective_from AS alias_from, al.effective_to AS alias_to
FROM account_aliases al
JOIN bank_accounts a ON a.id = al.bank_account_id
WHERE al.alias_kind = 'name' AND al.alias_value = ?
""",
(normalized_name,),
).fetchall()
for alias in alias_rows:
if _alias_in_window(alias, day) and is_identifiable(alias, day):
hits.append(
(PRIORITY_NAME_ALIAS + alias["alias_priority"], alias, "name_alias")
)
hits.sort(key=lambda item: (item[0], item[1]["id"]))
seen: set[int] = set()
results: list[dict[str, object]] = []
for rank, account, via in hits:
if account["id"] in seen:
continue
seen.add(account["id"])
results.append(
{
"bank_account_id": account["id"],
"company_id": account["company_id"],
"via": via,
"priority": rank,
}
)
return results
def _alias_in_window(alias: sqlite3.Row, on_date: str) -> bool:
if alias["alias_from"] and on_date < alias["alias_from"]:
return False
if alias["alias_to"] and on_date > alias["alias_to"]:
return False
return True
File diff suppressed because it is too large Load Diff
+21
View File
@@ -39,3 +39,24 @@ class StatementBatch:
period_end: date | None period_end: date | None
transactions: tuple[NormalizedTransaction, ...] transactions: tuple[NormalizedTransaction, ...]
warnings: tuple[str, ...] warnings: tuple[str, ...]
template_version: int = 1
@dataclass(frozen=True)
class SheetResult:
"""Per-worksheet parse outcome with the four diagnostic evidence items.
``outcome`` is the deterministic parse verdict: ``parsed`` (transactions
normalized), ``exception`` (template/header/balance problems) or
``ignored`` (empty or single-cell sheets with no bank content). The human
decision about whether a parsed sheet may participate in calculations is
a separate lifecycle stage (``review_status``), persisted in
``sheet_reviews``; it is never decided here.
"""
sheet_name: str
outcome: str # parsed | exception | ignored
message: str | None = None
scanned_rows: int | None = None
candidate_headers: tuple[str, ...] = ()
batch: StatementBatch | None = None
+275
View File
@@ -0,0 +1,275 @@
"""Streaming multipart/form-data parser for the stdlib HTTP server.
The upload body is consumed in chunks from the request stream and never
assembled into memory as a whole. File parts are streamed straight to a temp
file on disk with an incremental size cap; text fields are capped at a small
field size. File bytes are preserved exactly (no trailing-byte trimming), the
first file part is the only one accepted, and both the extension and the file
signature are validated by the caller.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
from typing import BinaryIO
MAX_FIELD_BYTES = 16 * 1024
MAX_HEADER_BYTES = 32 * 1024
CHUNK_SIZE = 64 * 1024
# .xlsx files are ZIP containers (PK\x03\x04 local header or an empty-archive
# PK\x05\x06); .xls files are OLE2 compound documents.
XLSX_MAGIC = (b"PK\x03\x04", b"PK\x05\x06")
XLS_MAGIC = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",)
class MultipartError(ValueError):
pass
@dataclass(frozen=True)
class UploadedFile:
filename: str
path: Path
size: int
def valid_file_signature(path: Path, suffix: str) -> bool:
"""True when the file's leading bytes match its declared extension."""
with path.open("rb") as handle:
head = handle.read(8)
if suffix == ".xlsx":
return any(head.startswith(magic) for magic in XLSX_MAGIC)
if suffix == ".xls":
return any(head.startswith(magic) for magic in XLS_MAGIC)
return False
class _MultipartStream:
def __init__(
self,
rfile: BinaryIO,
content_length: int,
content_type: str,
*,
max_body_bytes: int,
) -> None:
lowered = content_type.lower()
if not lowered.startswith("multipart/form-data"):
raise MultipartError("上传请求必须是 multipart/form-data。")
match = re.search(r"boundary=(?:\"([^\"]+)\"|([^;]+))", content_type)
if not match:
raise MultipartError("上传请求缺少文件边界。")
boundary = (match.group(1) or match.group(2)).strip().encode("utf-8")
if not boundary or len(boundary) > 200:
raise MultipartError("上传请求的文件边界无效。")
if content_length <= 0:
raise MultipartError("上传请求为空。")
if content_length > max_body_bytes:
raise MultipartError(f"上传请求超过 {max_body_bytes // (1024 * 1024)} MB 限制。")
self.rfile = rfile
self.remaining = content_length
self.total_read = 0
self.buffer = bytearray()
self.boundary = boundary
self._fill()
# ------------------------------------------------------------------
# Low-level stream helpers
# ------------------------------------------------------------------
def _fill(self) -> None:
if self.total_read >= self.remaining:
return
want = min(CHUNK_SIZE, self.remaining - self.total_read)
data = self.rfile.read(want)
if not data:
# Client closed early; clamp remaining so every helper sees EOF.
self.remaining = self.total_read
return
self.total_read += len(data)
self.buffer.extend(data)
def _take(self, count: int) -> bytes:
while len(self.buffer) < count:
if self.total_read >= self.remaining:
raise MultipartError("multipart 请求体不完整。")
self._fill()
out = bytes(self.buffer[:count])
del self.buffer[:count]
return out
def _read_line(self) -> bytes:
while True:
index = self.buffer.find(b"\n")
if index >= 0:
line = bytes(self.buffer[: index + 1])
del self.buffer[: index + 1]
return line
if self.total_read >= self.remaining:
if not self.buffer:
raise MultipartError("multipart 请求体不完整。")
line = bytes(self.buffer)
self.buffer.clear()
return line
self._fill()
def _skip_preamble(self) -> None:
marker = b"--" + self.boundary
while True:
index = self.buffer.find(marker)
if index >= 0:
del self.buffer[: index + len(marker)]
return
keep = len(marker) - 1
if len(self.buffer) > keep:
del self.buffer[: len(self.buffer) - keep]
if self.total_read >= self.remaining:
raise MultipartError("上传请求中没有找到文件。")
self._fill()
def _iter_content(self, boundary: bytes):
"""Yield content bytes up to (excluding) the ``\\r\\n--boundary`` marker."""
marker = b"\r\n--" + boundary
keep = len(marker) - 1
while True:
index = self.buffer.find(marker)
if index >= 0:
content = bytes(self.buffer[:index])
del self.buffer[:index]
if content:
yield content
return
if len(self.buffer) > keep:
safe = len(self.buffer) - keep
content = bytes(self.buffer[:safe])
del self.buffer[:safe]
if content:
yield content
if self.total_read >= self.remaining:
raise MultipartError("multipart 请求体缺少结束边界。")
self._fill()
def _after_content_boundary(self) -> str:
"""Consume the content-ending marker; return 'part' or 'end'."""
marker = b"\r\n--" + self.boundary
del self.buffer[: len(marker)]
indicator = self._take(2)
if indicator == b"--":
if self.buffer.startswith(b"\r\n"):
del self.buffer[:2]
elif self.buffer.startswith(b"\n"):
del self.buffer[:1]
return "end"
if indicator != b"\r\n":
raise MultipartError("上传请求格式无效。")
return "part"
# ------------------------------------------------------------------
# Part parsing
# ------------------------------------------------------------------
def _read_headers(self) -> bytes:
total = 0
lines: list[bytes] = []
while True:
line = self._read_line()
total += len(line)
if total > MAX_HEADER_BYTES:
raise MultipartError("multipart 头部过长。")
if line in (b"\r\n", b"\n"):
return b"".join(lines)
lines.append(line)
@staticmethod
def _parse_disposition(header: bytes) -> tuple[str | None, str | None]:
name = None
filename = None
name_match = re.search(br'name="([^"]*)"', header)
if name_match:
name = name_match.group(1).decode("utf-8", errors="replace")
filename_match = re.search(br'filename="([^"]*)"', header)
if filename_match:
filename = filename_match.group(1).decode("utf-8", errors="replace")
return name, filename
def parse(self, work_dir: Path) -> tuple[dict[str, str], UploadedFile | None]:
import tempfile
self._skip_preamble()
next_bytes = self._take(2)
if next_bytes == b"--":
# Empty multipart body: opening boundary immediately closes.
return {}, None
if next_bytes != b"\r\n":
raise MultipartError("上传请求格式无效。")
fields: dict[str, str] = {}
uploaded: UploadedFile | None = None
while True:
header = self._read_headers()
name, filename = self._parse_disposition(header)
if name is None:
raise MultipartError("上传请求缺少字段名。")
if filename is not None:
if uploaded is not None:
raise MultipartError("一次只能上传一个文件。")
with tempfile.NamedTemporaryFile(
dir=str(work_dir), prefix="upload-", delete=False
) as sink:
temp_path = Path(sink.name)
size = 0
try:
for chunk in self._iter_content(self.boundary):
size += len(chunk)
sink.write(chunk)
except Exception:
temp_path.unlink(missing_ok=True)
raise
uploaded = UploadedFile(
filename=filename, path=temp_path, size=size
)
if self._after_content_boundary() == "end":
break
else:
chunks: list[bytes] = []
total = 0
for chunk in self._iter_content(self.boundary):
total += len(chunk)
if total > MAX_FIELD_BYTES:
raise MultipartError("表单字段超过大小限制。")
chunks.append(chunk)
fields[name] = b"".join(chunks).decode(
"utf-8", errors="replace"
).strip()
if self._after_content_boundary() == "end":
break
return fields, uploaded
def parse_upload(
rfile: BinaryIO,
content_length: int,
content_type: str,
work_dir: str | Path,
*,
max_file_bytes: int,
) -> tuple[dict[str, str], UploadedFile | None]:
"""Stream a multipart upload; returns ``(fields, uploaded_file)``.
Raises :class:`MultipartError` for malformed or oversized bodies. The
caller owns the uploaded temp file and must remove it when done.
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
stream = _MultipartStream(
rfile, content_length, content_type, max_body_bytes=max_file_bytes + MAX_FIELD_BYTES
)
fields, uploaded = stream.parse(work)
if uploaded is None:
raise MultipartError("上传请求中没有找到文件。")
if uploaded.size > max_file_bytes:
raise MultipartError(f"文件超过 {max_file_bytes // (1024 * 1024)} MB 限制。")
return fields, uploaded
+79 -22
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import re import re
from typing import Any, Iterable from typing import Any, Iterable
from .models import NormalizedTransaction, StatementBatch from .models import NormalizedTransaction, SheetResult, StatementBatch
from .reader import RawSheet, read_workbook from .reader import RawSheet, read_workbook
from .templates import BankTemplate, TEMPLATES, normalize_header from .templates import BankTemplate, TEMPLATES, normalize_header
@@ -24,8 +24,11 @@ class AmbiguousTemplateError(StatementParseError):
pass pass
SCAN_LIMIT = 50
def detect_header( def detect_header(
rows: tuple[tuple[Any, ...], ...], scan_limit: int = 50 rows: tuple[tuple[Any, ...], ...], scan_limit: int = SCAN_LIMIT
) -> tuple[BankTemplate, int, dict[str, int]]: ) -> tuple[BankTemplate, int, dict[str, int]]:
candidates: list[tuple[int, BankTemplate, int, dict[str, int]]] = [] candidates: list[tuple[int, BankTemplate, int, dict[str, int]]] = []
for row_index, row in enumerate(rows[:scan_limit]): for row_index, row in enumerate(rows[:scan_limit]):
@@ -42,8 +45,8 @@ def detect_header(
if not candidates: if not candidates:
inspected = min(len(rows), scan_limit) inspected = min(len(rows), scan_limit)
candidate_rows = _header_candidate_summary(rows[:scan_limit]) candidate_rows = _header_candidate_rows(rows[:scan_limit])
detail = f";候选表头:{candidate_rows}" if candidate_rows else "" detail = f";候选表头:{''.join(candidate_rows)}" if candidate_rows else ""
raise UnknownTemplateError( raise UnknownTemplateError(
f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}" f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}"
) )
@@ -60,21 +63,69 @@ def detect_header(
def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]: def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]:
source = Path(path) batches = tuple(result.batch for result in analyze_workbook(path) if result.batch)
batches: list[StatementBatch] = []
errors: list[str] = []
for sheet in read_workbook(source):
if not any(any(_text(value) for value in row) for row in sheet.rows):
continue
try:
batches.append(_parse_sheet(source, sheet))
except UnknownTemplateError as exc:
errors.append(f"{sheet.name}: {exc}")
if not batches: if not batches:
detail = "; ".join(errors) or "Workbook contains no readable worksheets." raise UnknownTemplateError(f"{Path(path).name}: 工作簿中没有可解析的工作表。")
raise UnknownTemplateError(f"{source.name}: {detail}") return batches
return tuple(batches)
def analyze_workbook(path: str | Path) -> tuple[SheetResult, ...]:
"""Parse every worksheet into an independent result.
Each worksheet yields exactly one :class:`SheetResult` whose ``outcome``
is ``parsed``, ``exception`` or ``ignored``. Unreadable workbooks raise
``CorruptWorkbookError``; a workbook that reads but contains no parsable
sheet still returns one result per sheet so the UI can surface the
filename / sheet / scanned range / candidate headers evidence.
"""
source = Path(path)
return tuple(_analyze_sheet(source, sheet) for sheet in read_workbook(source))
def _analyze_sheet(source: Path, sheet: RawSheet) -> SheetResult:
rows = sheet.rows
scanned = min(len(rows), SCAN_LIMIT)
if not rows:
return SheetResult(
sheet_name=sheet.name,
outcome="ignored",
message=f"工作表「{sheet.name}」为空,已跳过。",
scanned_rows=0,
)
if not any(any(_text(value) for value in row) for row in rows):
return SheetResult(
sheet_name=sheet.name,
outcome="ignored",
message=f"工作表「{sheet.name}」无有效内容,已跳过。",
scanned_rows=scanned,
)
try:
batch = _parse_sheet(source, sheet)
except UnknownTemplateError as exc:
return SheetResult(
sheet_name=sheet.name,
outcome="exception",
message=_clean_sheet_message(str(exc), source),
scanned_rows=scanned,
candidate_headers=_header_candidate_rows(rows),
)
except (AmbiguousTemplateError, StatementParseError) as exc:
return SheetResult(
sheet_name=sheet.name,
outcome="exception",
message=_clean_sheet_message(str(exc), source),
scanned_rows=scanned,
)
return SheetResult(
sheet_name=sheet.name,
outcome="parsed",
scanned_rows=scanned,
batch=batch,
)
def _clean_sheet_message(message: str, source: Path) -> str:
return message.replace(str(source), source.name).replace(source.name, "本文件")
def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]: def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
@@ -90,9 +141,14 @@ def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
return tuple(batch for file in files for batch in parse_statement(file)) return tuple(batch for file in files for batch in parse_statement(file))
def _header_candidate_summary( def _header_candidate_rows(
rows: tuple[tuple[Any, ...], ...], limit: int = 3 rows: tuple[tuple[Any, ...], ...], limit: int = 3
) -> str: ) -> tuple[str, ...]:
"""Preview up to ``limit`` plausible header rows.
Every non-empty row counts as a candidate, including single-cell rows, so
an ambiguous workbook always has explicit scan evidence for the UI.
"""
known_headers = { known_headers = {
alias alias
for template in TEMPLATES for template in TEMPLATES
@@ -102,7 +158,7 @@ def _header_candidate_summary(
candidates: list[tuple[int, int, int, tuple[str, ...]]] = [] candidates: list[tuple[int, int, int, tuple[str, ...]]] = []
for row_index, row in enumerate(rows): for row_index, row in enumerate(rows):
values = tuple(_text(value) for value in row if _text(value)) values = tuple(_text(value) for value in row if _text(value))
if len(values) < 2: if not values:
continue continue
matched = sum(normalize_header(value) in known_headers for value in values) matched = sum(normalize_header(value) in known_headers for value in values)
candidates.append((matched, len(values), row_index, values)) candidates.append((matched, len(values), row_index, values))
@@ -114,7 +170,7 @@ def _header_candidate_summary(
if len(values) > 8: if len(values) > 8:
preview += "……" preview += "……"
summaries.append(f"{row_index + 1} 行「{preview}") summaries.append(f"{row_index + 1} 行「{preview}")
return "".join(summaries) return tuple(summaries)
def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch: def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
@@ -190,6 +246,7 @@ def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
else None, else None,
transactions=tuple(transactions), transactions=tuple(transactions),
warnings=tuple(warnings), warnings=tuple(warnings),
template_version=template.version,
) )
+281
View File
@@ -0,0 +1,281 @@
"""Personal transit account mappings and their administrator approval.
A personal transit mapping states that a personal bank account represents a
company for a specific flow direction inside an effective interval. The key is
the normalized account number; the account holder name is display-only and
never resolves a counterparty on its own (``AGENTS.md``: never infer a
financial fact from a name alone). Only administrator-approved (``active``)
mappings inside their effective window participate in counterparty resolution.
"""
from __future__ import annotations
import sqlite3
from .db import utc_now
from .master_data import (
ConflictError,
mask_account_number,
normalize_account_number,
record_change,
validate_date,
)
MAPPING_STATUSES = ("pending", "active", "returned", "disabled")
ALLOWED_DIRECTIONS = ("outgoing", "incoming", "both")
def get_mapping(connection: sqlite3.Connection, mapping_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM personal_transit_mappings WHERE id = ?", (mapping_id,)
).fetchone()
def submit_mapping(
connection: sqlite3.Connection,
*,
account_number: object,
account_name: object,
represented_company_id: int,
allowed_direction: str,
effective_from: object = None,
actor: sqlite3.Row | None,
) -> sqlite3.Row:
"""Create a pending personal transit mapping; returns the new row.
The account number is normalized (digits only) and unique: resubmitting a
returned request from the same company reopens the same row as pending;
any other existing row is a conflict guarded by the UNIQUE constraint.
"""
number = normalize_account_number(account_number)
holder = str(account_name or "").strip() or None
direction = str(allowed_direction or "").strip()
if direction not in ALLOWED_DIRECTIONS:
raise ValueError(f"允许方向必须是:{''.join(ALLOWED_DIRECTIONS)}")
if not holder:
raise ValueError("账户户名不能为空(仅作展示,不作匹配键)。")
company = connection.execute(
"SELECT id FROM companies WHERE id = ?", (represented_company_id,)
).fetchone()
if company is None:
raise ValueError("代表的公司不存在。")
start = validate_date(effective_from, "生效日期")
now = utc_now()
existing = connection.execute(
"SELECT * FROM personal_transit_mappings WHERE account_number = ?", (number,)
).fetchone()
if existing is None:
try:
with connection:
cursor = connection.execute(
"""
INSERT INTO personal_transit_mappings (
account_number, account_name, represented_company_id,
allowed_direction, status, effective_from, submitted_by,
created_at, updated_at
) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
""",
(
number, holder, represented_company_id, direction,
start, actor["id"] if actor else None, now, now,
),
)
except sqlite3.IntegrityError as exc:
raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") from exc
mapping_id = int(cursor.lastrowid)
with connection:
record_change(
connection, "personal_transit_mapping", mapping_id, "submit", None,
{"account_number": number, "account_name": holder,
"represented_company_id": represented_company_id,
"allowed_direction": direction, "status": "pending",
"effective_from": start},
None, actor,
)
return get_mapping(connection, mapping_id)
if existing["status"] == "returned":
with connection:
connection.execute(
"""
UPDATE personal_transit_mappings
SET account_name = ?, represented_company_id = ?,
allowed_direction = ?, status = 'pending', effective_from = ?,
submitted_by = ?, reviewed_by = NULL, reviewed_at = NULL,
review_reason = NULL, updated_at = ?
WHERE id = ? AND status = 'returned'
""",
(
holder, represented_company_id, direction, start,
actor["id"] if actor else None, now, existing["id"],
),
)
record_change(
connection, "personal_transit_mapping", existing["id"], "resubmit",
None, {"account_number": number, "status": "pending",
"allowed_direction": direction, "effective_from": start},
"退回后重新提交", actor,
)
return get_mapping(connection, existing["id"])
raise ConflictError("该个人过账账号已登记,请等待现有申请处理。")
def review_mapping(
connection: sqlite3.Connection,
mapping_id: int,
decision: str,
reason: str | None,
actor: sqlite3.Row,
*,
effective_from: object = None,
effective_to: object = None,
) -> sqlite3.Row:
"""Approve, return or disable a mapping; returns the updated row."""
mapping = get_mapping(connection, mapping_id)
if mapping is None:
raise LookupError("个人过账映射不存在。")
reason = (reason or "").strip() or None
today = utc_now()[:10]
if decision == "approve":
if mapping["status"] != "pending":
raise ConflictError("只有待复核的映射可以审核通过。")
start = validate_date(effective_from, "生效日期") or mapping["effective_from"] or today
with connection:
connection.execute(
"""
UPDATE personal_transit_mappings
SET status = 'active', effective_from = ?, effective_to = NULL,
reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ?
WHERE id = ?
""",
(start, actor["id"], utc_now(), reason, utc_now(), mapping_id),
)
record_change(
connection, "personal_transit_mapping", mapping_id, "approve", None,
{"status": "active", "effective_from": start}, reason, actor,
)
elif decision == "return":
if mapping["status"] != "pending":
raise ConflictError("只有待复核的映射可以退回。")
if reason is None:
raise ValueError("退回必须填写原因。")
with connection:
connection.execute(
"""
UPDATE personal_transit_mappings
SET status = 'returned', reviewed_by = ?, reviewed_at = ?,
review_reason = ?, updated_at = ?
WHERE id = ?
""",
(actor["id"], utc_now(), reason, utc_now(), mapping_id),
)
record_change(
connection, "personal_transit_mapping", mapping_id, "return", None,
{"status": "returned"}, reason, actor,
)
elif decision == "disable":
if mapping["status"] != "active":
raise ConflictError("只有已启用的映射可以停用。")
if reason is None:
raise ValueError("停用必须填写原因。")
end = validate_date(effective_to, "停用日期") or today
with connection:
connection.execute(
"""
UPDATE personal_transit_mappings
SET status = 'disabled', effective_to = ?,
reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ?
WHERE id = ?
""",
(end, actor["id"], utc_now(), reason, utc_now(), mapping_id),
)
record_change(
connection, "personal_transit_mapping", mapping_id, "disable", None,
{"status": "disabled", "effective_to": end}, reason, actor,
)
else:
raise ValueError("审核决定必须是 approve、return 或 disable。")
return get_mapping(connection, mapping_id)
def list_mappings(
connection: sqlite3.Connection,
company_id: int | None = None,
status: str | None = None,
) -> list[sqlite3.Row]:
conditions: list[str] = []
params: list[object] = []
if company_id is not None:
conditions.append("m.represented_company_id = ?")
params.append(company_id)
if status is not None:
if status not in MAPPING_STATUSES:
raise ValueError("无效的映射状态。")
conditions.append("m.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
return connection.execute(
f"""
SELECT m.*, c.name AS company_name
FROM personal_transit_mappings m
JOIN companies c ON c.id = m.represented_company_id
{where}
ORDER BY m.id
""",
params,
).fetchall()
def is_mapping_active(mapping: sqlite3.Row, on_date: str) -> bool:
"""True when the mapping may resolve a counterparty on ``on_date``.
``effective_to`` is the last participating day (inclusive). Disabled
mappings stop resolving new rows but keep identifying historical rows
inside their effective window, matching ``bank_accounts`` semantics.
"""
if mapping["status"] not in ("active", "disabled"):
return False
if mapping["effective_from"] and on_date < mapping["effective_from"]:
return False
if mapping["effective_to"] and on_date > mapping["effective_to"]:
return False
return True
def direction_allowed(mapping: sqlite3.Row, row_direction: str) -> bool:
"""True when the personal account may represent the company for this flow.
``allowed_direction`` describes the transfer from the represented company's
perspective: when the personal account appears as the counterparty of an
outgoing row, the represented company is receiving (incoming); for an
incoming row the represented company is paying out (outgoing).
"""
counterparty_flow = "incoming" if row_direction == "outgoing" else "outgoing"
allowed = mapping["allowed_direction"]
return allowed == "both" or allowed == counterparty_flow
def mapping_payload(mapping: sqlite3.Row, *, full: bool) -> dict[str, object]:
"""Serialize a mapping; company-facing views only ever see masked numbers."""
payload: dict[str, object] = {
"id": mapping["id"],
"account_name": mapping["account_name"],
"represented_company_id": mapping["represented_company_id"],
"allowed_direction": mapping["allowed_direction"],
"status": mapping["status"],
"effective_from": mapping["effective_from"],
"effective_to": mapping["effective_to"],
"reviewed_at": mapping["reviewed_at"],
"review_reason": mapping["review_reason"],
"created_at": mapping["created_at"],
}
if "company_name" in mapping.keys():
payload["company_name"] = mapping["company_name"]
if full:
payload["account_number"] = mapping["account_number"]
else:
payload["account_number_masked"] = mask_account_number(mapping["account_number"])
return payload
File diff suppressed because it is too large Load Diff
+59 -13
View File
@@ -10,6 +10,23 @@ class UnsupportedWorkbookError(RuntimeError):
pass pass
class CorruptWorkbookError(RuntimeError):
"""The file cannot be opened as a valid Excel workbook.
Raised for corrupt/truncated files, wrong signatures and files that
exceed the resource limits. The message is user-safe: it never contains
server-side paths or the content-addressed storage filename.
"""
# Workbook resource guards (defense against decompression bombs and
# accidentally giant exports). Bank statements are small; these bounds are
# generous enough for real exports while keeping memory bounded.
MAX_SHEETS = 50
MAX_ROWS_PER_SHEET = 200_000
MAX_COLS_PER_SHEET = 64
@dataclass(frozen=True) @dataclass(frozen=True)
class RawSheet: class RawSheet:
name: str name: str
@@ -18,11 +35,18 @@ class RawSheet:
def read_workbook(path: Path) -> tuple[RawSheet, ...]: def read_workbook(path: Path) -> tuple[RawSheet, ...]:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix == ".xlsx": try:
return _read_xlsx(path) if suffix == ".xlsx":
if suffix == ".xls": return _read_xlsx(path)
return _read_xls(path) if suffix == ".xls":
raise UnsupportedWorkbookError(f"Unsupported workbook type: {suffix}") return _read_xls(path)
raise UnsupportedWorkbookError(f"Unsupported workbook type: {suffix}")
except (UnsupportedWorkbookError, CorruptWorkbookError):
raise
except Exception as exc:
raise CorruptWorkbookError(
"文件无法读取,可能已损坏或不是有效的 Excel 文件。"
) from exc
def _read_xlsx(path: Path) -> tuple[RawSheet, ...]: def _read_xlsx(path: Path) -> tuple[RawSheet, ...]:
@@ -35,13 +59,24 @@ def _read_xlsx(path: Path) -> tuple[RawSheet, ...]:
workbook = load_workbook(path, read_only=True, data_only=True) workbook = load_workbook(path, read_only=True, data_only=True)
try: try:
return tuple( sheets: list[RawSheet] = []
RawSheet( for worksheet in workbook.worksheets:
name=worksheet.title, rows: list[tuple[Any, ...]] = []
rows=tuple(tuple(row) for row in worksheet.iter_rows(values_only=True)), for row_index, row in enumerate(worksheet.iter_rows(values_only=True)):
) if row_index >= MAX_ROWS_PER_SHEET:
for worksheet in workbook.worksheets raise CorruptWorkbookError(
) f"工作表「{worksheet.title}」超过 {MAX_ROWS_PER_SHEET} 行,已拒绝读取。"
)
values = tuple(row)
if len(values) > MAX_COLS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{worksheet.title}」列数超过 {MAX_COLS_PER_SHEET},已拒绝读取。"
)
rows.append(values)
sheets.append(RawSheet(name=worksheet.title, rows=tuple(rows)))
if len(sheets) > MAX_SHEETS:
raise CorruptWorkbookError(f"工作簿工作表数量超过 {MAX_SHEETS} 个,已拒绝读取。")
return tuple(sheets)
finally: finally:
workbook.close() workbook.close()
@@ -57,8 +92,19 @@ def _read_xls(path: Path) -> tuple[RawSheet, ...]:
workbook = xlrd.open_workbook(path, on_demand=True) workbook = xlrd.open_workbook(path, on_demand=True)
sheets: list[RawSheet] = [] sheets: list[RawSheet] = []
try: try:
for sheet_name in workbook.sheet_names(): names = workbook.sheet_names()
if len(names) > MAX_SHEETS:
raise CorruptWorkbookError(f"工作簿工作表数量超过 {MAX_SHEETS} 个,已拒绝读取。")
for sheet_name in names:
worksheet = workbook.sheet_by_name(sheet_name) worksheet = workbook.sheet_by_name(sheet_name)
if worksheet.nrows > MAX_ROWS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{sheet_name}」超过 {MAX_ROWS_PER_SHEET} 行,已拒绝读取。"
)
if worksheet.ncols > MAX_COLS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{sheet_name}」列数超过 {MAX_COLS_PER_SHEET},已拒绝读取。"
)
rows: list[tuple[Any, ...]] = [] rows: list[tuple[Any, ...]] = []
for row_index in range(worksheet.nrows): for row_index in range(worksheet.nrows):
values: list[Any] = [] values: list[Any] = []
+398
View File
@@ -0,0 +1,398 @@
"""Statutory subject suggestions, mirror mapping and confirmation (B-44).
Subjects are stored from one participating company's perspective and the
other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two
companies can never record conflicting subjects. Bank summary/purpose text
only ever produces a *suggestion*; nothing here confirms a subject
automatically. Confirmation is an explicit administrator decision carrying
``expected_revision`` and an idempotency key.
"""
from __future__ import annotations
import json
import re
import sqlite3
from . import matching
SUBJECTS = ("receivable", "payable", "other_receivable", "other_payable")
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
MIRROR = {
"receivable": "payable",
"payable": "receivable",
"other_receivable": "other_payable",
"other_payable": "other_receivable",
}
SUBJECT_LABELS = {
"receivable": "应收",
"payable": "应付",
"other_receivable": "其他应收",
"other_payable": "其他应付",
}
_FULL_WIDTH = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz0123456789",
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz0123456789",
)
# Draft v1 dictionary. Exact-keyword matching only; every hit is a suggestion
# and never an automatic posting. Trade-type keywords are deliberately absent
# until the group supplies an approved dictionary (they always go to review).
_LOAN_LIKE = ("借款", "往来款", "资金往来", "临时借款", "资金调拨", "代垫", "垫付")
_REPAY_LIKE = ("还款", "归还借款", "归还往来款")
class SubjectConflictError(ValueError):
"""A stale revision or idempotency conflict (mapped to HTTP 409)."""
class SubjectInputError(ValueError):
"""Invalid input for a subject decision (mapped to HTTP 400/422)."""
def mirror_subject(subject_code: str) -> str:
if subject_code not in MIRROR:
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
return MIRROR[subject_code]
def subject_label(subject_code: str) -> str:
return SUBJECT_LABELS.get(subject_code, subject_code)
def _normalize(text: object) -> str:
return re.sub(r"[\s\ufeff]+", "", str(text or "").translate(_FULL_WIDTH))
def _bank_evidence_texts(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, str]:
"""Purpose/summary text of the B-43 source rows behind a bank event."""
row = connection.execute(
"""
SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
if row is None:
return {"purpose": "", "summary": ""}
decision = matching._current_decision_for_event(connection, row["bank_event_id"])
if decision is None:
return {"purpose": "", "summary": ""}
observations = matching._decision_observations(connection, decision["id"])
texts: dict[str, list[str]] = {"purpose": [], "summary": []}
for observation in observations:
source = connection.execute(
"SELECT purpose, summary FROM source_rows WHERE id = ?",
(observation["source_row_id"],),
).fetchone()
if source is None:
continue
for key in ("purpose", "summary"):
value = str(source[key] or "").strip()
if value:
texts[key].append(value)
return {
"purpose": " ".join(texts["purpose"]),
"summary": " ".join(texts["summary"]),
}
def compute_suggestions(
connection: sqlite3.Connection, ledger_event_id: int
) -> list[dict[str, object]]:
""" Deterministic draft suggestions for a pending event, never confirmation.
Purpose rules take precedence over summary rules. When both loan-like and
repay-like keywords match, both candidates are returned as a conflict for
the reviewer; no priority breaks the tie.
"""
from .ledger_events import current_revision
revision = current_revision(connection, ledger_event_id)
if revision is None or revision["state"] != "pending_subject":
return []
texts = _bank_evidence_texts(connection, ledger_event_id)
purpose = _normalize(texts["purpose"])
summary = _normalize(texts["summary"])
search = purpose or summary
payer = revision["payer_company_id"]
payee = revision["payee_company_id"]
loan_hit = next((word for word in _LOAN_LIKE if word in search), None)
repay_hit = next((word for word in _REPAY_LIKE if word in search), None)
suggestions: list[dict[str, object]] = []
if loan_hit:
suggestions.append(
{
"suggested_perspective_company_id": payer,
"suggested_subject_code": "other_receivable",
"reason": f"匹配建议词典「{loan_hit}」,建议付款方其他应收",
"rule_version": SUBJECT_RULE_VERSION,
"evidence": {
"keyword": loan_hit,
"matched_text": search,
"approved": False,
},
}
)
if repay_hit:
suggestions.append(
{
"suggested_perspective_company_id": payee,
"suggested_subject_code": "other_receivable",
"reason": f"匹配建议词典「{repay_hit}」,建议收款方其他应收",
"rule_version": SUBJECT_RULE_VERSION,
"evidence": {
"keyword": repay_hit,
"matched_text": search,
"approved": False,
},
}
)
return suggestions
def store_suggestions(connection: sqlite3.Connection, ledger_event_id: int) -> int:
"""Compute and append suggestions for a pending event. Returns count stored."""
from .ledger_events import current_revision
revision = current_revision(connection, ledger_event_id)
if revision is None or revision["state"] != "pending_subject":
return 0
stored = 0
for suggestion in compute_suggestions(connection, ledger_event_id):
from .db import utc_now
connection.execute(
"""
INSERT INTO ledger_subject_suggestions (
ledger_event_id, source_revision_id,
suggested_perspective_company_id, suggested_subject_code,
rule_version, evidence_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
ledger_event_id, revision["id"],
suggestion["suggested_perspective_company_id"],
suggestion["suggested_subject_code"],
suggestion["rule_version"],
json.dumps(suggestion.get("evidence", {}), ensure_ascii=False),
utc_now(),
),
)
stored += 1
return stored
def confirm_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
perspective_company_id: int,
subject_code: str,
reason: str,
expected_revision: int | None,
request_key: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
"""Confirm a subject, turning a pending event into a confirmed revision."""
from .ledger_events import append_revision, current_revision
reason = (reason or "").strip()
if not reason:
raise SubjectInputError("必须填写科目确认依据。")
if subject_code not in SUBJECTS:
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
if request_key:
existing = connection.execute(
"""
SELECT * FROM ledger_event_revisions
WHERE ledger_event_id = ? AND idempotency_key = ?
ORDER BY id LIMIT 1
""",
(ledger_event_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _revision_payload(connection, existing)
current = current_revision(connection, ledger_event_id)
if current is None:
raise SubjectConflictError("该事件不存在或没有当前修订。")
if current["state"] != "pending_subject":
raise SubjectConflictError("只有待确认科目的事件可以确认科目。")
# ``expected_revision`` may be the revision row id (what the API/UI
# sends as ``ledger_revision_id``) or the per-event sequence number;
# both identify the exact revision the client saw.
if expected_revision is not None and int(expected_revision) not in (
current["id"], current["revision"],
):
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
participants = {current["payer_company_id"], current["payee_company_id"]}
if perspective_company_id not in participants:
raise SubjectInputError("视角公司必须是事件参与方。")
revision_id = append_revision(
connection,
ledger_event_id,
state="confirmed",
effective_at=current["effective_at"],
amount=current["amount"],
currency=current["currency"],
payer_company_id=current["payer_company_id"],
payee_company_id=current["payee_company_id"],
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
posting_kind=current["posting_kind"],
reverses_ledger_event_id=current["reverses_ledger_event_id"],
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
evidence_json=current["evidence_json"],
idempotency_key=request_key,
actor=actor,
reason=reason,
supersedes_revision_id=current["id"],
)
row = connection.execute(
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
).fetchone()
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _revision_payload(connection, row)
def park_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
disposition: str,
reason: str,
expected_revision: int | None,
request_key: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
"""Record 退回/转异常 without confirming a statutory subject.
The event stays ``pending_subject`` so it never enters confirmed balances.
``exception`` is hidden from the active review queue; ``return`` remains
visible so the company can supplement materials.
"""
from .ledger_events import append_revision, current_revision
if disposition not in ("return", "exception"):
raise SubjectInputError("科目处理只能是退回或转异常。")
reason = (reason or "").strip()
if not reason:
raise SubjectInputError("必须填写处理依据。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
if request_key:
existing = connection.execute(
"""
SELECT * FROM ledger_event_revisions
WHERE ledger_event_id = ? AND idempotency_key = ?
ORDER BY id LIMIT 1
""",
(ledger_event_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _revision_payload(connection, existing)
current = current_revision(connection, ledger_event_id)
if current is None:
raise SubjectConflictError("该事件不存在或没有当前修订。")
if current["state"] != "pending_subject":
raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。")
if expected_revision is not None and int(expected_revision) not in (
current["id"], current["revision"],
):
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {}
evidence["admin_disposition"] = disposition
revision_id = append_revision(
connection,
ledger_event_id,
state="pending_subject",
effective_at=current["effective_at"],
amount=current["amount"],
currency=current["currency"],
payer_company_id=current["payer_company_id"],
payee_company_id=current["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
posting_kind=current["posting_kind"],
reverses_ledger_event_id=current["reverses_ledger_event_id"],
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
evidence_json=json.dumps(evidence, ensure_ascii=False),
idempotency_key=request_key,
actor=actor,
reason=reason,
supersedes_revision_id=current["id"],
)
row = connection.execute(
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
).fetchone()
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _revision_payload(connection, row)
def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]:
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],)
).fetchone()
return {
"ledger_event_id": revision["ledger_event_id"],
"revision_id": revision["id"],
"revision": revision["revision"],
"state": revision["state"],
"effective_at": revision["effective_at"],
"amount": revision["amount"],
"currency": revision["currency"],
"payer_company_id": revision["payer_company_id"],
"payee_company_id": revision["payee_company_id"],
"perspective_company_id": revision["perspective_company_id"],
"perspective_company_name": company["name"] if company else None,
"subject_code": revision["subject_code"],
"subject_label": subject_label(revision["subject_code"])
if revision["subject_code"]
else None,
"posting_kind": revision["posting_kind"],
"source_kind": revision["source_kind"],
"reason": revision["reason"],
"created_at": revision["created_at"],
}
+1
View File
@@ -10,6 +10,7 @@ class BankTemplate:
bank_name: str bank_name: str
columns: dict[str, tuple[str, ...]] columns: dict[str, tuple[str, ...]]
required: tuple[str, ...] required: tuple[str, ...]
version: int = 1
def normalize_header(value: object) -> str: def normalize_header(value: object) -> str:
-43
View File
@@ -1,43 +0,0 @@
param(
[ValidateRange(1, 65535)]
[int]$Port = 4173
)
$ErrorActionPreference = "Stop"
docker compose version | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose is not available."
}
docker info | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Docker is not running."
}
$env:APP_PORT = $Port.ToString()
docker compose up --build --detach
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start the application."
}
$containerId = (docker compose ps --quiet app).Trim()
if (-not $containerId) {
throw "The application container was not created."
}
for ($attempt = 0; $attempt -lt 60; $attempt++) {
$state = docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $containerId
if ($state -eq "healthy") {
Write-Host "Application is ready: http://localhost:$Port"
exit 0
}
if ($state -eq "unhealthy" -or $state -eq "exited" -or $state -eq "dead") {
docker compose logs --no-color --tail 100 app
throw "The application container entered state: $state"
}
Start-Sleep -Seconds 1
}
docker compose logs --no-color --tail 100 app
throw "Timed out waiting for the application health check."
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env sh
set -eu
APP_PORT="${APP_PORT:-${1:-4173}}"
export APP_PORT
case "$APP_PORT" in
''|*[!0-9]*)
echo "APP_PORT must be an integer between 1 and 65535." >&2
exit 1
;;
esac
if [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then
echo "APP_PORT must be an integer between 1 and 65535." >&2
exit 1
fi
docker compose version >/dev/null
docker info >/dev/null
docker compose up --build --detach
container_id="$(docker compose ps --quiet app)"
if [ -z "$container_id" ]; then
echo "The application container was not created." >&2
exit 1
fi
attempt=0
while [ "$attempt" -lt 60 ]; do
state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
case "$state" in
healthy)
echo "Application is ready: http://localhost:${APP_PORT}"
exit 0
;;
unhealthy|exited|dead)
docker compose logs --no-color --tail 100 app
echo "The application container entered state: $state" >&2
exit 1
;;
esac
attempt=$((attempt + 1))
sleep 1
done
docker compose logs --no-color --tail 100 app
echo "Timed out waiting for the application health check." >&2
exit 1
+86
View File
@@ -0,0 +1,86 @@
"use strict";
const path = require("path");
const assert = require("assert");
const ui = require(path.join(__dirname, "..", "web", "app.js"));
assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
assert.strictEqual(ui.cycleTab(0, 4, false), 1);
assert.strictEqual(ui.cycleTab(3, 4, false), 0);
assert.strictEqual(ui.cycleTab(0, 4, true), 3);
assert.strictEqual(ui.cycleTab(2, 5, true), 1);
assert.strictEqual(ui.cycleTab(0, 0, false), 0);
assert.strictEqual(ui.drawerEscAction(1), "close");
assert.strictEqual(ui.drawerEscAction(0), "close");
assert.strictEqual(ui.drawerEscAction(2), "back");
assert.strictEqual(ui.drawerEscAction(3), "back");
const abs = ui.amountWithCurrency(1280, "CNY");
assert.ok(abs.includes("CNY"));
assert.ok(abs.includes("1,280.00"));
assert.ok(!abs.includes("+"));
assert.ok(!abs.includes(""));
const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
assert.ok(repay.includes(""));
assert.ok(!repay.includes("+"));
assert.ok(repay.includes("3,200,000.00"));
assert.strictEqual(ui.resultDirection(10).label, "应收");
assert.strictEqual(ui.resultDirection(-10).label, "应付");
assert.strictEqual(ui.resultDirection(0).label, "持平");
assert.strictEqual(ui.isCompactAmount(1280), false);
assert.strictEqual(ui.isCompactAmount(999999999), false);
assert.strictEqual(ui.isCompactAmount(1000000000), true);
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
const subjectFields = ui.auditEvidenceFields("subject-review", {
direction: "outgoing",
effectiveAt: "2026-07-18T09:00:00",
amount: "600",
currency: "CNY",
summary: "资金调拨",
});
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
assert.strictEqual(subjectFields[0][1], "转出");
const manualFields = ui.auditEvidenceFields("manual-review", {
counterpartyName: "乙公司",
direction: "incoming",
relatedSourceRowId: "",
submittedBy: "出纳甲",
attachmentName: "",
amount: "80",
currency: "CNY",
summary: "补记",
});
assert.deepStrictEqual(
manualFields.map((item) => item[0]),
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
);
assert.strictEqual(manualFields[0][1], "乙公司");
assert.strictEqual(manualFields[1][1], "转入");
assert.strictEqual(manualFields[2][1], "无关联流水");
assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
console.log("b44_ui_check ok");
+138
View File
@@ -0,0 +1,138 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>B-44 余额目录布局回归</title>
<link rel="stylesheet" href="../../web/styles.css" />
</head>
<body data-portal="admin">
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
<nav class="nav-list">
<button class="nav-item is-active" type="button"><span>往来查询</span></button>
</nav>
</aside>
<div class="workspace">
<main id="main-content">
<section class="app-view is-active">
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
<section class="panel company-ledger-panel" aria-label="公司余额目录">
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
<div class="company-ledgers">
<details class="company-ledger is-balances">
<summary>
<span class="company-name" title="甲公司"><i></i><b>甲公司</b><small class="currency-tag">CNY</small></span>
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
<span class="ledger-cutoff">截止 2026.07.31</span>
<svg></svg>
</summary>
</details>
<details class="company-ledger is-balances" id="stressRow">
<summary>
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i></i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
<span class="ledger-cutoff">截止 2026.07.31</span>
<svg></svg>
</summary>
</details>
</div>
</section>
</section>
</main>
</div>
</div>
<aside class="drawer is-open" id="evidenceDrawer">
<header class="drawer-header">
<div>
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
<h2>甲公司 ↔ 乙公司</h2>
</div>
<button type="button" class="icon-button" aria-label="关闭">×</button>
</header>
<div class="drawer-body">
<div class="pair-balance-line is-six" id="drawerSix">
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
</div>
</div>
</aside>
<pre id="b175-metrics" hidden></pre>
<script>
function box(el) {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
}
function overlap(a, b) {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(Math.min(dx, dy) === dy ? dx : dx);
}
function clipped(el) {
return el.scrollWidth > el.clientWidth + 1;
}
function measure() {
const row = document.querySelector("#stressRow");
const summary = row.querySelector("summary");
const amount = row.querySelector(".ledger-result .amount-with-currency");
const direction = row.querySelector(".ledger-result .status");
const unresolved = row.querySelector(".ledger-unresolved .status");
const cutoff = row.querySelector(".ledger-cutoff");
const chevron = row.querySelector("summary > svg");
const head = document.querySelector(".ledger-head.is-balances");
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
const amountBox = box(amount);
const unresolvedBox = box(unresolved);
const cutoffBox = box(cutoff);
const chevronBox = box(chevron);
const directionBox = box(direction);
const metrics = {
viewport: window.innerWidth,
clientWidth: document.documentElement.clientWidth,
fiveColVisible: getComputedStyle(head).display !== "none",
amountText: amount.textContent.replace(/\s+/g, " ").trim(),
unresolvedText: unresolved.textContent.replace(/\s+/g, " ").trim(),
cutoffText: cutoff.textContent.replace(/\s+/g, " ").trim(),
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
amountCutoffOverlap: overlap(amountBox, cutoffBox),
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
amountClipped: clipped(amount),
unresolvedClipped: clipped(unresolved),
cutoffClipped: clipped(cutoff),
amountVisible: amountBox.width > 4 && amountBox.height > 4,
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
drawerAmountClipped: clipped(drawerAmount),
summaryWidth: Math.round(box(summary).width),
};
const node = document.getElementById("b175-metrics");
node.hidden = false;
node.textContent = JSON.stringify(metrics);
document.title = "B175 " + node.textContent;
}
window.measureB175 = measure;
if (document.readyState === "complete") {
measure();
} else {
window.addEventListener("load", measure);
}
</script>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
"""Shared fixtures for B-44 ledger / manual / position tests."""
from __future__ import annotations
from pathlib import Path
import sqlite3
import tempfile
import unittest
from bank_importer import auth, matching, master_data
from bank_importer.db import connect, migrate, utc_now
class LedgerBase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
self.admin = self._admin()
self.company_a = self._company("甲公司")
self.company_b = self._company("乙公司")
self.company_c = self._company("丙公司")
self.account_a = self._approved_account(self.company_a, "6222000000000001")
self.account_b = self._approved_account(self.company_b, "6222000000000002")
def _admin(self):
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
return self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
def _company(self, name: str) -> int:
with self.connection:
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, utc_now(), utc_now()),
)
return int(cursor.lastrowid)
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
account = master_data.submit_bank_account(
self.connection, company_id=company_id, bank_name="中信银行",
account_type="基本户", account_number=number, start_date=start,
actor=None,
)
return master_data.review_bank_account(
self.connection, account["id"], "approve", None, self.admin,
effective_from=start,
)
def add_row(
self,
company_id: int,
*,
own_account: str,
cp_account: str | None = None,
income: str = "0",
expense: str = "0",
at: str = "2026-01-05T10:00:00",
currency: str = "CNY",
reference: str | None = None,
summary: str | None = None,
purpose: str | None = None,
) -> int:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(utc_now(), utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
VALUES (?, 'parsing', ?, ?, ?)
""",
(source_file_id, company_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, own_account, own_name, period_start, period_end,
transaction_count, warnings, created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, NULL, NULL, NULL, NULL, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense, balance,
own_account, own_name, counterparty_account, counterparty_name,
counterparty_bank, summary, purpose, reference, currency, created_at
) VALUES (?, 1, ?, ?, ?, NULL, ?, '测试', ?, '对方', NULL, ?, ?, ?, ?, ?)
""",
(
sheet_batch_id, at, income, expense, own_account,
cp_account, summary, purpose, reference, currency, utc_now(),
),
)
return int(cursor.lastrowid)
def pair(
self,
payer: int,
payee: int,
amount: str,
at: str = "2026-01-05T10:00:00",
*,
currency: str = "CNY",
summary: str = "借款",
purpose: str = "往来款",
) -> tuple[int, int]:
"""Create a mirrored A/B pair and reconcile into an eligible event."""
payer_account = self._account_of(payer)
payee_account = self._account_of(payee)
row_payer = self.add_row(
payer, own_account=payer_account, cp_account=payee_account,
expense=amount, at=at, currency=currency, summary=summary, purpose=purpose,
)
row_payee = self.add_row(
payee, own_account=payee_account, cp_account=payer_account,
income=amount, at=at.replace("T10:", "T11:"), currency=currency,
summary=summary, purpose=purpose,
)
matching.reconcile_rows(self.connection, [row_payer, row_payee])
return row_payer, row_payee
def _account_of(self, company_id: int) -> str:
if company_id == self.company_a:
return self.account_a["account_number"]
if company_id == self.company_b:
return self.account_b["account_number"]
return "6222000000000005"
def eligible(self) -> list[sqlite3.Row]:
return matching.eligible_intercompany_events(self.connection)
def ledger_events(self) -> list[sqlite3.Row]:
return self.connection.execute(
"SELECT * FROM ledger_events ORDER BY id"
).fetchall()
def current(self, ledger_event_id: int) -> sqlite3.Row | None:
return self.connection.execute(
"""
SELECT r.* FROM current_ledger_event_revisions c
JOIN ledger_event_revisions r ON r.id = c.revision_id
WHERE c.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
def position_events(self) -> list[sqlite3.Row]:
return self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
+252
View File
@@ -0,0 +1,252 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import hashlib
import sqlite3
import unittest
from bank_importer import auth
from bank_importer.db import connect, migrate, utc_now
class AuthTestCase(unittest.TestCase):
def setUp(self) -> None:
self.connection = connect(":memory:")
self.addCleanup(self.connection.close)
migrate(self.connection)
now = utc_now()
with self.connection:
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(now, now),
)
self.company_id = int(self.connection.execute("SELECT id FROM companies").fetchone()["id"])
def create_company_user(self, username: str = "cashier-a", password: str = "Init1234") -> int:
return auth.create_user(
self.connection, username, password, "company", company_id=self.company_id
)
def create_admin(self, username: str = "group-admin", password: str = "Admin1234") -> int:
return auth.create_user(self.connection, username, password, "admin")
class PasswordHashTests(unittest.TestCase):
def test_hash_format_and_verify_roundtrip(self) -> None:
stored = auth.hash_password("Secret123")
scheme, iterations, salt_hex, hash_hex = stored.split("$")
self.assertEqual("pbkdf2_sha256", scheme)
self.assertEqual(str(auth.PBKDF2_ITERATIONS), iterations)
self.assertEqual(32, len(salt_hex))
self.assertEqual(64, len(hash_hex))
self.assertTrue(auth.verify_password("Secret123", stored))
def test_wrong_password_is_rejected(self) -> None:
stored = auth.hash_password("Secret123")
self.assertFalse(auth.verify_password("Secret124", stored))
def test_same_password_gets_different_salts(self) -> None:
self.assertNotEqual(auth.hash_password("Secret123"), auth.hash_password("Secret123"))
def test_malformed_stored_hash_is_rejected(self) -> None:
for stored in ("", "plain", "pbkdf2_sha256$x$y$z", "bcrypt$1$aa$bb"):
with self.subTest(stored=stored):
self.assertFalse(auth.verify_password("Secret123", stored))
class PasswordPolicyTests(unittest.TestCase):
def test_policy_rejects_short_username_like_and_single_charset(self) -> None:
self.assertIsNotNone(auth.validate_password_policy("Ab1", "cashier"))
self.assertIsNotNone(auth.validate_password_policy("Cashier99", "cashier99"))
self.assertIsNotNone(auth.validate_password_policy("abcdefgh", "cashier"))
self.assertIsNotNone(auth.validate_password_policy("12345678", "cashier"))
self.assertIsNone(auth.validate_password_policy("GoodPass1", "cashier"))
def test_initial_password_generator_guarantees_charset(self) -> None:
for _ in range(50):
password = auth.generate_initial_password()
self.assertEqual(12, len(password))
self.assertTrue(any(char.isupper() for char in password))
self.assertTrue(any(char.islower() for char in password))
self.assertTrue(any(char.isdigit() for char in password))
self.assertIsNone(auth.validate_password_policy(auth.generate_initial_password(), "x"))
def test_initial_password_exclude_never_equals_username(self) -> None:
for _ in range(50):
password = auth.generate_initial_password(exclude="Cashier99")
self.assertNotEqual(password.lower(), "cashier99")
class CreateUserTests(AuthTestCase):
def test_company_role_requires_company(self) -> None:
with self.assertRaises(ValueError):
auth.create_user(self.connection, "cashier-x", "Init1234", "company")
def test_company_must_exist(self) -> None:
with self.assertRaises(ValueError):
auth.create_user(self.connection, "cashier-x", "Init1234", "company", company_id=999)
def test_admin_cannot_bind_company(self) -> None:
with self.assertRaises(ValueError):
auth.create_user(
self.connection, "admin-x", "Init1234", "admin", company_id=self.company_id
)
def test_invalid_role_rejected(self) -> None:
with self.assertRaises(ValueError):
auth.create_user(self.connection, "nobody", "Init1234", "superuser")
def test_duplicate_username_rejected(self) -> None:
self.create_company_user()
with self.assertRaises(ValueError):
self.create_company_user()
def test_new_company_user_must_change_password_by_default(self) -> None:
user_id = self.create_company_user()
user = self.connection.execute(
"SELECT must_change_password, status FROM users WHERE id = ?", (user_id,)
).fetchone()
self.assertEqual(1, user["must_change_password"])
self.assertEqual("active", user["status"])
class AuthenticateTests(AuthTestCase):
def test_success_returns_user_and_records_attempt(self) -> None:
self.create_company_user()
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1")
self.assertIsNotNone(user)
self.assertIsNone(reason)
attempt = self.connection.execute("SELECT success FROM login_attempts").fetchone()
self.assertEqual(1, attempt["success"])
entry = self.connection.execute(
"SELECT action FROM audit_log WHERE action = 'login_success'"
).fetchone()
self.assertIsNotNone(entry)
def test_bad_credentials_do_not_leak_which_part_failed(self) -> None:
self.create_company_user()
for username, password in (("cashier-a", "Wrong999"), ("no-such-user", "Init1234")):
with self.subTest(username=username):
user, reason = auth.authenticate(self.connection, username, password, "127.0.0.1")
self.assertIsNone(user)
self.assertEqual("bad_credentials", reason)
def test_disabled_user_gets_disabled_reason(self) -> None:
user_id = self.create_company_user()
with self.connection:
self.connection.execute(
"UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
(utc_now(), user_id),
)
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1")
self.assertIsNone(user)
self.assertEqual("disabled", reason)
def test_five_failures_within_window_trigger_rate_limit(self) -> None:
self.create_company_user()
for _ in range(auth.RATE_LIMIT_MAX_FAILURES):
user, reason = auth.authenticate(self.connection, "cashier-a", "Wrong999", "10.0.0.1")
self.assertEqual("bad_credentials", reason)
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.1")
self.assertIsNone(user)
self.assertEqual("rate_limited", reason)
# A different IP is not limited.
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.2")
self.assertIsNotNone(user)
def test_failures_outside_window_do_not_count(self) -> None:
self.create_company_user()
old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
with self.connection:
for _ in range(auth.RATE_LIMIT_MAX_FAILURES):
self.connection.execute(
"INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?, ?, 0, ?)",
("cashier-a", "10.0.0.9", old),
)
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.9")
self.assertIsNotNone(user)
self.assertIsNone(reason)
class SessionTests(AuthTestCase):
def test_create_and_resolve_roundtrip(self) -> None:
user_id = self.create_company_user()
token = auth.create_session(self.connection, user_id)
user = auth.resolve_session(self.connection, token)
self.assertIsNotNone(user)
self.assertEqual(user_id, user["id"])
# Only the digest is stored, never the raw token.
row = self.connection.execute("SELECT token_hash FROM sessions").fetchone()
self.assertEqual(hashlib.sha256(token.encode()).hexdigest(), row["token_hash"])
self.assertNotIn(token, row["token_hash"])
def test_expired_session_is_rejected(self) -> None:
user_id = self.create_company_user()
token = auth.create_session(self.connection, user_id)
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
with self.connection:
self.connection.execute(
"UPDATE sessions SET expires_at = ? WHERE token_hash = ?",
(past, hashlib.sha256(token.encode()).hexdigest()),
)
self.assertIsNone(auth.resolve_session(self.connection, token))
def test_revoked_session_is_rejected(self) -> None:
user_id = self.create_company_user()
token = auth.create_session(self.connection, user_id)
auth.revoke_session(self.connection, token)
self.assertIsNone(auth.resolve_session(self.connection, token))
def test_revoke_user_sessions_kills_all_sessions(self) -> None:
user_id = self.create_company_user()
first = auth.create_session(self.connection, user_id)
second = auth.create_session(self.connection, user_id)
auth.revoke_user_sessions(self.connection, user_id)
self.assertIsNone(auth.resolve_session(self.connection, first))
self.assertIsNone(auth.resolve_session(self.connection, second))
def test_disabled_user_session_is_rejected(self) -> None:
user_id = self.create_company_user()
token = auth.create_session(self.connection, user_id)
with self.connection:
self.connection.execute(
"UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
(utc_now(), user_id),
)
self.assertIsNone(auth.resolve_session(self.connection, token))
class ChangePasswordTests(AuthTestCase):
def test_change_password_clears_flag_and_updates_hash(self) -> None:
user_id = self.create_company_user()
error = auth.change_password(self.connection, user_id, "Init1234", "NewPass99")
self.assertIsNone(error)
user = self.connection.execute(
"SELECT password_hash, must_change_password FROM users WHERE id = ?", (user_id,)
).fetchone()
self.assertEqual(0, user["must_change_password"])
self.assertTrue(auth.verify_password("NewPass99", user["password_hash"]))
self.assertFalse(auth.verify_password("Init1234", user["password_hash"]))
entry = self.connection.execute(
"SELECT detail FROM audit_log WHERE action = 'password_change'"
).fetchone()
self.assertIsNotNone(entry)
self.assertNotIn("NewPass99", entry["detail"] or "")
def test_wrong_old_password_rejected(self) -> None:
user_id = self.create_company_user()
error = auth.change_password(self.connection, user_id, "Wrong999", "NewPass99")
self.assertIsNotNone(error)
user = self.connection.execute(
"SELECT must_change_password FROM users WHERE id = ?", (user_id,)
).fetchone()
self.assertEqual(1, user["must_change_password"])
def test_policy_violation_rejected(self) -> None:
user_id = self.create_company_user()
error = auth.change_password(self.connection, user_id, "Init1234", "short")
self.assertIsNotNone(error)
if __name__ == "__main__":
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
"""Static + Node checks for the B-44 visual rework.
Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
event table, and the exported keyboard/amount helpers.
"""
from __future__ import annotations
from pathlib import Path
import re
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
class B44FrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
cls.company = (WEB / "company.html").read_text(encoding="utf-8")
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
def test_admin_ids_are_unique(self) -> None:
self.assertEqual(1, self.admin.count('id="companyLedgers"'))
self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
self.assertIn('data-view="pair"', self.admin)
self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?<span>往来查询</span>')
self.assertIn("<h1>往来查询</h1>", self.admin)
self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
self.assertIn('id="auditExceptionNote"', self.admin)
def test_company_balance_groups_and_nav(self) -> None:
self.assertIn('id="companyBalanceGroups"', self.company)
self.assertNotIn('id="companyBalanceLine"', self.company)
self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?<span>往来余额</span>')
self.assertIn("<h1>往来余额</h1>", self.company)
def test_css_directory_and_drawer_breakpoints(self) -> None:
self.assertIn("@media (max-width: 375px)", self.css)
self.assertIn("@media (max-width: 1179px)", self.css)
self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css)
self.assertIn(
"minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px",
self.css,
)
self.assertIn("@media (max-width: 767px)", self.css)
self.assertIn(".drawer {", self.css)
self.assertIn("width: 640px", self.css)
self.assertIn(".drawer { width: 480px; }", self.css)
self.assertIn(".drawer { width: 100%; }", self.css)
self.assertIn(".drawer .pair-balance-line.is-six", self.css)
self.assertIn("repeat(3, 1fr)", self.css)
self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
self.assertIn(".company-row.is-balance-counterparty", self.css)
self.assertIn(".ledger-hide-md", self.css)
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
self.assertIn("grid-row: 1 / span 2", self.css)
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css)
def test_js_selectors_and_event_table(self) -> None:
self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
self.assertIn("交易日期", self.js)
self.assertIn("本方账户", self.js)
self.assertIn("对方账户", self.js)
self.assertIn("摘要", self.js)
self.assertIn("匹配状态", self.js)
self.assertIn("function eventTableHead()", self.js)
self.assertIn("colspan=\"8\"", self.js)
self.assertIn("function cycleTab(", self.js)
self.assertIn("function drawerEscAction(", self.js)
self.assertIn("function eventIsNegative(", self.js)
self.assertIn("fmtAbsMoney", self.js)
self.assertIn("is-balance-counterparty", self.js)
self.assertIn("function isCompactAmount(", self.js)
self.assertIn("function auditEvidenceFields(", self.js)
self.assertIn("无关联流水", self.js)
self.assertIn('row.dataset.direction = "outgoing"', self.js)
self.assertIn("row.dataset.counterpartyName", self.js)
self.assertIn("row.dataset.relatedSourceRowId", self.js)
heads = re.search(
r"function eventTableHead\(\) \{\s*return `([^`]+)`",
self.js,
)
self.assertIsNotNone(heads)
markup = heads.group(1)
self.assertNotIn("来源", markup)
for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
self.assertIn(label, markup)
self.assertEqual(8, len(re.findall(r"<th\b", markup)))
def test_node_keyboard_and_amount_helpers(self) -> None:
result = subprocess.run(
["node", str(ROOT / "tests" / "b44_ui_check.js")],
capture_output=True,
text=True,
cwd=str(ROOT),
)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertIn("b44_ui_check ok", result.stdout)
if __name__ == "__main__":
unittest.main()
+530
View File
@@ -0,0 +1,530 @@
"""Chromium layout regression for the B-44 admin balance directory.
Measures 15-digit period-end amounts against the unresolved chip using
getBoundingClientRect — not static text presence.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import socket
import subprocess
import tempfile
import threading
import time
from urllib.parse import urlparse
from urllib.request import urlopen
import unittest
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
FIXTURE = "/tests/fixtures/b44-balance-directory.html"
CHROME_CANDIDATES = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "Google/Chrome/Application/chrome.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "Microsoft/Edge/Application/msedge.exe",
]
VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440)
def chrome_bin() -> Path:
for path in CHROME_CANDIDATES:
if path.exists():
return path
raise FileNotFoundError("Chrome/Edge not found for layout regression")
class QuietHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, directory=str(ROOT), **kwargs)
def log_message(self, *_args) -> None:
pass
def handle(self) -> None:
try:
super().handle()
except (ConnectionResetError, BrokenPipeError, TimeoutError):
pass
class _Cdp:
def __init__(self, ws_url: str) -> None:
parsed = urlparse(ws_url)
self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10)
self._sock.settimeout(10)
key = base64.b64encode(os.urandom(16)).decode()
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
self._sock.sendall(
(
f"GET {path} HTTP/1.1\r\n"
f"Host: {parsed.netloc}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
).encode()
)
header = b""
while b"\r\n\r\n" not in header:
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("CDP websocket handshake failed")
header += chunk
expected = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
).decode()
if expected not in header.decode("latin1"):
raise ConnectionError("CDP websocket accept mismatch")
leftover = header.split(b"\r\n\r\n", 1)[1]
self._buf = leftover
self._next_id = 1
def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
msg_id = self._next_id
self._next_id += 1
self._send({"id": msg_id, "method": method, "params": params or {}})
deadline = time.time() + timeout
while time.time() < deadline:
payload = self._recv()
if payload.get("id") == msg_id:
if "error" in payload:
raise RuntimeError(f"{method}: {payload['error']}")
return payload.get("result") or {}
raise TimeoutError(method)
def wait_event(self, name: str, timeout: float = 15) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
payload = self._recv()
if payload.get("method") == name:
return payload.get("params") or {}
raise TimeoutError(name)
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
def _send(self, obj: dict) -> None:
data = json.dumps(obj, separators=(",", ":")).encode()
mask = os.urandom(4)
header = bytearray([0x81])
length = len(data)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(length.to_bytes(2, "big"))
else:
header.append(0x80 | 127)
header.extend(length.to_bytes(8, "big"))
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
self._sock.sendall(header + masked)
def _recv(self) -> dict:
while True:
opcode, payload = self._read_frame()
if opcode == 0x9:
self._send_pong(payload)
continue
if opcode == 0xA:
continue
if opcode == 0x8:
raise ConnectionError("CDP websocket closed")
return json.loads(payload.decode())
def _send_pong(self, payload: bytes) -> None:
mask = os.urandom(4)
header = bytearray([0x8A, 0x80 | len(payload)])
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self._sock.sendall(header + masked)
def _read_frame(self) -> tuple[int, bytes]:
header = self._read_exact(2)
opcode = header[0] & 0x0F
length = header[1] & 0x7F
masked = bool(header[1] & 0x80)
if length == 126:
length = int.from_bytes(self._read_exact(2), "big")
elif length == 127:
length = int.from_bytes(self._read_exact(8), "big")
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
def _read_exact(self, size: int) -> bytes:
while len(self._buf) < size:
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("CDP websocket closed")
self._buf += chunk
data, self._buf = self._buf[:size], self._buf[size:]
return data
class B44LayoutRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.chrome = chrome_bin()
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.http_thread.start()
cls.port = cls.httpd.server_address[1]
cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}"
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True)
user_dir = cls.tmp.name
cls.proc = subprocess.Popen(
[
str(cls.chrome),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--remote-debugging-port=0",
f"--user-data-dir={user_dir}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(user_dir) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(
f"http://127.0.0.1:{cls.debug_port}/json/list",
timeout=5,
).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "httpd", None):
cls.httpd.shutdown()
cls.httpd.server_close()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _metrics(self, width: int) -> dict:
self.cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width,
"height": 1100,
"deviceScaleFactor": 1,
"mobile": False,
})
self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"})
deadline = time.time() + 10
last = None
while time.time() < deadline:
result = self.cdp.call(
"Runtime.evaluate",
{
"expression": (
"typeof window.measureB175 === 'function' "
"? (window.measureB175(), document.getElementById('b175-metrics').textContent) "
": null"
),
"returnByValue": True,
},
)
last = result.get("result", {}).get("value")
if last:
return json.loads(last)
time.sleep(0.1)
raise TimeoutError(f"layout metrics not ready: {last}")
def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None:
for width in VIEWPORTS:
with self.subTest(width=width):
metrics = self._metrics(width)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertIn("23,456,789,012,345.00", metrics["amountText"])
self.assertIn("未决", metrics["unresolvedText"])
self.assertIn("2026.07.31", metrics["cutoffText"])
self.assertTrue(metrics["amountVisible"])
self.assertTrue(metrics["unresolvedVisible"])
self.assertTrue(metrics["cutoffVisible"])
self.assertFalse(metrics["amountClipped"], metrics)
self.assertFalse(metrics["unresolvedClipped"], metrics)
self.assertFalse(metrics["cutoffClipped"], metrics)
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics)
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics)
self.assertFalse(metrics["drawerAmountClipped"], metrics)
if 376 <= width <= 1179:
self.assertTrue(metrics["fiveColVisible"], metrics)
if 376 <= width <= 900:
self.assertTrue(metrics["stackedResult"], metrics)
if width <= 375:
self.assertFalse(metrics["fiveColVisible"], metrics)
LIVE_MEASURE = r"""
(() => {
const pair = document.querySelector('[data-view="pair"]');
if (pair) pair.click();
const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")];
if (!rows.length) return null;
const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0];
const amount = row.querySelector(".ledger-result .amount-with-currency");
const direction = row.querySelector(".ledger-result .status");
const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved");
const cutoff = row.querySelector(".ledger-cutoff");
const chevron = row.querySelector("summary > svg");
const box = (el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
const overlap = (a, b) => {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx);
};
const clipped = (el) => el.scrollWidth > el.clientWidth + 1;
const amountBox = box(amount);
const unresolvedBox = box(unresolved);
return {
viewport: window.innerWidth,
amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(),
unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(),
cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(),
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
directionUnresolvedOverlap: overlap(box(direction), unresolvedBox),
amountCutoffOverlap: overlap(amountBox, box(cutoff)),
unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)),
cutoffChevronOverlap: overlap(box(cutoff), box(chevron)),
amountClipped: clipped(amount),
unresolvedClipped: clipped(unresolved),
cutoffClipped: clipped(cutoff),
fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none",
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
rowCount: rows.length,
};
})()
"""
COMPANY_MEASURE = r"""
(() => {
const balances = document.querySelector('[data-view="balances"]');
if (balances) balances.click();
const row = document.querySelector(".company-row.is-balance-counterparty");
if (!row) return null;
const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong");
const direction = row.querySelector(".status");
const cutoff = row.querySelector(".company-row-figure small");
const box = (el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
};
const overlap = (a, b) => {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx);
};
return {
viewport: window.innerWidth,
amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(),
directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(),
cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(),
amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0,
visible: Boolean(amount && amount.getBoundingClientRect().width > 4),
};
})()
"""
class LiveAdminDirectoryLayoutTests(unittest.TestCase):
"""Chromium against a real login/API page with B-168-scale 15-digit amounts."""
@classmethod
def setUpClass(cls) -> None:
from test_positions_api import IntercompanyApiTests
cls.api = IntercompanyApiTests("test_admin_balances_directory")
cls.api.setUp()
confirmed = cls.api._fresh_pair("123456789012345.00")
cls.api._confirm(confirmed["ledger_event_id"])
cls.api._fresh_pair("2150.00")
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True)
cls.proc = subprocess.Popen(
[
str(chrome_bin()),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--remote-debugging-port=0",
f"--user-data-dir={cls.tmp.name}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(
f"http://127.0.0.1:{cls.debug_port}/json/list",
timeout=5,
).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
cls.cdp.call("Network.enable")
token = cls.api.admin.cookies["cw_session"]
cls.cdp.call(
"Network.setCookie",
{
"name": "cw_session",
"value": token,
"url": f"http://127.0.0.1:{cls.api.port}/",
},
)
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "api", None):
cls.api.tearDown()
cls.api.doCleanups()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _eval(self, expression: str):
deadline = time.time() + 12
last = None
while time.time() < deadline:
result = self.cdp.call(
"Runtime.evaluate",
{"expression": expression, "returnByValue": True},
)
last = result.get("result", {}).get("value")
if last:
return last
time.sleep(0.15)
raise TimeoutError(f"live page metrics not ready: {last}")
def _open(self, path: str, width: int) -> None:
self.cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width,
"height": 1100,
"deviceScaleFactor": 1,
"mobile": False,
})
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"})
time.sleep(0.4)
def test_live_admin_directory_viewports(self) -> None:
for width in (720, 768, 800, 900, 375, 1024, 1440):
with self.subTest(width=width):
self._open("admin.html", width)
metrics = self._eval(LIVE_MEASURE)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertGreaterEqual(metrics["rowCount"], 1, metrics)
self.assertIn("123,456,789,012,345", metrics["amountText"], metrics)
self.assertIn("未决", metrics["unresolvedText"], metrics)
self.assertIn("2026.07.31", metrics["cutoffText"], metrics)
self.assertFalse(metrics["amountClipped"], metrics)
self.assertFalse(metrics["unresolvedClipped"], metrics)
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
if 376 <= width <= 900:
self.assertTrue(metrics["stackedResult"], metrics)
if width <= 375:
self.assertFalse(metrics["fiveColVisible"], metrics)
def test_live_company_balance_rows(self) -> None:
token = self.api.cashier_a.cookies["cw_session"]
self.cdp.call(
"Network.setCookie",
{
"name": "cw_session",
"value": token,
"url": f"http://127.0.0.1:{self.api.port}/",
},
)
for width in (375, 1440):
with self.subTest(width=width):
self._open("company.html", width)
metrics = self._eval(COMPANY_MEASURE)
self.assertTrue(metrics["visible"], metrics)
self.assertTrue(metrics["amountText"], metrics)
self.assertTrue(metrics["directionText"], metrics)
self.assertIn("截止", metrics["cutoffText"], metrics)
self.assertEqual(0, metrics["amountDirectionOverlap"], metrics)
if __name__ == "__main__":
unittest.main()
+735
View File
@@ -0,0 +1,735 @@
"""HTTP integration tests for the hardened import API (B-42).
Covers streaming multipart limits, per-worksheet parse results with the four
diagnostic evidence items, the confirm/ignore review lifecycle with tenant
scoping and idempotency, unconfirmed-sheet export gating, and concurrent
duplicate uploads. Uses a real ``ThreadingHTTPServer`` like
``test_server_auth``.
"""
from __future__ import annotations
import io
import itertools
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from unittest import mock
from openpyxl import Workbook
from bank_importer import auth
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
ROOT = Path(__file__).resolve().parents[1]
SAMPLES = ROOT / "流水模板"
CCB_SAMPLE = SAMPLES / "中国建设银行账户流水.xls"
CITIC_SAMPLE = SAMPLES / "中信银行账户流水.xlsx"
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
CCB_DATA = [
["6228480000000000", "测试公司", "2026-01-05 10:00:00", "100.00", "", "99900.00", "RMB", "供应商", "1002003004", "某银行", "货款", ""],
["6228480000000000", "测试公司", "2026-01-06 11:00:00", "", "200.00", "100100.00", "RMB", "客户", "2003004005", "某银行", "收款", ""],
]
def ccb_row(own, cp, *, expense="", income="", at="2026-01-05 10:00:00"):
return [own, "测试公司", at, expense or "", income or "", "99900.00", "RMB", "对方", cp, "某银行", "货款", ""]
def ccb_bytes(rows):
return workbook_bytes([("正常流水", [CCB_HEADER, *rows])])
def workbook_bytes(sheets):
"""Build an xlsx in memory.
``sheets`` is a list of ``(name, rows)``; an empty ``rows`` list means an
empty worksheet.
"""
workbook = Workbook()
workbook.remove(workbook.active)
for name, rows in sheets:
worksheet = workbook.create_sheet(name)
for row in rows:
worksheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
# Workbook fixtures are content-hashed for dedupe, so every helper produces
# distinct bytes per call (each test uploads a genuinely new file).
_counter = itertools.count()
def multi_sheet_bytes():
"""Valid CCB sheet + unknown-header sheet + empty sheet in one workbook."""
return workbook_bytes(
[
("正常流水", [CCB_HEADER, *CCB_DATA]),
("未知模板", [["日期", "金额", "备注"], ["2026-01-01", next(_counter), "x"]]),
("空表", []),
]
)
def empty_workbook_bytes():
return workbook_bytes([("Sheet1", [])])
def single_cell_bytes():
return workbook_bytes([("候选", [[f"只有一个单元格 {next(_counter)}"]])])
def unknown_header_bytes():
return workbook_bytes([("流水", [["日期", "金额", "备注"], ["2026-01-01", next(_counter), "x"]])])
# ---------------------------------------------------------------------------
# Test server
# ---------------------------------------------------------------------------
class ImportApiServerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
generated = server.ensure_bootstrap_admin(connection)
assert generated is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "甲公司"})
assert status == 200, data
cls.company_a = as_json(data)["company_id"]
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
assert status == 200, data
cls.company_b = as_json(data)["company_id"]
cls.cashier_a, cls.cashier_a_password = cls.create_company_user(
cls.admin, cls.port, "cashier-a"
)
cls.cashier_b, cls.cashier_b_password = cls.create_company_user(
cls.admin, cls.port, "cashier-b"
)
@classmethod
def create_company_user(cls, admin, port, username):
status, _, data = admin.post_json(
"/api/admin/users", {"username": username, "company_id": cls.company_a}
)
assert status == 200, data
initial = as_json(data)["initial_password"]
client = Client("127.0.0.1", port)
status, _, data = client.post_json(
"/api/login",
{"username": username, "password": initial, "portal": "company"},
)
assert status == 200, data
new_password = "Changed456"
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": new_password},
)
assert status == 200, data
return client, new_password
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def fresh_client(self) -> Client:
return Client("127.0.0.1", self.port)
def upload(self, client, content: bytes, filename: str = "语句.xlsx", fields=None):
return client.post_multipart(
"/api/parse", fields or {}, filename, content
)
def upload_for_company(self, content: bytes, company_id: int, filename: str = "语句.xlsx"):
return self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, filename, content
)
def upload_for_a(self, content: bytes, filename: str = "语句.xlsx"):
return self.cashier_a.post_multipart("/api/parse", {}, filename, content)
def batch_sheets(self, client, batch_id: int):
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
self.assertEqual(200, status, data)
return as_json(data)["sheets"]
def confirm(self, client, batch_id: int, sheets, reason=None):
payload = {"sheets": sheets}
if reason is not None:
payload["reason"] = reason
return client.post_json(f"/api/batches/{batch_id}/confirm", payload)
def ignore(self, client, batch_id: int, sheets, reason):
return client.post_json(
f"/api/batches/{batch_id}/ignore", {"sheets": sheets, "reason": reason}
)
# ------------------------------------------------------------------
# Multipart validation
# ------------------------------------------------------------------
def test_oversized_body_returns_stable_error(self) -> None:
content = b"x" * (server.MAX_UPLOAD_BYTES + 1)
status, _, data = self.upload_for_a(content)
self.assertEqual(422, status)
payload = as_json(data)
self.assertIn("20 MB", payload["message"])
self.assertNotIn(server.STORAGE_DIR.as_posix(), payload["message"])
self.assertNotIn(str(self.storage), payload["message"])
def test_missing_boundary_returns_stable_error(self) -> None:
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=b"whatever",
headers={"Content-Type": "multipart/form-data"},
)
self.assertEqual(422, status)
self.assertIn("边界", as_json(data)["message"])
def test_wrong_content_type_returns_stable_error(self) -> None:
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=CCB_SAMPLE.read_bytes(),
headers={"Content-Type": "application/octet-stream"},
)
self.assertEqual(422, status)
def test_missing_content_length_returns_stable_error(self) -> None:
boundary = "----missingcl"
body = f"--{boundary}--\r\n".encode()
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
self.assertEqual(422, status)
def test_bad_extension_returns_stable_error(self) -> None:
status, _, data = self.upload_for_a(CCB_SAMPLE.read_bytes(), filename="statement.pdf")
self.assertEqual(422, status)
self.assertIn(".xls", as_json(data)["message"])
def test_signature_mismatch_returns_stable_error(self) -> None:
# Valid .xls content renamed with a .xlsx extension must be rejected.
status, _, data = self.upload_for_a(CCB_SAMPLE.read_bytes(), filename="fake.xlsx")
self.assertEqual(422, status)
self.assertIn("损坏", as_json(data)["message"])
def test_corrupt_file_returns_stable_error_without_temp_path(self) -> None:
status, _, data = self.upload_for_a(b"PK\x03\x04 not a real zip at all")
self.assertEqual(422, status)
payload = as_json(data)
text = json.dumps(payload, ensure_ascii=False)
self.assertNotIn(str(self.storage), text)
self.assertNotIn("temp", text.lower())
self.assertNotIn("data/files", text)
def test_two_file_parts_rejected(self) -> None:
boundary = "----twofilesboundary"
content = CCB_SAMPLE.read_bytes()
crlf = b"\r\n"
body = (
b"--" + boundary.encode() + crlf
+ b'Content-Disposition: form-data; name="file"; filename="a.xls"' + crlf
+ b"Content-Type: application/octet-stream" + crlf + crlf
+ content + crlf
+ b"--" + boundary.encode() + crlf
+ b'Content-Disposition: form-data; name="file"; filename="b.xls"' + crlf
+ b"Content-Type: application/octet-stream" + crlf + crlf
+ content + crlf
+ b"--" + boundary.encode() + b"--" + crlf
)
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
self.assertEqual(422, status)
def test_no_file_part_rejected(self) -> None:
boundary = "----nofile"
crlf = b"\r\n"
body = (
b"--" + boundary.encode() + crlf
+ b'Content-Disposition: form-data; name="company_id"' + crlf + crlf
+ b"1" + crlf
+ b"--" + boundary.encode() + b"--" + crlf
)
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
self.assertEqual(422, status)
self.assertIn("文件", as_json(data)["message"])
def test_upload_trailing_bytes_preserved_exactly(self) -> None:
# The stored source file must be byte-identical to what was uploaded
# even when it ends with CRLF/LF; the parser must never trim tail
# bytes, and the upload temp must not appear anywhere in the payload.
content = CCB_SAMPLE.read_bytes()
uploaded = content + b"\r\n\r\n\x00\x01"
status, _, data = self.upload_for_a(uploaded, filename="尾部字节.xls")
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
connection = connect(self.db_path)
try:
row = connection.execute(
"""
SELECT f.storage_path FROM source_files f
JOIN import_batches b ON b.source_file_id = f.id
WHERE b.id = ?
""",
(batch_id,),
).fetchone()
finally:
connection.close()
self.assertIsNotNone(row)
self.assertEqual(uploaded, Path(row["storage_path"]).read_bytes())
self.assertNotIn("upload-", json.dumps(as_json(data), ensure_ascii=False))
# ------------------------------------------------------------------
# Per-sheet results and four-item evidence
# ------------------------------------------------------------------
def test_multi_sheet_workbook_returns_every_sheet_result(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="多工作表.xlsx")
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("parsed", payload["status"])
self.assertEqual("多工作表.xlsx", payload.get("original_filename"))
sheets = payload["sheets"]
self.assertEqual(3, len(sheets))
by_name = {sheet["sheet_name"]: sheet for sheet in sheets}
self.assertEqual("parsed", by_name["正常流水"]["outcome"])
self.assertEqual("pending", by_name["正常流水"]["review_status"])
self.assertEqual("中国建设银行", by_name["正常流水"]["bank"])
self.assertEqual(2, by_name["正常流水"]["transactions"])
unknown = by_name["未知模板"]
self.assertEqual("exception", unknown["outcome"])
self.assertIn("未识别到受支持的银行表头", unknown["message"])
self.assertGreaterEqual(unknown["scanned_rows"], 2)
self.assertTrue(unknown["candidate_headers"])
self.assertIn("日期、金额、备注", "".join(unknown["candidate_headers"]))
empty = by_name["空表"]
self.assertEqual("ignored", empty["outcome"])
self.assertIn("为空", empty["message"])
self.assertEqual(0, empty["scanned_rows"])
def test_batch_detail_matches_parse_response(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="详情.xlsx")
batch_id = as_json(data)["batch_id"]
sheets = self.batch_sheets(self.cashier_a, batch_id)
self.assertEqual(3, len(sheets))
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
self.assertEqual(200, status)
detail = as_json(data)
self.assertEqual("详情.xlsx", detail["original_filename"])
def test_unknown_header_file_returns_four_evidence_items(self) -> None:
status, _, data = self.upload_for_a(unknown_header_bytes(), filename="未知银行.xlsx")
self.assertEqual(422, status, data)
payload = as_json(data)
self.assertEqual("exception", payload["status"])
self.assertEqual("未知银行.xlsx", payload.get("original_filename"))
sheet = payload["sheets"][0]
self.assertEqual("exception", sheet["outcome"])
self.assertEqual("流水", sheet["sheet_name"])
self.assertGreaterEqual(sheet["scanned_rows"], 1)
self.assertTrue(sheet["candidate_headers"])
text = json.dumps(payload, ensure_ascii=False)
for evidence in (str(self.storage), "data/files", ".uploads", "upload-"):
self.assertNotIn(evidence, text)
def test_empty_workbook_returns_evidence(self) -> None:
status, _, data = self.upload_for_a(empty_workbook_bytes(), filename="空工作簿.xlsx")
self.assertEqual(422, status, data)
payload = as_json(data)
self.assertEqual("空工作簿.xlsx", payload.get("original_filename"))
sheet = payload["sheets"][0]
self.assertEqual("ignored", sheet["outcome"])
self.assertEqual("Sheet1", sheet["sheet_name"])
self.assertEqual(0, sheet["scanned_rows"])
def test_single_cell_sheet_returns_candidate_evidence(self) -> None:
status, _, data = self.upload_for_a(single_cell_bytes(), filename="单格.xlsx")
self.assertEqual(422, status, data)
sheet = as_json(data)["sheets"][0]
self.assertEqual("exception", sheet["outcome"])
self.assertEqual("候选", sheet["sheet_name"])
self.assertEqual(1, sheet["scanned_rows"])
self.assertTrue(sheet["candidate_headers"])
self.assertIn("只有一个单元格", "".join(sheet["candidate_headers"]))
# ------------------------------------------------------------------
# Confirm / ignore lifecycle
# ------------------------------------------------------------------
def test_confirm_and_ignore_lifecycle(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="确认.xlsx")
payload = as_json(data)
batch_id = payload["batch_id"]
names = [s["sheet_name"] for s in payload["sheets"]]
self.assertIn("正常流水", names)
# Confirm the parsed sheet; exception/ignored cannot be confirmed.
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水"])
self.assertEqual(200, status, data)
result = as_json(data)
self.assertEqual(["正常流水"], result["updated"])
sheet = next(s for s in result["sheets"] if s["sheet_name"] == "正常流水")
self.assertEqual("confirmed", sheet["review_status"])
# Confirming an exception sheet is a conflict.
status, _, data = self.confirm(self.cashier_a, batch_id, ["未知模板"])
self.assertEqual(409, status, data)
# Ignore the exception sheet with a reason.
status, _, data = self.ignore(self.cashier_a, batch_id, ["未知模板"], "模板待补充")
self.assertEqual(200, status, data)
sheet = next(s for s in as_json(data)["sheets"] if s["sheet_name"] == "未知模板")
self.assertEqual("ignored", sheet["review_status"])
self.assertEqual("模板待补充", sheet["review_reason"])
# Idempotent repeat of an already-applied decision.
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水"])
self.assertEqual(200, status, data)
result = as_json(data)
self.assertEqual([], result["updated"])
self.assertEqual(["正常流水"], result["already"])
# Changing a settled decision is a conflict.
status, _, data = self.ignore(self.cashier_a, batch_id, ["正常流水"], "改主意")
self.assertEqual(409, status, data)
def test_ignore_requires_reason(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="忽略原因.xlsx")
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/ignore", {"sheets": ["未知模板"], "reason": " "}
)
self.assertEqual(400, status, data)
self.assertIn("原因", as_json(data)["message"])
def test_unknown_sheet_name_is_rejected_and_atomic(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="原子.xlsx")
batch_id = as_json(data)["batch_id"]
# One valid + one unknown sheet in the same request must not leave a
# half-applied confirmation behind.
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水", "不存在"])
self.assertEqual(400, status, data)
sheet = next(
s for s in self.batch_sheets(self.cashier_a, batch_id)
if s["sheet_name"] == "正常流水"
)
self.assertEqual("pending", sheet["review_status"])
def test_company_cannot_confirm_another_companys_batch(self) -> None:
status, _, data = self.upload_for_company(multi_sheet_bytes(), self.company_b)
batch_b = as_json(data)["batch_id"]
status, _, data = self.confirm(self.cashier_a, batch_b, ["正常流水"])
self.assertEqual(404, status, data)
status, _, data = self.cashier_a.get(f"/api/batches/{batch_b}/sheets")
self.assertEqual(404, status, data)
def test_export_excludes_unconfirmed_and_ignore_keeps_excluded(self) -> None:
def export_line_count() -> int:
status, _, data = self.cashier_a.get("/api/export.csv")
self.assertEqual(200, status)
return len([line for line in data.decode("utf-8-sig").splitlines() if line])
baseline = export_line_count()
# Unconfirmed upload: export stays unchanged (no downstream leak).
status, _, data = self.upload_for_a(CCB_SAMPLE.read_bytes(), filename="导出.xls")
batch_id = as_json(data)["batch_id"]
self.assertEqual("parsed", as_json(data)["status"])
self.assertEqual(baseline, export_line_count())
# Confirm → the sheet's rows enter the export.
sheets = self.batch_sheets(self.cashier_a, batch_id)
name = sheets[0]["sheet_name"]
status, _, data = self.confirm(self.cashier_a, batch_id, [name])
self.assertEqual(200, status, data)
self.assertGreater(export_line_count(), baseline)
# A confirmed sheet cannot be silently revoked: the settled decision
# is a conflict, not a silent data removal.
status, _, data = self.ignore(self.cashier_a, batch_id, [name], "取消")
self.assertEqual(409, status, data)
self.assertGreater(export_line_count(), baseline)
# A sheet that was only ever ignored never enters the export.
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="忽略.xlsx")
batch2 = as_json(data)["batch_id"]
parsed = [
sheet for sheet in self.batch_sheets(self.cashier_a, batch2)
if sheet["outcome"] == "parsed"
]
self.assertTrue(parsed)
before_ignore = export_line_count()
status, _, data = self.ignore(
self.cashier_a, batch2, [parsed[0]["sheet_name"]], "该表复核后不采用"
)
self.assertEqual(200, status, data)
self.assertEqual(before_ignore, export_line_count())
# ------------------------------------------------------------------
# Concurrency
# ------------------------------------------------------------------
def test_concurrent_duplicate_upload_keeps_one_fact_set(self) -> None:
content = CITIC_SAMPLE.read_bytes()
results: list[tuple[int, dict]] = []
errors: list[Exception] = []
barrier = threading.Barrier(2)
def upload() -> None:
try:
client = self.fresh_client()
client.cookies.update(self.cashier_a.cookies)
barrier.wait(timeout=10)
status, _, data = client.post_multipart(
"/api/parse", {}, "并发.xlsx", content
)
results.append((status, as_json(data)))
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [threading.Thread(target=upload) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
self.assertEqual([], errors)
self.assertEqual(2, len(results))
statuses = sorted(payload["status"] for _, payload in results)
self.assertEqual(["duplicate", "parsed"], statuses)
connection = connect(self.db_path)
try:
# The concurrent upload adds exactly one source file: identical
# bytes never create a second fact set.
count = connection.execute(
"SELECT COUNT(*) AS n FROM source_files WHERE original_filename = '并发.xlsx'"
).fetchone()["n"]
self.assertEqual(1, count)
duplicates = connection.execute(
"SELECT COUNT(*) AS n FROM import_batches WHERE status = 'duplicate'"
).fetchone()["n"]
self.assertGreaterEqual(duplicates, 1)
finally:
connection.close()
# ------------------------------------------------------------------
# Worksheet confirmation drives canonical matching in the same transaction
# ------------------------------------------------------------------
def _approve_test_account(self, company_client, number: str) -> int:
status, _, data = company_client.post_json(
"/api/company/accounts",
{"bank_name": "中信银行", "account_type": "基本户",
"account_number": number, "start_date": "2026-01-01"},
)
self.assertEqual(200, status, data)
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
)
self.assertEqual(200, status, data)
return account_id
def _company_cashier(self, username: str, company_id: int) -> Client:
"""A company cashier bound to the given company (the class fixture binds
both cashiers to company A, so tests needing a real tenant B create one)."""
status, _, data = self.admin.post_json(
"/api/admin/users", {"username": username, "company_id": company_id}
)
self.assertEqual(200, status, data)
initial = as_json(data)["initial_password"]
client = self.fresh_client()
status, _, data = client.post_json(
"/api/login",
{"username": username, "password": initial, "portal": "company"},
)
self.assertEqual(200, status, data)
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": "Changed456"},
)
self.assertEqual(200, status, data)
return client
def test_confirm_drives_bilateral_matching_atomically(self) -> None:
account_a = self._approve_test_account(self.cashier_a, "6222000000000001")
cashier_b = self._company_cashier("cashier-b2", self.company_b)
account_b = self._approve_test_account(cashier_b, "6222000000000002")
# A's side first: confirming the worksheet reconciles its row into an
# internal single (not yet eligible for the intercompany balance).
status, _, data = self.upload_for_a(
ccb_bytes([ccb_row("6222000000000001", "6222000000000002", expense="100.00")]),
filename="A方.xlsx",
)
self.assertEqual(200, status, data)
batch_a = as_json(data)["batch_id"]
status, _, data = self.confirm(self.cashier_a, batch_a, ["正常流水"])
self.assertEqual(200, status, data)
self.assertEqual(1, as_json(data)["matching"]["created_events"])
connection = connect(self.db_path)
try:
row_a = connection.execute(
"""
SELECT r.id FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
WHERE s.import_batch_id = ?
""",
(batch_a,),
).fetchone()
status_a = connection.execute(
"""
SELECT d.classification FROM transfer_observation_claims c
JOIN transfer_match_decisions d ON d.id = c.decision_id
WHERE c.source_row_id = ?
""",
(row_a["id"],),
).fetchone()
self.assertEqual("internal_single", status_a["classification"])
finally:
connection.close()
# B's side arrives later: the second confirmation merges both into one
# paired intercompany event without duplicating the first.
status, _, data = cashier_b.post_multipart(
"/api/parse", {}, "B方.xlsx",
ccb_bytes([ccb_row("6222000000000002", "6222000000000001", income="100.00")]),
)
self.assertEqual(200, status, data)
batch_b = as_json(data)["batch_id"]
status, _, data = self.confirm(cashier_b, batch_b, ["正常流水"])
self.assertEqual(200, status, data)
connection = connect(self.db_path)
try:
eligible = connection.execute(
"SELECT * FROM eligible_intercompany_events"
).fetchall()
self.assertEqual(1, len(eligible))
self.assertEqual("100.00", eligible[0]["amount"])
self.assertEqual("paired", eligible[0]["pairing"])
# Exactly one current intercompany decision for the pair.
self.assertEqual(1, connection.execute(
"""
SELECT COUNT(*) AS n FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
WHERE d.classification = 'intercompany'
"""
).fetchone()["n"])
finally:
connection.close()
def test_confirm_rolls_back_when_matching_fails(self) -> None:
account_a = self._approve_test_account(self.cashier_a, "6222000000000003")
status, _, data = self.upload_for_a(
ccb_bytes([ccb_row("6222000000000003", "6222000000000004", expense="50.00")]),
filename="回滚.xlsx",
)
batch_id = as_json(data)["batch_id"]
with mock.patch(
"bank_importer.importing.matching.reconcile_rows",
side_effect=RuntimeError("simulated matching failure"),
):
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水"])
self.assertEqual(500, status, data)
# The worksheet confirmation was rolled back with the failed matching.
sheet = self.batch_sheets(self.cashier_a, batch_id)[0]
self.assertEqual("pending", sheet["review_status"])
connection = connect(self.db_path)
try:
# No decision or claim was left behind for this batch's rows.
claims = connection.execute(
"""
SELECT COUNT(*) AS n FROM transfer_observation_claims c
JOIN source_rows r ON r.id = c.source_row_id
JOIN sheet_batches s ON s.id = r.sheet_batch_id
WHERE s.import_batch_id = ?
""",
(batch_id,),
).fetchone()["n"]
self.assertEqual(0, claims)
finally:
connection.close()
if __name__ == "__main__":
unittest.main()
+306
View File
@@ -0,0 +1,306 @@
"""B-44 ledger event layer tests: bank reconciliation, revision chain,
reversal/adjustment, projection rebuild and immutability boundaries."""
from __future__ import annotations
from decimal import Decimal
import sqlite3
import unittest
from bank_importer import ledger_events, matching, subjects
from ledger_helpers import LedgerBase
class BankReconcileTests(LedgerBase):
def test_paired_eligible_event_creates_pending_subject_only(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
self.assertEqual(1, len(self.eligible()))
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, stats["created"])
events = self.ledger_events()
self.assertEqual(1, len(events))
revision = self.current(events[0]["id"])
self.assertEqual("pending_subject", revision["state"])
self.assertEqual(Decimal("100.00"), Decimal(revision["amount"]))
self.assertEqual("CNY", revision["currency"])
# Nothing confirmed yet -> no position event.
self.assertEqual([], self.position_events())
# The event is unresolved as subject_review.
from bank_importer import positions
unresolved = positions.unresolved_for_company(self.connection, self.company_a, "2026-12-31")
self.assertEqual("100.00", unresolved["by_reason"]["subject_review"]["gross_amount"])
def test_unlocked_single_never_enters_ledger(self) -> None:
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [1])
self.assertEqual([], self.eligible())
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(0, stats["created"])
self.assertEqual([], self.ledger_events())
def test_same_company_transfer_never_enters_ledger(self) -> None:
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000001", expense="100.00",
)
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [1, 2])
eligible = self.eligible()
self.assertEqual(0, len([e for e in eligible if e["payer_company_id"] == e["payee_company_id"]]))
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(0, stats["created"])
def test_reconcile_is_idempotent(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
events = self.ledger_events()
self.assertEqual(1, len(events))
before_revisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
).fetchone()["n"]
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, stats["unchanged"])
after_revisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
).fetchone()["n"]
self.assertEqual(before_revisions, after_revisions)
def test_reconcile_picks_up_newly_linked_rows_as_new_pending_event(self) -> None:
# A single observation locked as intercompany -> pending ledger event.
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
single = self.connection.execute(
"SELECT * FROM current_transfer_decisions"
).fetchone()
event_id = single["event_id"]
decision = self.connection.execute(
"SELECT * FROM transfer_match_decisions WHERE id = ?",
(single["decision_id"],),
).fetchone()
matching.apply_manual_decision(
self.connection, event_id, "assign_participant",
reason="函证确认对方",
expected_revision=decision["revision"], request_key="assign-1",
actor=self.admin,
participant={"role": "payee", "company_id": self.company_b},
)
self.assertEqual(1, len(self.eligible()))
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, len(self.ledger_events()))
# The administrator links two fresh rows with a different amount onto a
# new canonical event; reconcile creates a second pending event.
row_a2 = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="150.00",
at="2026-02-01T10:00:00",
)
row_b2 = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="150.00",
at="2026-02-01T11:00:00",
)
current_row = self.connection.execute(
"SELECT d.* FROM current_transfer_decisions c "
"JOIN transfer_match_decisions d ON d.id = c.decision_id "
"WHERE c.event_id = ?",
(event_id,),
).fetchone()
matching.apply_manual_decision(
self.connection, event_id, "link_rows",
reason="补录双边流水",
expected_revision=current_row["revision"], request_key="link-1",
actor=self.admin, source_row_ids=[row_a2, row_b2],
)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
amounts = sorted(
row["amount"] for row in self.connection.execute(
"SELECT amount FROM ledger_event_revisions WHERE state = 'pending_subject'"
).fetchall()
)
self.assertEqual(["100.00", "150.00"], amounts)
def test_confirm_subject_enters_position_view(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款建议其他应收", expected_revision=1, request_key="k1",
actor=self.admin,
)
positions = self.position_events()
self.assertEqual(1, len(positions))
self.assertEqual("other_receivable", positions[0]["subject_code"])
self.assertEqual(self.company_a, positions[0]["perspective_company_id"])
def test_confirm_stale_revision_conflicts(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认应收", expected_revision=1, request_key="k1",
actor=self.admin,
)
with self.assertRaises(subjects.SubjectConflictError):
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="重复确认", expected_revision=1, request_key="k2",
actor=self.admin,
)
def test_reversal_mirrors_direction_and_subject(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="k1", actor=self.admin,
)
reversal_id, _ = ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
reason="冲销错误确认", actor=self.admin,
)
reversal = self.current(reversal_id)
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(self.company_b, reversal["payer_company_id"])
self.assertEqual(self.company_a, reversal["payee_company_id"])
self.assertEqual("other_payable", reversal["subject_code"])
self.assertEqual(self.company_b, reversal["perspective_company_id"])
# Original event stays confirmed and still in the position view.
self.assertEqual("confirmed", self.current(event_id)["state"])
positions = self.position_events()
self.assertEqual(2, len(positions))
signed = sum(
Decimal(position["amount"]) * (
1 if position["payer_company_id"] == self.company_a else -1
)
for position in positions
)
self.assertEqual(Decimal("0"), signed)
def test_evidence_rows_stay_immutable_under_reversal(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
)
before = self.connection.execute(
"SELECT income, expense FROM source_rows ORDER BY id"
).fetchall()
ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
reason="冲销", actor=self.admin,
)
after = self.connection.execute(
"SELECT income, expense FROM source_rows ORDER BY id"
).fetchall()
self.assertEqual(before, after)
def test_reversal_idempotent_guard(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
)
# Simulate the B-43 event leaving the eligible set.
with self.connection:
self.connection.execute(
"UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
(self.eligible()[0]["event_id"],),
)
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, stats["reversal"])
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(1, reversals)
# Re-run does not create a second reversal.
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(0, stats["reversal"])
class ProjectionTests(LedgerBase):
def test_rebuild_current_projection(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
)
self.connection.execute("DELETE FROM current_ledger_event_revisions")
rebuilt = ledger_events.rebuild_current_ledger_projection(self.connection)
self.assertGreaterEqual(rebuilt, 1)
revision = self.current(event_id)
self.assertEqual("confirmed", revision["state"])
self.assertEqual("receivable", revision["subject_code"])
class SubjectSuggestionTests(LedgerBase):
def test_mirror_mapping_is_symmetric(self) -> None:
for subject, mirror in (
("receivable", "payable"), ("payable", "receivable"),
("other_receivable", "other_payable"), ("other_payable", "other_receivable"),
):
self.assertEqual(mirror, subjects.mirror_subject(subject))
self.assertEqual(subject, subjects.mirror_subject(mirror))
def test_loan_keyword_suggests_payer_other_receivable(self) -> None:
self.pair(self.company_a, self.company_b, "100.00", summary="借款", purpose="往来款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
suggestions = subjects.compute_suggestions(self.connection, event_id)
self.assertEqual(1, len(suggestions))
self.assertEqual(self.company_a, suggestions[0]["suggested_perspective_company_id"])
self.assertEqual("other_receivable", suggestions[0]["suggested_subject_code"])
self.assertFalse(suggestions[0]["evidence"]["approved"])
def test_repay_keyword_suggests_payee(self) -> None:
self.pair(self.company_a, self.company_b, "100.00", summary="归还往来款", purpose="还款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
suggestions = subjects.compute_suggestions(self.connection, event_id)
self.assertEqual(1, len(suggestions))
self.assertEqual(self.company_b, suggestions[0]["suggested_perspective_company_id"])
def test_trade_keyword_never_suggests(self) -> None:
# Trade vocabulary needs group approval; no dictionary hit -> review.
self.pair(self.company_a, self.company_b, "100.00", summary="货款", purpose="采购货款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
def test_name_alone_never_confirms(self) -> None:
# A name match on the counterparty cannot decide the subject.
self.pair(self.company_a, self.company_b, "100.00", summary="某客户往来", purpose=None)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
current = self.current(event_id)
self.assertEqual("pending_subject", current["state"])
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -0,0 +1,84 @@
"""Static contracts for the B-229 login desktop layout."""
from __future__ import annotations
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
class LoginFrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = (WEB / "index.html").read_text(encoding="utf-8")
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
def test_dual_zone_markup_and_auth_controls(self) -> None:
self.assertIn('class="entry-context"', self.html)
self.assertIn('class="entry-form-wrap"', self.html)
self.assertIn('id="loginForm"', self.html)
self.assertIn('id="togglePassword"', self.html)
self.assertIn('id="changePassword"', self.html)
self.assertIn('id="loginError"', self.html)
self.assertIn('name="username"', self.html)
self.assertIn('name="password"', self.html)
self.assertIn('name="new_password"', self.html)
self.assertIn('name="confirm_password"', self.html)
self.assertIn('value="admin"', self.html)
self.assertIn('value="company"', self.html)
self.assertIn("总账管理端", self.html)
self.assertIn("公司业务端", self.html)
self.assertIn("服务状态", self.html)
self.assertIn("当前账期", self.html)
self.assertNotIn("entry-card", self.html)
self.assertNotIn('value="group-admin"', self.html)
self.assertNotIn('value="demo123456"', self.html)
def test_login_css_lives_in_one_section(self) -> None:
self.assertIn("minmax(460px, 520px)", self.css)
self.assertIn("max-width: 1180px", self.css)
self.assertIn("margin: 0 auto", self.css)
self.assertIn("width: min(440px, 100%)", self.css)
self.assertIn("@media (max-width: 1023px)", self.css)
self.assertIn("@media (max-width: 720px)", self.css)
self.assertIn("minmax(400px, 440px)", self.css)
self.assertNotIn(".entry-card", self.css)
self.assertNotIn("0 0 60px rgba(55, 235, 137, 0.06)", self.css)
login_block = self.css.split("/* Login */", 1)[1]
after_login = login_block.split("/* ---- B-44", 1)[0] if "/* ---- B-44" in login_block else login_block
self.assertEqual(1, after_login.count(".entry-shell { width: 100%;"))
self.assertEqual(3, len(re.findall(r"\.entry-shell \{", after_login)))
later = self.css.split("/* ---- B-44 intercompany balances ---- */", 1)[-1]
self.assertNotIn(".entry-shell", later)
self.assertNotIn(".entry-form {", later)
self.assertNotIn(".role-switch {", later)
def test_login_error_and_keyboard_focus_follow_tokens(self) -> None:
self.assertIn(".entry-form #loginError { color: var(--color-danger); }", self.css)
self.assertIn(
".entry-form .field input:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }",
self.css,
)
def test_role_switch_reuses_segmented_rhythm(self) -> None:
self.assertIn(".role-switch { display: flex; gap: 4px; padding: 4px;", self.css)
self.assertIn("min-height: 36px", self.css)
self.assertIn('role="radiogroup"', self.html)
self.assertIn("class=\"sr-only\"", self.html)
def test_auth_javascript_untouched(self) -> None:
self.assertIn("function initEntry()", self.js)
self.assertIn('fetch("/api/login"', self.js)
self.assertIn('fetch("/api/password/change"', self.js)
self.assertIn("must_change_password", self.js)
self.assertIn("#togglePassword", self.js)
self.assertIn("进入总账管理端", self.js)
self.assertIn("进入公司业务端", self.js)
if __name__ == "__main__":
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
"""Chromium layout regression for the B-229 login page.
Measures dual-zone geometry, form width, overflow and overlap at the
accepted viewports. Screenshots are written next to other visual evidence.
"""
from __future__ import annotations
import base64
import json
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_b44_layout import QuietHandler, _Cdp, chrome_bin # noqa: E402
SCREEN_DIR = ROOT / "screenshots"
LOGIN_URL = "/web/index.html"
MEASURE = r"""
(() => {
const box = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
width: r.width, height: r.height
};
};
const overlapArea = (a, b) => {
if (!a || !b) return 0;
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx * dy);
};
const shell = document.querySelector(".entry-shell");
const context = document.querySelector(".entry-context");
const wrap = document.querySelector(".entry-form-wrap");
const form = document.querySelector(".entry-form");
const role = document.querySelector(".role-switch");
const spans = [...document.querySelectorAll(".role-switch span")];
const brand = document.querySelector("#product-name");
const status = document.querySelector(".entry-status");
const statement = document.querySelector(".entry-statement h1");
const sh = box(shell);
const ctx = box(context);
const wr = box(wrap);
const fm = box(form);
const sp = spans.map(box);
const clipped = (el) => Boolean(el && el.scrollWidth > el.clientWidth + 1);
return {
viewport: window.innerWidth,
viewportH: window.innerHeight,
grid: getComputedStyle(shell).gridTemplateColumns,
shellBox: sh,
shellCentered: Boolean(sh && Math.abs((sh.left + sh.right) / 2 - window.innerWidth / 2) <= 1),
contextBox: ctx,
wrapBox: wr,
formBox: fm,
formWidth: fm ? Math.round(fm.width) : 0,
roleHeight: role ? Math.round(role.getBoundingClientRect().height) : 0,
roleHorizontal: sp.length === 2 && sp[0].right <= sp[1].left + 2,
dualZone: Boolean(ctx && wr && ctx.right <= wr.left + 2 && Math.abs(ctx.top - wr.top) < 48),
stacked: Boolean(ctx && wr && ctx.bottom <= wr.top + 8),
overlap: overlapArea(ctx, fm),
pageOverflowX: document.documentElement.scrollWidth - window.innerWidth,
formBottom: fm ? fm.bottom : 0,
formFitsViewport: Boolean(fm && fm.top >= -1 && fm.bottom <= window.innerHeight + 2),
brandClipped: clipped(brand),
statementClipped: clipped(statement),
statusText: (status?.textContent || "").replace(/\s+/g, " ").trim(),
periodText: (document.querySelector(".entry-facts")?.textContent || "").replace(/\s+/g, " ").trim(),
hasToggle: Boolean(document.querySelector("#togglePassword")),
hasError: Boolean(document.querySelector("#loginError")),
changeHidden: document.querySelector("#changePassword")?.hidden === true,
};
})()
"""
VIEWPORTS = (
(1440, 900, "desktop", "b229-login-1440.png"),
(1366, 768, "desktop", "b229-login-1366.png"),
(1024, 768, "desktop", "b229-login-1024.png"),
(768, 1024, "compact", "b229-login-768.png"),
(375, 812, "mobile", "b229-login-375.png"),
)
class LoginLayoutTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.chrome = chrome_bin()
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.http_thread.start()
cls.port = cls.httpd.server_address[1]
cls.tmp = tempfile.TemporaryDirectory(prefix="b229-login-", ignore_cleanup_errors=True)
cls.proc = subprocess.Popen(
[
str(cls.chrome),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--hide-scrollbars",
"--remote-debugging-port=0",
f"--user-data-dir={cls.tmp.name}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(f"http://127.0.0.1:{cls.debug_port}/json/list", timeout=5).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
SCREEN_DIR.mkdir(exist_ok=True)
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except Exception:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "httpd", None):
cls.httpd.shutdown()
cls.httpd.server_close()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _open(self, width: int, height: int) -> dict:
self.cdp.call(
"Emulation.setDeviceMetricsOverride",
{
"width": width,
"height": height,
"deviceScaleFactor": 1,
"mobile": width <= 720,
},
)
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.port}{LOGIN_URL}"})
time.sleep(0.4)
deadline = time.time() + 8
last = None
while time.time() < deadline:
result = self.cdp.call("Runtime.evaluate", {"expression": MEASURE, "returnByValue": True})
last = result.get("result", {}).get("value")
if last and last.get("formWidth"):
return last
time.sleep(0.1)
raise TimeoutError(f"login metrics not ready: {last}")
def _shot(self, name: str) -> None:
raw = self.cdp.call("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})
(SCREEN_DIR / name).write_bytes(base64.b64decode(raw["data"]))
def test_login_viewports(self) -> None:
for width, height, mode, shot in VIEWPORTS:
with self.subTest(width=width, height=height, mode=mode):
metrics = self._open(width, height)
self._shot(shot)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertEqual(0, metrics["overlap"], metrics)
self.assertLessEqual(metrics["pageOverflowX"], 1, metrics)
self.assertFalse(metrics["brandClipped"], metrics)
self.assertFalse(metrics["statementClipped"], metrics)
self.assertTrue(metrics["roleHorizontal"], metrics)
self.assertLessEqual(metrics["roleHeight"], 52, metrics)
self.assertIn("运行中", metrics["statusText"], metrics)
self.assertIn("2026 年 7 月", metrics["periodText"], metrics)
self.assertTrue(metrics["hasToggle"], metrics)
self.assertTrue(metrics["hasError"], metrics)
self.assertTrue(metrics["changeHidden"], metrics)
if mode == "desktop":
self.assertTrue(metrics["dualZone"], metrics)
self.assertLessEqual(metrics["shellBox"]["width"], 1180, metrics)
self.assertTrue(metrics["shellCentered"], metrics)
self.assertFalse(metrics["stacked"], metrics)
self.assertGreaterEqual(metrics["formWidth"], 420, metrics)
self.assertLessEqual(metrics["formWidth"], 460, metrics)
self.assertTrue(metrics["formFitsViewport"], metrics)
elif mode == "compact":
self.assertTrue(metrics["dualZone"], metrics)
self.assertFalse(metrics["stacked"], metrics)
self.assertGreaterEqual(metrics["formWidth"], 360, metrics)
self.assertLessEqual(metrics["formWidth"], 440, metrics)
else:
self.assertTrue(metrics["stacked"], metrics)
self.assertFalse(metrics["dualZone"], metrics)
if __name__ == "__main__":
unittest.main()
+607
View File
@@ -0,0 +1,607 @@
"""B-44 manual record tests: submit idempotency, approve new/link, return,
exception, reverse, deduplication, idempotent replay and concurrency."""
from __future__ import annotations
from decimal import Decimal
import threading
import unittest
from bank_importer import ledger_events, manual_records, matching, subjects
from ledger_helpers import LedgerBase
class SubmitTests(LedgerBase):
def submit(self, **overrides):
params = dict(
company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00",
direction="incoming",
amount="50.00",
currency="CNY",
funding_source="other",
requested_subject="other_receivable",
request_key="mr-key",
actor=self.admin,
summary="归还往来款",
)
params.update(overrides)
return manual_records.submit(self.connection, **params)
def test_submit_creates_pending_decision(self) -> None:
payload = self.submit()
self.assertEqual("pending", payload["state"])
self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
self.assertEqual(1, payload["decision_revision"])
self.assertEqual([], payload["candidates"])
def test_submit_idempotent_on_request_key(self) -> None:
first = self.submit()
second = self.submit()
self.assertEqual(first["id"], second["id"])
self.assertTrue(second["idempotent_replay"])
decisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_record_decisions"
).fetchone()["n"]
self.assertEqual(1, decisions)
def test_submit_validations(self) -> None:
cases = (
({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
({"amount": "0"}, "必须大于零"),
({"amount": "abc"}, "十进制"),
({"funding_source": "credit_card"}, "资金来源"),
({"requested_subject": "equity"}, "科目"),
({"request_key": ""}, "request_key"),
({"counterparty_company_id": 9999}, "公司不存在"),
)
for overrides, expected in cases:
with self.subTest(overrides=overrides):
with self.assertRaises(manual_records.ManualInputError) as ctx:
self.submit(**overrides)
self.assertIn(expected, str(ctx.exception))
def test_bank_account_must_belong_to_submitting_company(self) -> None:
with self.assertRaises(manual_records.ManualInputError):
self.submit(
funding_source="approved_bank_account",
bank_account_id=self.account_b["id"],
)
def test_related_source_row_never_modified(self) -> None:
row_id = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
payload = self.submit(related_source_row_id=row_id)
self.assertEqual(row_id, payload["related_source_row_id"])
with self.assertRaises(Exception):
self.connection.execute(
"UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
)
self.connection.rollback()
class DecisionTests(LedgerBase):
def setUp(self) -> None:
super().setUp()
self.record = manual_records.submit(
self.connection,
company_id=self.company_a, counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-1",
actor=self.admin, summary="归还往来款",
)
def decide(self, action, **overrides):
params = dict(
record_id=self.record["id"],
action=action,
reason="管理员审核",
expected_decision_id=self.record["decision_id"],
request_key="dec-key",
actor=self.admin,
)
params.update(overrides)
return manual_records.decide(self.connection, **params)
def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
outcome = self.decide("approve_new")
self.assertEqual("approved", outcome["state"])
event_id = outcome["ledger_event_id"]
revision = ledger_events.current_revision(self.connection, event_id)
self.assertEqual("confirmed", revision["state"])
# incoming 50 from A's perspective: B is the payer, A the payee.
self.assertEqual(self.company_b, revision["payer_company_id"])
self.assertEqual(self.company_a, revision["payee_company_id"])
self.assertEqual(self.company_a, revision["perspective_company_id"])
self.assertEqual("other_receivable", revision["subject_code"])
# Exactly one position impact.
positions = self.connection.execute(
"SELECT * FROM eligible_position_events"
).fetchall()
self.assertEqual(1, len(positions))
def test_approve_new_replay_is_idempotent(self) -> None:
first = self.decide("approve_new")
second = self.decide("approve_new")
self.assertEqual(first["decision_id"], second["decision_id"])
events = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_events"
).fetchone()["n"]
self.assertEqual(1, events)
def test_return_and_exception_never_produce_balance(self) -> None:
for action in ("return", "exception"):
with self.subTest(action=action):
fresh = manual_records.submit(
self.connection,
company_id=self.company_a, counterparty_company_id=self.company_b,
occurred_at="2026-01-11T09:00:00", direction="incoming",
amount="10.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key=f"mr-{action}",
actor=self.admin,
)
outcome = manual_records.decide(
self.connection, fresh["id"], action,
reason="材料不足" if action == "return" else "转入异常",
expected_decision_id=fresh["decision_id"],
request_key=f"dec-{action}", actor=self.admin,
)
expected_state = "returned" if action == "return" else "exception"
self.assertEqual(expected_state, outcome["state"])
self.assertEqual(0, self.connection.execute(
"SELECT COUNT(*) AS n FROM eligible_position_events"
).fetchone()["n"])
def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
approved = self.decide("approve_new")
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录,冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
rows = self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
self.assertEqual(2, len(rows))
original, reversal = rows
self.assertEqual("normal", original["posting_kind"])
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
# Net position is zero.
signed = Decimal(original["amount"]) * (
1 if original["payer_company_id"] == self.company_a else -1
) + Decimal(reversal["amount"]) * (
1 if reversal["payer_company_id"] == self.company_a else -1
)
self.assertEqual(Decimal("0"), signed)
def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
# Build an eligible bank event and confirm its subject.
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
bank_ledger_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, bank_ledger_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="subj-1",
actor=self.admin,
)
before = len(self.position_events())
outcome = manual_records.decide(
self.connection, self.record["id"], "approve_link",
reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
request_key="dec-link", actor=self.admin,
target_ledger_event_id=bank_ledger_id,
)
self.assertEqual("approved", outcome["state"])
self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
after = len(self.position_events())
self.assertEqual(before, after)
sources = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(self.record["id"],),
).fetchall()
self.assertEqual(1, len(sources))
self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])
def test_approve_link_requires_target(self) -> None:
with self.assertRaises(manual_records.ManualInputError):
self.decide("approve_link")
def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
bank_ledger_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, bank_ledger_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="subj-1",
actor=self.admin,
)
approved = manual_records.decide(
self.connection, self.record["id"], "approve_link",
reason="同源", expected_decision_id=self.record["decision_id"],
request_key="dec-link", actor=self.admin,
target_ledger_event_id=bank_ledger_id,
)
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="撤销关联", expected_decision_id=approved["decision_id"],
request_key="dec-unlink", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
# No reversal event is created for a linked claim.
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(0, reversals)
# The bank impact is untouched.
self.assertEqual(1, len(self.position_events()))
def test_stale_expected_decision_conflicts(self) -> None:
approved = self.decide("approve_new")
with self.assertRaises(manual_records.ManualConflictError):
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="冲销", expected_decision_id=self.record["decision_id"],
request_key="dec-stale", actor=self.admin,
)
def test_returned_record_cannot_be_reversed(self) -> None:
returned = self.decide("return", reason="材料不足")
with self.assertRaises(manual_records.ManualConflictError):
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="冲销", expected_decision_id=returned["decision_id"],
request_key="dec-x", actor=self.admin,
)
def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None:
# Reversing with an independent effective date must keep the original
# impact for cutoffs before it and net it to zero only on/after it —
# never rewrite the historical period retroactively.
from bank_importer import positions
approved = self.decide("approve_new")
event_id = approved["ledger_event_id"]
self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10])
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-date", actor=self.admin,
effective_at="2026-06-15",
)
reversal = self.connection.execute(
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()
self.assertEqual("2026-06-15", reversal["effective_at"][:10])
before = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-05-31"
)
after = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in before["items"]:
if item["company_id"] == self.company_a:
self.assertEqual("-50.00", item["result"]["signed_amount"])
for item in after["items"]:
if item["company_id"] == self.company_a:
self.assertEqual("0.00", item["result"]["signed_amount"])
def test_reverse_defaults_to_approval_business_day(self) -> None:
from datetime import datetime, timedelta, timezone
from bank_importer import positions
approved = self.decide("approve_new")
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-default", actor=self.admin,
)
today = datetime.now(timezone(timedelta(hours=8))).date().isoformat()
reversal = self.connection.execute(
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()
self.assertEqual(today, reversal["effective_at"][:10])
# The day before the approval business day keeps the original impact.
before = positions.company_balances(
self.connection, from_="2026-01-01",
cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(),
)
for item in before["items"]:
if item["company_id"] == self.company_a:
self.assertEqual("-50.00", item["result"]["signed_amount"])
def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None:
# approve_new created the event; a second manual record later links
# onto it. Reversing the creation source must still create an
# equal-amount reversal — the original economic impact must not survive
# just because other evidence was attached later.
approved = self.decide("approve_new")
event_id = approved["ledger_event_id"]
linked = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-12T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-link-ev",
actor=self.admin,
)
manual_records.decide(
self.connection, linked["id"], "approve_link",
reason="同源补充证据", expected_decision_id=linked["decision_id"],
request_key="dec-link-ev", actor=self.admin,
target_ledger_event_id=event_id,
)
self.assertEqual(1, len(self.position_events()))
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-create", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
rows = self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
self.assertEqual(2, len(rows))
original, reversal = rows
self.assertEqual("normal", original["posting_kind"])
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
# The later linked evidence stays attached to the event.
links = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id"
).fetchall()
self.assertEqual(2, len(links))
def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None:
# M1 creates the event, M2 links; after M1's reversal created the
# offset, reversing the linked M2 must only detach, never add a second
# reversal event.
approved = self.decide("approve_new")
event_id = approved["ledger_event_id"]
linked = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-12T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-link-ev2",
actor=self.admin,
)
linked_approved = manual_records.decide(
self.connection, linked["id"], "approve_link",
reason="同源补充证据", expected_decision_id=linked["decision_id"],
request_key="dec-link-ev2", actor=self.admin,
target_ledger_event_id=event_id,
)
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-create2", actor=self.admin,
)
manual_records.decide(
self.connection, linked["id"], "reverse",
reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"],
request_key="dec-rev-link2", actor=self.admin,
)
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(1, reversals)
links = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources"
).fetchall()
self.assertEqual(1, len(links))
class DedupAndConcurrencyTests(LedgerBase):
def test_duplicate_manual_record_pairing_candidate(self) -> None:
manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="outgoing",
amount="40.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-a",
actor=self.admin,
)
other = manual_records.submit(
self.connection, company_id=self.company_b,
counterparty_company_id=self.company_a,
occurred_at="2026-01-10T10:00:00", direction="incoming",
amount="40.00", currency="CNY", funding_source="other",
requested_subject="other_payable", request_key="mr-b",
actor=self.admin,
)
candidates = manual_records.find_candidates(self.connection, other["id"])
self.assertEqual(1, len(candidates))
self.assertEqual("manual_record", candidates[0]["kind"])
def test_duplicate_bank_event_candidate_hints_link(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-05T12:00:00", direction="incoming",
amount="100.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-c",
actor=self.admin,
)
candidates = manual_records.find_candidates(self.connection, record["id"])
self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))
def test_concurrent_approve_new_counts_once(self) -> None:
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-conc",
actor=self.admin,
)
errors: list[Exception] = []
def worker(thread_key: str) -> None:
import sqlite3
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
manual_records.decide(
connection, record["id"], "approve_new",
reason="并发审批", expected_decision_id=record["decision_id"],
request_key=f"dec-{thread_key}", actor=self.admin,
)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# Two connections can still double-commit, so the UNIQUE manual source
# claim must make the second write fail or be a no-op; either way only
# one ledger event may exist.
events = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_events"
).fetchone()["n"]
self.assertEqual(1, events)
def test_concurrent_submit_same_request_key_is_idempotent(self) -> None:
import sqlite3
results: list[dict] = []
errors: list[Exception] = []
def worker(thread_key: str) -> None:
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
payload = manual_records.submit(
connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable",
request_key="mr-conc-submit", actor=self.admin,
)
results.append(payload)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual([], errors)
rows = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_records"
).fetchone()["n"]
self.assertEqual(1, rows)
self.assertEqual(1, len({payload["id"] for payload in results}))
decisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_record_decisions"
).fetchone()["n"]
self.assertEqual(1, decisions)
def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None:
import sqlite3
self.pair(self.company_a, self.company_b, "100.00")
from bank_importer import ledger_events
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
outcomes: list[dict] = []
errors: list[Exception] = []
def worker(thread_key: str) -> None:
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
payload = subjects.confirm_subject(
connection, event_id,
perspective_company_id=self.company_a,
subject_code="other_receivable", reason="并发确认",
expected_revision=1, request_key="conc-subj-key",
actor=self.admin,
)
outcomes.append(payload)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual([], errors)
confirmed = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'"
).fetchone()["n"]
self.assertEqual(1, confirmed)
self.assertEqual(1, len({payload["revision_id"] for payload in outcomes}))
def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
from bank_importer import ledger_events
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
first = subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="确认", expected_revision=1, request_key="noise-key",
actor=self.admin,
)
second = subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="重复确认", expected_revision=1, request_key="noise-key",
actor=self.admin,
)
self.assertEqual(first["revision_id"], second["revision_id"])
revisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?",
(event_id,),
).fetchone()["n"]
# One pending_subject + one confirmed; the replay appended nothing.
self.assertEqual(2, revisions)
confirmed = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions "
"WHERE ledger_event_id = ? AND state = 'confirmed'",
(event_id,),
).fetchone()["n"]
self.assertEqual(1, confirmed)
if __name__ == "__main__":
unittest.main()
+597
View File
@@ -0,0 +1,597 @@
"""Tests for dynamic master data (companies, bank accounts, aliases).
Unit tests exercise normalization, masking, effective-window boundaries and
alias-match priority against an in-memory database; the HTTP integration
class drives a live server to cover the submission -> review -> disable
workflow, uniqueness under concurrency, masking boundaries and RBAC.
"""
from __future__ import annotations
from pathlib import Path
import tempfile
import threading
import unittest
from bank_importer import auth, master_data
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
class MasterDataUnitTests(unittest.TestCase):
def setUp(self) -> None:
self.connection = connect(":memory:")
migrate(self.connection)
now = master_data.utc_now()
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(now, now),
)
self.company_id = int(cursor.lastrowid)
self.connection.commit()
def tearDown(self) -> None:
self.connection.close()
def _active_account(self, number: str = "1234567890123") -> object:
return master_data.submit_bank_account(
self.connection,
company_id=self.company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
def test_normalize_account_number(self) -> None:
self.assertEqual("1234567890", master_data.normalize_account_number("1234 5678-90"))
self.assertEqual("1234567890", master_data.normalize_account_number("1234567890"))
for invalid in ("", "123", "abc123456", "12345678901234567890123456789012345"):
with self.assertRaises(ValueError):
master_data.normalize_account_number(invalid)
def test_mask_account_number(self) -> None:
self.assertEqual("****9012", master_data.mask_account_number("123456789012"))
self.assertEqual("****123", master_data.mask_account_number("123"))
def test_is_usable_window_boundaries(self) -> None:
account = self._active_account()
account = master_data.review_bank_account(
self.connection, account["id"], "approve", None, self._admin(),
effective_from="2026-02-01",
)
self.assertFalse(master_data.is_usable(account, "2026-01-31"))
self.assertTrue(master_data.is_usable(account, "2026-02-01"))
# A scheduled effective_to bounds the usable window (inclusive).
with self.connection:
self.connection.execute(
"UPDATE bank_accounts SET effective_to = '2026-06-30' WHERE id = ?",
(account["id"],),
)
account = master_data.get_account(self.connection, account["id"])
self.assertTrue(master_data.is_usable(account, "2026-06-30"))
self.assertFalse(master_data.is_usable(account, "2026-07-01"))
# Disabled accounts never accept new uploads, even inside the window.
account = master_data.review_bank_account(
self.connection, account["id"], "disable", "账户销户", self._admin(),
effective_to="2026-06-30",
)
self.assertFalse(master_data.is_usable(account, "2026-06-30"))
# …but they still identify historical rows inside the window.
self.assertTrue(master_data.is_identifiable(account, "2026-06-30"))
self.assertFalse(master_data.is_identifiable(account, "2026-07-01"))
def _admin(self):
user = self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
if user is None:
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
user = self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
return user
def test_pending_and_returned_accounts_never_usable(self) -> None:
account = master_data.submit_bank_account(
self.connection,
company_id=self.company_id,
bank_name="中信银行",
account_type="一般户",
account_number="9988776655",
start_date="2026-01-01",
actor=None,
)
self.assertFalse(master_data.is_usable(account, "2026-06-01"))
account = master_data.review_bank_account(
self.connection, account["id"], "return", "资料不全", self._admin()
)
self.assertEqual("returned", account["status"])
self.assertFalse(master_data.is_usable(account, "2026-06-01"))
def test_alias_match_priority_and_window(self) -> None:
exact_account = self._active_account("1111222233334")
exact_account = master_data.review_bank_account(
self.connection, exact_account["id"], "approve", None, self._admin(),
effective_from="2026-01-01",
)
alias_account = master_data.submit_bank_account(
self.connection,
company_id=self.company_id,
bank_name="建设银行",
account_type="一般户",
account_number="5555666677778",
start_date="2026-01-01",
actor=None,
)
alias_account = master_data.review_bank_account(
self.connection, alias_account["id"], "approve", None, self._admin(),
effective_from="2026-01-01",
)
master_data.add_alias(
self.connection, alias_account["id"], "account", "9999000011112",
priority=5, actor=None,
)
master_data.add_alias(
self.connection, alias_account["id"], "name", "甲公司郑州分部",
effective_from="2026-03-01", effective_to="2026-03-31", actor=None,
)
# Exact account number beats the account alias tier.
master_data.add_alias(
self.connection, alias_account["id"], "account", "1111222233334",
priority=1, actor=None,
)
hits = master_data.match_account(
self.connection, account_number="1111-2222 3333 4", on_date="2026-06-01"
)
self.assertEqual(exact_account["id"], hits[0]["bank_account_id"])
self.assertEqual("account_exact", hits[0]["via"])
self.assertEqual(alias_account["id"], hits[1]["bank_account_id"])
self.assertEqual("account_alias", hits[1]["via"])
# Name alias only matches inside its effective window.
hits = master_data.match_account(
self.connection, name="甲公司郑州分部 ", on_date="2026-03-15"
)
self.assertEqual(alias_account["id"], hits[0]["bank_account_id"])
self.assertEqual("name_alias", hits[0]["via"])
self.assertEqual(
[],
master_data.match_account(
self.connection, name="甲公司郑州分部", on_date="2026-04-01"
),
)
def test_match_never_returns_pending_or_disabled(self) -> None:
pending = master_data.submit_bank_account(
self.connection,
company_id=self.company_id,
bank_name="郑州银行",
account_type="一般户",
account_number="7777888899990",
actor=None,
)
self.assertEqual(
[],
master_data.match_account(self.connection, account_number="7777888899990"),
)
account = master_data.review_bank_account(
self.connection, pending["id"], "approve", None, self._admin(),
effective_from="2026-01-01",
)
self.assertTrue(
master_data.match_account(self.connection, account_number="7777888899990")
)
master_data.review_bank_account(
self.connection, account["id"], "disable", "销户", self._admin(),
effective_to="2026-06-30",
)
# Disabled accounts still resolve dates inside their effective window…
self.assertTrue(
master_data.match_account(
self.connection, account_number="7777888899990", on_date="2026-06-30"
)
)
# …and never resolve dates after it.
self.assertEqual(
[],
master_data.match_account(
self.connection, account_number="7777888899990", on_date="2026-07-01"
),
)
class MasterDataApiTests(unittest.TestCase):
"""Live-server workflow tests for account registration and review."""
@classmethod
def setUpClass(cls) -> None:
import os
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
server.ensure_bootstrap_admin(connection)
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
# Company A created together with its cashier login in one call.
status, _, data = cls.admin.post_json(
"/api/admin/companies",
{"name": "甲公司", "credit_code": "91410100TEST", "cashier_name": "牛女士",
"username": "cashier-a"},
)
assert status == 200, data
payload = as_json(data)
cls.company_a = payload["company_id"]
cls.initial_a = payload["initial_password"]
assert cls.initial_a != "cashier-a"
assert len(cls.initial_a) >= 12
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
assert status == 200, data
cls.company_b = as_json(data)["company_id"]
status, _, data = cls.admin.post_json(
"/api/admin/users", {"username": "cashier-b", "company_id": cls.company_b}
)
assert status == 200, data
cls.initial_b = as_json(data)["initial_password"]
assert cls.initial_b != "cashier-b"
cls.cashier_a = cls._login_company_user("cashier-a", cls.initial_a)
cls.cashier_b = cls._login_company_user("cashier-b", cls.initial_b)
@classmethod
def _login_company_user(cls, username: str, initial: str) -> Client:
client = Client("127.0.0.1", cls.port)
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
@classmethod
def tearDownClass(cls) -> None:
import os
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def submit_account(self, client: Client, number: str, bank: str = "郑州银行"):
return client.post_json(
"/api/company/accounts",
{"bank_name": bank, "account_type": "一般户",
"account_number": number, "start_date": "2026-08-01"},
)
def review(self, account_id: int, decision: str, reason: str = ""):
return self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": decision, "reason": reason},
)
# ------------------------------------------------------------------
# Company management
# ------------------------------------------------------------------
def test_new_company_appears_in_admin_list_with_user(self) -> None:
status, _, data = self.admin.get("/api/admin/companies")
self.assertEqual(200, status)
companies = {c["name"]: c for c in as_json(data)["companies"]}
self.assertIn("甲公司", companies)
self.assertEqual("91410100TEST", companies["甲公司"]["credit_code"])
self.assertEqual("牛女士", companies["甲公司"]["cashier_name"])
self.assertIn("cashier-a", companies["甲公司"]["usernames"])
self.assertEqual(0, companies["乙公司"]["account_count"])
def test_company_user_cannot_change_own_company_binding(self) -> None:
# A company_id in the submission body is ignored: the account is
# always bound to the session company.
status, _, data = self.cashier_a.post_json(
"/api/company/accounts",
{"bank_name": "民生银行", "account_type": "一般户",
"account_number": "6001000100010001", "company_id": self.company_b},
)
self.assertEqual(200, status, data)
self.assertEqual(self.company_a, as_json(data)["account"]["company_id"])
# ------------------------------------------------------------------
# Account registration -> review workflow
# ------------------------------------------------------------------
def test_full_registration_workflow(self) -> None:
status, _, data = self.submit_account(self.cashier_a, "6222 0210-0100 8888")
self.assertEqual(200, status, data)
account = as_json(data)["account"]
account_id = account["id"]
self.assertEqual("pending", account["status"])
# Company view is masked only.
self.assertEqual("****8888", account["account_number_masked"])
self.assertNotIn("account_number", account)
self.assertFalse(account.get("usable", True))
# Admin audit view shows the full normalized number.
status, _, data = self.admin.get("/api/admin/accounts")
self.assertEqual(200, status)
full = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
self.assertEqual("62220210010088 88".replace(" ", ""), full["account_number"])
self.assertEqual("甲公司", full["company_name"])
# Pending account cannot upload.
sample = Path("流水模板/中信银行账户流水.xlsx")
status, _, data = self.cashier_a.post_multipart(
"/api/parse", {"bank_account_id": str(account_id)},
sample.name, sample.read_bytes(),
)
self.assertEqual(409, status, data)
# Approve -> active and usable.
status, _, data = self.review(account_id, "approve")
self.assertEqual(200, status, data)
self.assertEqual("active", as_json(data)["account"]["status"])
self.assertEqual("2026-08-01", as_json(data)["account"]["effective_from"])
status, _, data = self.cashier_a.get("/api/company/accounts")
mine = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
self.assertTrue(mine["usable"])
# Approved account accepts uploads.
status, _, data = self.cashier_a.post_multipart(
"/api/parse", {"bank_account_id": str(account_id)},
sample.name, sample.read_bytes(),
)
self.assertEqual(200, status, data)
# Disable -> leaves the usable window.
status, _, data = self.review(account_id, "disable", "账户销户")
self.assertEqual(200, status, data)
disabled = as_json(data)["account"]
self.assertEqual("disabled", disabled["status"])
self.assertIsNotNone(disabled["effective_to"])
status, _, data = self.cashier_a.get("/api/company/accounts")
mine = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
self.assertFalse(mine["usable"])
def test_return_and_resubmit_reuses_same_row(self) -> None:
status, _, data = self.submit_account(self.cashier_a, "3100998877665")
self.assertEqual(200, status, data)
account_id = as_json(data)["account"]["id"]
status, _, data = self.review(account_id, "return", "开户许可证模糊")
self.assertEqual(200, status, data)
self.assertEqual("returned", as_json(data)["account"]["status"])
# Return requires a reason.
status, _, data = self.submit_account(self.cashier_a, "3100998877666")
other_id = as_json(data)["account"]["id"]
status, _, _ = self.review(other_id, "return")
self.assertEqual(400, status)
# Resubmission reopens the same row as pending.
status, _, data = self.submit_account(self.cashier_a, "3100 9988-7766 5")
self.assertEqual(200, status, data)
resubmitted = as_json(data)["account"]
self.assertEqual(account_id, resubmitted["id"])
self.assertEqual("pending", resubmitted["status"])
# The whole trail is auditable with actor, time and reason.
status, _, data = self.admin.get(
f"/api/admin/master-changes?entity_type=bank_account&entity_id={account_id}"
)
self.assertEqual(200, status)
changes = as_json(data)["changes"]
actions = [change["action"] for change in changes]
self.assertEqual(["resubmit", "return", "submit"], actions)
returned = [c for c in changes if c["action"] == "return"][0]
self.assertEqual("开户许可证模糊", returned["reason"])
self.assertEqual("group-admin", returned["actor_username"])
self.assertIn('"pending"', returned["before_json"])
self.assertIn('"returned"', returned["after_json"])
def test_duplicate_normalized_number_conflicts(self) -> None:
status, _, _ = self.submit_account(self.cashier_a, "4501111222233")
self.assertEqual(200, status)
# Same company, same number with different separators.
status, _, data = self.submit_account(self.cashier_a, "4501-1112 2223-3")
self.assertEqual(409, status, data)
# Another company registering the same number conflicts too.
status, _, data = self.submit_account(self.cashier_b, "4501111222233")
self.assertEqual(409, status, data)
self.assertIn("其他公司", as_json(data)["message"])
def test_concurrent_submissions_create_only_one_account(self) -> None:
number = "8800123456789"
results: list[int] = []
def submit() -> None:
client = Client("127.0.0.1", self.port)
client.cookies.update(self.cashier_a.cookies)
status, _, _ = client.post_json(
"/api/company/accounts",
{"bank_name": "中信银行", "account_type": "基本户",
"account_number": number},
)
results.append(status)
threads = [threading.Thread(target=submit) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(sorted(results), [200, 409])
connection = connect(self.db_path)
try:
count = connection.execute(
"SELECT COUNT(*) AS n FROM bank_accounts WHERE account_number = ?",
(number,),
).fetchone()["n"]
finally:
connection.close()
self.assertEqual(1, count)
# ------------------------------------------------------------------
# RBAC and masking
# ------------------------------------------------------------------
def test_company_user_forbidden_on_admin_account_endpoints(self) -> None:
for call in (
lambda: self.cashier_a.get("/api/admin/accounts"),
lambda: self.cashier_a.post_json(
"/api/admin/accounts/1/review", {"decision": "approve"}
),
lambda: self.cashier_a.get("/api/admin/accounts/1/aliases"),
lambda: self.cashier_a.post_json(
"/api/admin/accounts/1/aliases",
{"alias_kind": "name", "alias_value": "x"},
),
lambda: self.cashier_a.get("/api/admin/master-changes"),
):
status, _, data = call()
self.assertEqual(403, status, data)
def test_admin_cannot_use_company_account_endpoint(self) -> None:
status, _, _ = self.admin.get("/api/company/accounts")
self.assertEqual(403, status)
status, _, _ = self.submit_account(self.admin, "1000200030004")
self.assertEqual(403, status)
def test_unauthenticated_master_data_calls_return_401(self) -> None:
anon = Client("127.0.0.1", self.port)
for call in (
lambda: anon.get("/api/company/accounts"),
lambda: anon.get("/api/admin/accounts"),
lambda: anon.post_json("/api/company/accounts", {}),
lambda: anon.get("/api/admin/master-changes"),
):
status, _, _ = call()
self.assertEqual(401, status)
def test_company_account_list_scoped_and_masked(self) -> None:
status, _, data = self.cashier_b.get("/api/company/accounts")
self.assertEqual(200, status)
for account in as_json(data)["accounts"]:
self.assertEqual(self.company_b, account["company_id"])
self.assertNotIn("account_number", account)
self.assertTrue(account["account_number_masked"].startswith("****"))
def test_upload_with_other_companys_account_returns_404(self) -> None:
status, _, data = self.submit_account(self.cashier_a, "5550001112223")
account_id = as_json(data)["account"]["id"]
status, _, _ = self.review(account_id, "approve")
self.assertEqual(200, status)
sample = Path("流水模板/中信银行账户流水.xlsx")
status, _, data = self.cashier_b.post_multipart(
"/api/parse", {"bank_account_id": str(account_id)},
sample.name, sample.read_bytes(),
)
self.assertEqual(404, status, data)
# ------------------------------------------------------------------
# Aliases via API
# ------------------------------------------------------------------
def test_alias_crud_and_audit(self) -> None:
status, _, data = self.submit_account(self.cashier_a, "6601234509876")
account_id = as_json(data)["account"]["id"]
status, _, _ = self.review(account_id, "approve")
self.assertEqual(200, status)
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/aliases",
{"alias_kind": "name", "alias_value": "甲公司工会",
"effective_from": "2026-01-01", "effective_to": "2026-12-31",
"priority": 10},
)
self.assertEqual(200, status, data)
alias_id = as_json(data)["alias_id"]
# Duplicate alias on the same account conflicts.
status, _, _ = self.admin.post_json(
f"/api/admin/accounts/{account_id}/aliases",
{"alias_kind": "name", "alias_value": "甲公司工会"},
)
self.assertEqual(409, status)
status, _, data = self.admin.get(f"/api/admin/accounts/{account_id}/aliases")
self.assertEqual(200, status)
aliases = as_json(data)["aliases"]
self.assertEqual(1, len(aliases))
self.assertEqual("甲公司工会", aliases[0]["alias_value"])
self.assertEqual(10, aliases[0]["priority"])
status, _, data = self.admin.get(
f"/api/admin/master-changes?entity_type=account_alias&entity_id={alias_id}"
)
changes = as_json(data)["changes"]
self.assertEqual("create", changes[0]["action"])
self.assertIn("2026-12-31", changes[0]["after_json"])
# Invalid alias input is rejected, unknown account is 404.
status, _, _ = self.admin.post_json(
f"/api/admin/accounts/{account_id}/aliases",
{"alias_kind": "account", "alias_value": "abc"},
)
self.assertEqual(400, status)
status, _, _ = self.admin.post_json(
"/api/admin/accounts/99999/aliases",
{"alias_kind": "name", "alias_value": "x"},
)
self.assertEqual(404, status)
if __name__ == "__main__":
unittest.main()
+984
View File
@@ -0,0 +1,984 @@
"""Matching engine tests: bilateral ordering, cross-day windows, ambiguity,
same-company transfers, personal transit mappings, manual decisions, locking,
idempotency, concurrency, Decimal precision and B-44 eligibility."""
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import sqlite3
import tempfile
import threading
import unittest
from bank_importer import auth, matching, master_data, personal_transit
from bank_importer.db import connect, migrate, utc_now
class MatchingBase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
self.admin = self._admin()
self.company_a = self._company("甲公司")
self.company_b = self._company("乙公司")
self.company_c = self._company("丙公司")
self.account_a = self._approved_account(self.company_a, "6222000000000001")
self.account_b = self._approved_account(self.company_b, "6222000000000002")
self.account_a2 = self._approved_account(self.company_a, "6222000000000003")
self.account_b2 = self._approved_account(self.company_b, "6222000000000004")
def _admin(self):
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
return self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
def _company(self, name: str) -> int:
with self.connection:
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, utc_now(), utc_now()),
)
return int(cursor.lastrowid)
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
account = master_data.submit_bank_account(
self.connection, company_id=company_id, bank_name="中信银行",
account_type="基本户", account_number=number, start_date=start,
actor=None,
)
return master_data.review_bank_account(
self.connection, account["id"], "approve", None, self.admin,
effective_from=start,
)
def add_row(
self,
company_id: int,
*,
own_account: str,
own_name: str = "测试公司",
cp_account: str | None = None,
cp_name: str | None = None,
income: str = "0",
expense: str = "0",
at: str = "2026-01-05T10:00:00",
currency: str = "CNY",
reference: str | None = None,
summary: str | None = None,
sheet: str = "流水",
) -> int:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(utc_now(), utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
VALUES (?, 'parsing', ?, ?, ?)
""",
(source_file_id, company_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, own_account, own_name, period_start, period_end,
transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, NULL, NULL, NULL, NULL, 1, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense, balance,
own_account, own_name, counterparty_account, counterparty_name,
counterparty_bank, summary, purpose, reference, currency, created_at
) VALUES (?, 1, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?, NULL, ?, ?, ?)
""",
(
sheet_batch_id, at, income, expense, own_account, own_name,
cp_account, cp_name, summary, reference, currency, utc_now(),
),
)
return int(cursor.lastrowid)
def current(self, row_id: int):
return self.connection.execute(
"""
SELECT d.classification, d.pairing, d.amount, d.currency, d.effective_at,
d.mode, d.locked, d.revision
FROM transfer_observation_claims c
JOIN transfer_match_decisions d ON d.id = c.decision_id
WHERE c.source_row_id = ?
""",
(row_id,),
).fetchone()
def _event_of(self, row_id: int) -> int:
return self.connection.execute(
"SELECT event_id FROM transfer_observation_claims WHERE source_row_id = ?",
(row_id,),
).fetchone()["event_id"]
def eligible(self) -> list[sqlite3.Row]:
return matching.eligible_intercompany_events(self.connection)
def event_count(self) -> int:
return self.connection.execute(
"SELECT COUNT(*) AS n FROM canonical_transfer_events"
).fetchone()["n"]
def decision_count(self) -> int:
return self.connection.execute(
"SELECT COUNT(*) AS n FROM transfer_match_decisions"
).fetchone()["n"]
class BilateralOrderTests(MatchingBase):
def test_a_outgoing_then_b_incoming_pairs_once(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", at="2026-01-05T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("internal_single", self.current(row_a)["classification"])
# A single internal observation must not enter the B-44 balance yet.
self.assertEqual([], self.eligible())
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00", at="2026-01-05T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
for row_id in (row_a, row_b):
self.assertEqual("intercompany", self.current(row_id)["classification"])
self.assertEqual("paired", self.current(row_id)["pairing"])
eligible = self.eligible()
self.assertEqual(1, len(eligible))
self.assertEqual(Decimal("100.00"), Decimal(eligible[0]["amount"]))
self.assertEqual("CNY", eligible[0]["currency"])
self.assertEqual(self.company_a, eligible[0]["payer_company_id"])
self.assertEqual(self.company_b, eligible[0]["payee_company_id"])
self.assertEqual("paired", eligible[0]["pairing"])
# One event, one paired decision for both rows.
self.assertEqual(1, self.event_count())
def test_b_incoming_then_a_outgoing_same_result(self) -> None:
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00", at="2026-01-05T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", at="2026-01-05T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
eligible = self.eligible()
self.assertEqual(1, len(eligible))
self.assertEqual("100.00", eligible[0]["amount"])
# Economic date is the payer's outgoing posting time, import-order free.
self.assertEqual("2026-01-05T10:00:00", eligible[0]["effective_at"])
self.assertEqual(self.company_a, eligible[0]["payer_company_id"])
def test_same_batch_both_sides_pairs_once(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_b, row_a])
eligible = self.eligible()
self.assertEqual(1, len(eligible))
self.assertEqual("paired", self.current(row_a)["pairing"])
# One event, one auto decision carrying both observations.
self.assertEqual(1, self.event_count())
self.assertEqual(1, self.decision_count())
def test_repeated_reconcile_is_idempotent(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
before_decisions = self.decision_count()
stats = matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual(self.decision_count(), before_decisions)
self.assertEqual(2, stats["unchanged"])
self.assertEqual(0, stats["created_events"])
self.assertEqual(0, stats["updated_events"])
class DateWindowTests(MatchingBase):
def test_m1_same_reference_within_3_days(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
at="2026-01-01T09:00:00", reference="R1001",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
at="2026-01-04T15:00:00", reference="R1001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("matched", matching.exposed_status(self.current(row_a)))
candidate = self.connection.execute(
"SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1"
).fetchone()
self.assertEqual("M1", candidate["rule_tier"])
def test_m2_exact_mirror_same_day_without_reference(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("matched", matching.exposed_status(self.current(row_a)))
candidate = self.connection.execute(
"SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1"
).fetchone()
self.assertEqual("M2", candidate["rule_tier"])
def test_over_window_goes_to_review(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
at="2026-01-01T09:00:00", reference="R1001",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
at="2026-01-05T15:00:00", reference="R1001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("needs_review", matching.exposed_status(self.current(row_a)))
self.assertEqual([], self.eligible())
def test_reference_conflict_goes_to_review(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
reference="R-A",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
reference="R-B",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("needs_review", matching.exposed_status(self.current(row_a)))
class AmbiguityTests(MatchingBase):
def test_alias_across_companies_goes_to_review(self) -> None:
# The same observed number is an account alias of TWO companies; the
# counterparty cannot be uniquely decided, so it must go to review.
company_c_account = self._approved_account(self.company_c, "6222000000000005")
master_data.add_alias(
self.connection, self.account_b["id"], "account", "770077007700",
actor=self.admin,
)
master_data.add_alias(
self.connection, company_c_account["id"], "account", "770077007700",
actor=self.admin,
)
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="770077007700", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("needs_review", matching.exposed_status(self.current(row_a)))
self.assertEqual([], self.eligible())
def test_two_same_tier_candidates_go_to_review(self) -> None:
# Two identical incoming observations mirror the outgoing row exactly;
# no deterministic rule may pick one, so the pair goes to review.
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b1 = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
row_b2 = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b1, row_b2])
# The ambiguous side stays in review; the two identical observations
# may not be auto-grabbed by a processing-order tie-break.
self.assertEqual("needs_review", matching.exposed_status(self.current(row_a)))
self.assertEqual("internal_single", matching.exposed_status(self.current(row_b1)))
self.assertEqual("internal_single", matching.exposed_status(self.current(row_b2)))
self.assertEqual([], self.eligible())
def test_different_amounts_never_match(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.01",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("internal_single", matching.exposed_status(self.current(row_a)))
self.assertEqual("internal_single", matching.exposed_status(self.current(row_b)))
self.assertEqual([], self.eligible())
class SameCompanyTests(MatchingBase):
def test_same_company_transfer_excluded_from_intercompany(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000003", expense="50.00",
)
row_b = self.add_row(
self.company_a, own_account="6222000000000003",
cp_account="6222000000000001", income="50.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_a)))
self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_b)))
self.assertEqual([], self.eligible())
# The cash trail is kept: both rows remain claimed by one event.
self.assertEqual(1, self.event_count())
def test_same_company_single_observation_classified_immediately(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000003", expense="50.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_a)))
class UnresolvedAndExternalTests(MatchingBase):
def test_unknown_counterparty_stays_unresolved(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="9999999999999999", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("unresolved", matching.exposed_status(self.current(row_a)))
self.assertEqual([], self.eligible())
unresolved = matching.unresolved_amounts(self.connection, self.company_a)
self.assertEqual(1, len(unresolved))
self.assertEqual("100.00", unresolved[0]["amount"])
def test_internal_single_counts_as_unresolved_amount(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("internal_single", matching.exposed_status(self.current(row_a)))
self.assertEqual([], self.eligible())
unresolved = matching.unresolved_amounts(self.connection, self.company_a)
self.assertEqual(1, len(unresolved))
self.assertEqual("100.00", unresolved[0]["amount"])
def test_admin_confirm_single_becomes_eligible(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
current = self.current(row_a)
result = matching.apply_manual_decision(
self.connection, self._event_of(row_a),
"assign_participant",
reason="对方公司函证确认",
expected_revision=current["revision"],
request_key="assign-1",
actor=self.admin,
participant={"role": "payee", "company_id": self.company_b},
)
self.assertEqual("intercompany", result["classification"])
self.assertTrue(result["locked"])
eligible = self.eligible()
self.assertEqual(1, len(eligible))
self.assertEqual("single", eligible[0]["pairing"])
# The manual confirmation is skipped by auto reconcile.
decisions_before = self.decision_count()
stats = matching.reconcile_rows(self.connection, [row_a])
self.assertEqual(1, stats["skipped_locked"])
self.assertEqual(decisions_before, self.decision_count())
def test_mark_external_requires_admin_and_excludes(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="9999999999999999", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
event_id = self._event_of(row_a)
result = matching.apply_manual_decision(
self.connection, event_id, "mark_external",
reason="经核实为外部供应商付款",
expected_revision=self.current(row_a)["revision"],
request_key=None, actor=self.admin,
)
self.assertEqual("external", result["classification"])
self.assertEqual([], self.eligible())
def test_reverse_frees_claims_for_reconcile(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
event_id = self._event_of(row_a)
decisions_before = self.decision_count()
result = matching.apply_manual_decision(
self.connection, event_id, "reverse",
reason="配对依据有误,需要重新匹配",
expected_revision=self.current(row_a)["revision"],
request_key="rev-1", actor=self.admin,
)
self.assertEqual(decisions_before + 1, self.decision_count())
# Claims and current pointer are gone; B-44 sees nothing.
self.assertIsNone(self.connection.execute(
"SELECT 1 FROM transfer_observation_claims WHERE source_row_id = ?",
(row_a,),
).fetchone())
self.assertEqual([], self.eligible())
# A re-run is allowed and re-derives deterministically.
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("matched", matching.exposed_status(self.current(row_a)))
class PersonalTransitTests(MatchingBase):
def _submit_mapping(self, company_id: int, number: str, direction: str) -> int:
mapping = personal_transit.submit_mapping(
self.connection,
account_number=number,
account_name="张个人",
represented_company_id=company_id,
allowed_direction=direction,
effective_from="2026-01-01",
actor=self.admin,
)
approved = personal_transit.review_mapping(
self.connection, mapping["id"], "approve", None, self.admin,
effective_from="2026-01-01",
)
return approved["id"]
def test_personal_mapping_resolves_counterparty(self) -> None:
self._submit_mapping(self.company_b, "880088008800", "incoming")
# A pays to the personal account -> the counterparty represents B.
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="880088008800", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("internal_single", matching.exposed_status(self.current(row_a)))
unresolved = matching.unresolved_amounts(self.connection, self.company_a)
self.assertEqual(1, len(unresolved))
def test_personal_mapping_direction_mismatch_unresolved(self) -> None:
self._submit_mapping(self.company_b, "880088008800", "incoming")
# An outgoing row to the personal account is NOT covered by an
# incoming-only mapping.
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="880088008800", expense="100.00",
)
# reverse direction check: make it incoming via income
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="880088008800", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
matching.reconcile_rows(self.connection, [row_b])
self.assertEqual("unresolved", matching.exposed_status(self.current(row_b)))
def test_unapproved_mapping_never_resolves(self) -> None:
personal_transit.submit_mapping(
self.connection,
account_number="880088008801",
account_name="李个人",
represented_company_id=self.company_b,
allowed_direction="both",
actor=self.admin,
)
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="880088008801", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("unresolved", matching.exposed_status(self.current(row_a)))
def test_mapping_effective_window_and_resubmit_reuse(self) -> None:
mapping = personal_transit.submit_mapping(
self.connection,
account_number="880088008802",
account_name="王个人",
represented_company_id=self.company_b,
allowed_direction="both",
effective_from="2026-03-01",
actor=self.admin,
)
personal_transit.review_mapping(
self.connection, mapping["id"], "approve", None, self.admin,
effective_from="2026-03-01",
)
# Outside the effective window the mapping does not resolve.
row_before = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="880088008802", expense="100.00", at="2026-02-20T10:00:00",
)
matching.reconcile_rows(self.connection, [row_before])
self.assertEqual("unresolved", matching.exposed_status(self.current(row_before)))
# Inside the window it does.
row_inside = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="880088008802", expense="100.00", at="2026-03-05T10:00:00",
)
matching.reconcile_rows(self.connection, [row_inside])
self.assertEqual("internal_single", matching.exposed_status(self.current(row_inside)))
# Returned mappings reopen on resubmission reusing the same row.
returned = personal_transit.submit_mapping(
self.connection,
account_number="880088008803",
account_name="赵个人",
represented_company_id=self.company_b,
allowed_direction="both",
actor=self.admin,
)
personal_transit.review_mapping(
self.connection, returned["id"], "return", "资料待补充", self.admin,
)
resubmitted = personal_transit.submit_mapping(
self.connection,
account_number="8800 8800 8803",
account_name="赵个人",
represented_company_id=self.company_b,
allowed_direction="incoming",
actor=self.admin,
)
self.assertEqual(returned["id"], resubmitted["id"])
self.assertEqual("pending", resubmitted["status"])
class ResolutionTests(MatchingBase):
def test_name_alias_never_resolves_alone(self) -> None:
master_data.add_alias(
self.connection, self.account_b["id"], "name", "乙公司贸易部",
actor=self.admin,
)
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account=None, cp_name="乙公司贸易部", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
self.assertEqual("unresolved", matching.exposed_status(self.current(row_a)))
def test_own_account_conflict_with_upload_account_goes_to_review(self) -> None:
# own_account belongs to company B while the batch is uploaded for A.
row = self.add_row(
self.company_a, own_account="6222000000000002",
cp_account="6222000000000001", expense="100.00",
)
# Simulate an upload account pointing at A.
self.connection.execute(
"""
UPDATE import_batches SET upload_bank_account_id = ?
WHERE id = (SELECT b.id FROM import_batches b
JOIN sheet_batches s ON s.import_batch_id = b.id
JOIN source_rows r ON r.sheet_batch_id = s.id
WHERE r.id = ?)
""",
(self.account_a["id"], row),
)
matching.reconcile_rows(self.connection, [row])
self.assertEqual("needs_review", matching.exposed_status(self.current(row)))
def test_upload_account_fallback_resolves_own(self) -> None:
row = self.add_row(
self.company_a, own_account=None,
cp_account="6222000000000002", expense="100.00",
)
self.connection.execute(
"""
UPDATE import_batches SET upload_bank_account_id = ?
WHERE id = (SELECT b.id FROM import_batches b
JOIN sheet_batches s ON s.import_batch_id = b.id
JOIN source_rows r ON r.sheet_batch_id = s.id
WHERE r.id = ?)
""",
(self.account_a["id"], row),
)
matching.reconcile_rows(self.connection, [row])
self.assertEqual("internal_single", matching.exposed_status(self.current(row)))
def test_alias_mirror_m3_pairs(self) -> None:
# A's own account has an account alias; B's statement references the
# alias, so the mirror is proven through the approved alias (M3).
master_data.add_alias(
self.connection, self.account_a["id"], "account", "770077007700",
actor=self.admin,
)
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", summary="货款",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="770077007700", income="100.00", summary="货款",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("matched", matching.exposed_status(self.current(row_a)))
candidate = self.connection.execute(
"SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1"
).fetchone()
self.assertEqual("M3", candidate["rule_tier"])
class M3EvidenceTests(MatchingBase):
"""M3 requires positive reference or summary evidence; a missing reference
on either side is never treated as agreement (None == None is not equal)."""
def _alias_for_a(self) -> None:
master_data.add_alias(
self.connection, self.account_a["id"], "account", "770077007700",
actor=self.admin,
)
def test_both_references_empty_and_summaries_different_do_not_pair(self) -> None:
self._alias_for_a()
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", summary="货款A",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="770077007700", income="100.00", summary="货款B",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("needs_review", matching.exposed_status(self.current(row_a)))
self.assertEqual("internal_single", matching.exposed_status(self.current(row_b)))
self.assertEqual([], self.eligible())
self.assertIsNone(self.connection.execute(
"SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1"
).fetchone())
def test_single_sided_reference_is_not_reference_equality(self) -> None:
self._alias_for_a()
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", reference="R-ONLY-A",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="770077007700", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("needs_review", matching.exposed_status(self.current(row_a)))
self.assertEqual("internal_single", matching.exposed_status(self.current(row_b)))
self.assertEqual([], self.eligible())
def test_both_references_empty_but_summary_equal_still_pairs_m3(self) -> None:
self._alias_for_a()
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", summary="货款",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="770077007700", income="100.00", summary="货款",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("matched", matching.exposed_status(self.current(row_a)))
candidate = self.connection.execute(
"SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1"
).fetchone()
self.assertEqual("M3", candidate["rule_tier"])
class EligibleViewTests(MatchingBase):
"""``eligible_intercompany_events``: single-sided intercompany events only
enter the B-44 output when locked; paired events are unaffected."""
def _insert_intercompany(self, *, pairing: str, locked: int) -> int:
now = utc_now()
with self.connection:
cursor = self.connection.execute(
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
)
event_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO transfer_match_decisions (
event_id, revision, classification, pairing, amount, currency,
effective_at, mode, rule_version, locked, created_at
) VALUES (?, 1, 'intercompany', ?, '100.00', 'CNY',
'2026-01-05T10:00:00', 'auto', 'transfer-match-v1', ?, ?)
""",
(event_id, pairing, locked, now),
)
decision_id = int(cursor.lastrowid)
for role, company_id in (("payer", self.company_a), ("payee", self.company_b)):
self.connection.execute(
"""
INSERT INTO transfer_decision_participants (
decision_id, role, company_id, bank_account_id,
resolve_method, evidence, created_at
) VALUES (?, ?, ?, NULL, 'own_exact', '{}', ?)
""",
(decision_id, role, company_id, now),
)
self.connection.execute(
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
(event_id, decision_id),
)
return event_id
def test_unlocked_single_intercompany_is_excluded(self) -> None:
self._insert_intercompany(pairing="single", locked=0)
self.assertEqual([], self.eligible())
def test_locked_single_intercompany_is_included(self) -> None:
self._insert_intercompany(pairing="single", locked=1)
eligible = self.eligible()
self.assertEqual(1, len(eligible))
self.assertEqual("single", eligible[0]["pairing"])
self.assertEqual("100.00", eligible[0]["amount"])
def test_paired_intercompany_is_included_regardless_of_lock(self) -> None:
self._insert_intercompany(pairing="paired", locked=0)
self._insert_intercompany(pairing="paired", locked=1)
eligible = self.eligible()
self.assertEqual(2, len(eligible))
self.assertTrue(all(item["pairing"] == "paired" for item in eligible))
class ManualLinkTests(MatchingBase):
def test_manual_link_locks_pair(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
matching.reconcile_rows(self.connection, [row_b])
event_id = self._event_of(row_a)
current_revision = self.current(row_a)["revision"]
result = matching.apply_manual_decision(
self.connection, event_id, "link_rows",
reason="人工核对后确认是同一笔",
expected_revision=current_revision,
request_key="link-1", actor=self.admin,
source_row_ids=[row_a, row_b],
)
self.assertTrue(result["locked"])
self.assertEqual("paired", result["pairing"])
# Auto reconcile never touches locked decisions.
stats = matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual(2, stats["skipped_locked"])
def test_manual_decision_requires_reason(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
with self.assertRaises(matching.MatchInputError):
matching.apply_manual_decision(
self.connection, self._event_of(row_a), "reverse",
reason=" ", expected_revision=None, request_key=None,
actor=self.admin,
)
def test_stale_expected_revision_conflicts(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
event_id = self._event_of(row_a)
with self.assertRaises(matching.MatchConflictError):
matching.apply_manual_decision(
self.connection, event_id, "reverse",
reason="测试", expected_revision=999, request_key=None,
actor=self.admin,
)
class DecimalPrecisionTests(MatchingBase):
def test_equivalent_decimal_strings_match(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.0",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("matched", matching.exposed_status(self.current(row_a)))
def test_cent_difference_never_matches(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.01",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("internal_single", matching.exposed_status(self.current(row_a)))
def test_large_amount_precision(self) -> None:
amount = "99999999999999999999.99"
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense=amount,
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income=amount,
)
matching.reconcile_rows(self.connection, [row_a, row_b])
eligible = self.eligible()
self.assertEqual(1, len(eligible))
self.assertEqual(Decimal(amount), Decimal(eligible[0]["amount"]))
def test_currency_mismatch_never_matches(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00", currency="CNY",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00", currency="USD",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
self.assertEqual("internal_single", matching.exposed_status(self.current(row_a)))
class ProjectionRebuildTests(MatchingBase):
def test_rebuild_matches_current_projection(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
before = [
(item["event_id"], item["decision_id"])
for item in self.connection.execute(
"SELECT * FROM current_transfer_decisions"
).fetchall()
]
claims_before = [
(item["source_row_id"], item["event_id"], item["decision_id"])
for item in self.connection.execute(
"SELECT * FROM transfer_observation_claims"
).fetchall()
]
matching.rebuild_current_projection(self.connection)
after = [
(item["event_id"], item["decision_id"])
for item in self.connection.execute(
"SELECT * FROM current_transfer_decisions"
).fetchall()
]
claims_after = [
(item["source_row_id"], item["event_id"], item["decision_id"])
for item in self.connection.execute(
"SELECT * FROM transfer_observation_claims"
).fetchall()
]
self.assertEqual(sorted(before), sorted(after))
self.assertEqual(sorted(claims_before), sorted(claims_after))
class ConcurrentReconcileTests(MatchingBase):
def test_concurrent_reconcile_creates_one_event(self) -> None:
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
errors: list[Exception] = []
def run() -> None:
db = connect(self.db_path)
try:
matching.reconcile_rows(db, [row_a, row_b])
except Exception as exc: # pragma: no cover
errors.append(exc)
finally:
db.close()
threads = [threading.Thread(target=run) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual([], errors)
self.assertEqual(1, self.event_count())
eligible = self.eligible()
self.assertEqual(1, len(eligible))
claims = self.connection.execute(
"SELECT COUNT(*) AS n FROM transfer_observation_claims"
).fetchone()["n"]
self.assertEqual(2, claims)
if __name__ == "__main__":
unittest.main()
+463
View File
@@ -0,0 +1,463 @@
"""HTTP integration tests for the canonical transfer event APIs (B-43).
Covers the admin event list/detail, manual decisions and reconcile endpoints,
personal transit mapping workflow, upload-account persistence, and the
company-side read scoping with masked counterparty evidence. Uses a real
``ThreadingHTTPServer`` like ``test_server_auth``.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class MatchingApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
cls.company_a = cls._create_company("甲公司", "cashier-a")
cls.company_b = cls._create_company("乙公司", "cashier-b")
cls.company_c = cls._create_company("丙公司", "cashier-c")
cls.cashier_a = cls._login_company("cashier-a", cls.company_a)
cls.cashier_b = cls._login_company("cashier-b", cls.company_b)
cls.cashier_c = cls._login_company("cashier-c", cls.company_c)
cls.account_a = cls._approve_account(cls.company_a, ACCOUNT_A)
cls.account_b = cls._approve_account(cls.company_b, ACCOUNT_B)
cls.account_c = cls._approve_account(cls.company_c, ACCOUNT_C)
# One A<->B matched event and one B<->C matched event.
cls._upload_and_confirm(cls.cashier_a, cls.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, "100.00")])
cls._upload_and_confirm(cls.cashier_b, cls.company_b, [incoming(ACCOUNT_B, ACCOUNT_A, "100.00")])
cls._upload_and_confirm(cls.cashier_b, cls.company_b, [outgoing(ACCOUNT_B, ACCOUNT_C, "200.00")])
cls._upload_and_confirm(cls.cashier_c, cls.company_c, [incoming(ACCOUNT_C, ACCOUNT_B, "200.00")])
@classmethod
def _create_company(cls, name: str, username: str) -> int:
status, _, data = cls.admin.post_json(
"/api/admin/companies",
{"name": name, "username": username},
)
assert status == 200, data
cls.initial_passwords.setdefault(username, as_json(data)["initial_password"])
return as_json(data)["company_id"]
initial_passwords: dict[str, str] = {}
@classmethod
def _login_company(cls, username: str, company_id: int) -> Client:
client = Client("127.0.0.1", cls.port)
initial = cls.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
@classmethod
def _approve_account(cls, company_id: int, number: str) -> int:
# Build the account through company submission + admin review.
company_client = {
cls.company_a: cls.cashier_a,
cls.company_b: cls.cashier_b,
cls.company_c: cls.cashier_c,
}[company_id]
status, _, data = company_client.post_json(
"/api/company/accounts",
{"bank_name": "中信银行", "account_type": "基本户",
"account_number": number, "start_date": "2026-01-01"},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = cls.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "测试启用",
"effective_from": "2026-01-01"},
)
assert status == 200, data
return account_id
@classmethod
def _upload_and_confirm(cls, client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = cls.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
assert status == 200, data
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def events(self, client) -> list[dict]:
status, _, data = client.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
return as_json(data)["events"]
def company_events(self, client) -> list[dict]:
status, _, data = client.get("/api/company/transfer-events")
self.assertEqual(200, status, data)
return as_json(data)["events"]
# ------------------------------------------------------------------
# Admin event views
# ------------------------------------------------------------------
def test_admin_lists_matched_events(self) -> None:
events = self.events(self.admin)
matched = [e for e in events if e["status"] == "matched"]
self.assertGreaterEqual(len(matched), 2)
by_amount = {e["amount"]: e for e in matched}
self.assertEqual(self.company_a, by_amount["100.00"]["payer_company_id"])
self.assertEqual(self.company_b, by_amount["100.00"]["payee_company_id"])
self.assertEqual("paired", by_amount["100.00"]["pairing"])
self.assertEqual(2, by_amount["100.00"]["evidence_count"])
def test_admin_event_detail_has_history_and_observations(self) -> None:
events = self.events(self.admin)
event = next(e for e in events if e["status"] == "matched")
status, _, data = self.admin.get(f"/api/admin/transfer-events/{event['event_id']}")
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
self.assertEqual("matched", detail["status"])
self.assertEqual(2, len(detail["observations"]))
self.assertTrue(detail["history"])
self.assertTrue(detail["candidates"])
def test_admin_match_exceptions(self) -> None:
status, _, data = self.admin.get("/api/admin/match-exceptions")
self.assertEqual(200, status, data)
self.assertIsInstance(as_json(data)["exceptions"], list)
# ------------------------------------------------------------------
# Admin manual decisions and reconcile via API
# ------------------------------------------------------------------
def test_admin_reconcile_by_batch_is_idempotent(self) -> None:
# Re-running reconcile on an already settled batch changes nothing.
status, _, data = self.admin.post_json(
"/api/admin/transfer-events/reconcile", {"batch_id": 1}
)
self.assertEqual(200, status, data)
result = as_json(data)["matching"]
self.assertEqual(0, result["created_events"])
self.assertEqual(0, result["updated_events"])
def test_admin_confirm_single_and_reverse_via_api(self) -> None:
# A uploads its side only -> internal_single.
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(self.company_a)},
"单边.xlsx", workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "300.00", "2026-02-01 10:00:00")]),
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
self.assertEqual(200, status, data)
events = self.events(self.admin)
single = next(e for e in events if e["status"] == "internal_single" and e["amount"] == "300.00")
detail = self._admin_detail(single["event_id"])
revision = detail["revision"]
# Company users cannot write manual decisions.
status, _, data = self.cashier_a.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "reverse", "reason": "不应允许", "expected_revision": revision},
)
self.assertEqual(403, status, data)
# Admin confirms the single event based on evidence.
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "assign_participant", "reason": "函证确认",
"expected_revision": revision, "request_key": "confirm-300",
"participant": {"role": "payee", "company_id": self.company_b}},
)
self.assertEqual(200, status, data)
decision = as_json(data)["decision"]
self.assertEqual("intercompany", decision["classification"])
self.assertTrue(decision["locked"])
# Replaying the same request key is idempotent.
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "assign_participant", "reason": "函证确认",
"expected_revision": revision, "request_key": "confirm-300",
"participant": {"role": "payee", "company_id": self.company_b}},
)
self.assertEqual(200, status, data)
# Stale revision conflicts.
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{"action": "reverse", "reason": "撤销", "expected_revision": revision},
)
self.assertEqual(409, status, data)
def _admin_detail(self, event_id: int) -> dict:
status, _, data = self.admin.get(f"/api/admin/transfer-events/{event_id}")
self.assertEqual(200, status, data)
return as_json(data)["event"]
# ------------------------------------------------------------------
# Company scope and masking
# ------------------------------------------------------------------
def test_company_sees_only_events_it_participates_in(self) -> None:
events = self.company_events(self.cashier_a)
self.assertTrue(events)
for event in events:
self.assertEqual(self.company_a, event["own_company_id"])
matched = [e for e in events if e["status"] == "matched"]
self.assertTrue(matched)
self.assertEqual(
{"乙公司"},
{e["counterparty_company_name"] for e in matched},
)
# B<->C event is invisible to A.
status, _, data = self.cashier_a.get("/api/company/transfer-events")
bc_events = [
e for e in as_json(data)["events"]
if e["counterparty_company_name"] == "丙公司"
]
self.assertEqual([], bc_events)
def test_company_detail_masks_counterparty_and_own_rows_only(self) -> None:
events = self.company_events(self.cashier_a)
event = next(e for e in events if e["status"] == "matched")
status, _, data = self.cashier_a.get(
f"/api/company/transfer-events/{event['event_id']}"
)
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
self.assertEqual("乙公司", detail["counterparty"]["company_name"])
self.assertTrue(detail["counterparty"].get("account_number_masked", "").startswith("****"))
# The counterparty's full account number never leaves the server.
self.assertNotIn(ACCOUNT_B, json.dumps(detail, ensure_ascii=False))
# Only A's own observation rows are exposed.
self.assertEqual(1, len(detail["observations"]))
observation = detail["observations"][0]
self.assertTrue(observation["own_account_masked"].startswith("****"))
self.assertEqual(self.company_a, observation.get("batch_company_id"))
def test_company_cannot_read_event_it_does_not_participate_in(self) -> None:
# The B<->C event id comes from B's own list; A probing it returns 404.
status, _, data = self.cashier_b.get("/api/company/transfer-events")
bc_event_ids = [
e["event_id"] for e in as_json(data)["events"]
if e["counterparty_company_name"] == "丙公司"
]
self.assertTrue(bc_event_ids)
for event_id in bc_event_ids:
status, _, data = self.cashier_a.get(f"/api/company/transfer-events/{event_id}")
self.assertEqual(404, status, data)
status, _, data = self.cashier_a.get(f"/api/admin/transfer-events/{event_id}")
self.assertEqual(403, status, data)
def test_company_match_exceptions_scoped_to_own_company(self) -> None:
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(self.company_a)},
"未决.xlsx",
workbook_bytes([outgoing(ACCOUNT_A, "9999999999999999", "77.00", "2026-02-05 10:00:00")]),
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
self.assertEqual(200, status, data)
status, _, data = self.cashier_a.get("/api/company/match-exceptions")
self.assertEqual(200, status, data)
mine = [e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"]
self.assertEqual(1, len(mine))
self.assertEqual("unresolved", mine[0]["status"])
status, _, data = self.cashier_b.get("/api/company/match-exceptions")
self.assertEqual(200, status, data)
self.assertEqual(
[],
[e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"],
)
def test_company_forbidden_on_admin_event_endpoints(self) -> None:
for call in (
lambda: self.cashier_a.get("/api/admin/transfer-events"),
lambda: self.cashier_a.get("/api/admin/match-exceptions"),
lambda: self.cashier_a.post_json(
"/api/admin/transfer-events/reconcile", {"batch_id": 1}
),
lambda: self.cashier_a.post_json(
"/api/admin/personal-transit-mappings", {}
),
lambda: self.cashier_a.get("/api/admin/personal-transit-mappings"),
):
status, _, data = call()
self.assertEqual(403, status, data)
# ------------------------------------------------------------------
# Personal transit mappings
# ------------------------------------------------------------------
def test_personal_transit_mapping_workflow(self) -> None:
status, _, data = self.admin.post_json(
"/api/admin/personal-transit-mappings",
{"account_number": "880088008800", "account_name": "张个人",
"represented_company_id": self.company_b,
"allowed_direction": "incoming", "effective_from": "2026-01-01"},
)
self.assertEqual(200, status, data)
mapping_id = as_json(data)["mapping"]["id"]
self.assertEqual("pending", as_json(data)["mapping"]["status"])
status, _, data = self.admin.post_json(
f"/api/admin/personal-transit-mappings/{mapping_id}/review",
{"decision": "approve", "reason": "资料齐全", "effective_from": "2026-01-01"},
)
self.assertEqual(200, status, data)
self.assertEqual("active", as_json(data)["mapping"]["status"])
status, _, data = self.admin.get("/api/admin/personal-transit-mappings")
self.assertEqual(200, status, data)
mappings = as_json(data)["mappings"]
self.assertTrue(any(m["id"] == mapping_id for m in mappings))
# Duplicate account number conflicts.
status, _, data = self.admin.post_json(
"/api/admin/personal-transit-mappings",
{"account_number": "880088008800", "account_name": "张个人",
"represented_company_id": self.company_b, "allowed_direction": "both"},
)
self.assertEqual(409, status, data)
# ------------------------------------------------------------------
# Upload account persistence
# ------------------------------------------------------------------
def test_upload_persists_approved_bank_account(self) -> None:
content = workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "55.00", "2026-03-01 10:00:00")])
status, _, data = self.cashier_a.post_multipart(
"/api/parse", {"bank_account_id": str(self.account_a)},
"带账户.xlsx", content,
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
connection = connect(self.db_path)
try:
row = connection.execute(
"SELECT upload_bank_account_id FROM import_batches WHERE id = ?",
(batch_id,),
).fetchone()
finally:
connection.close()
self.assertEqual(self.account_a, row["upload_bank_account_id"])
if __name__ == "__main__":
unittest.main()
+67 -1
View File
@@ -1,10 +1,19 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
import io
from pathlib import Path from pathlib import Path
import tempfile
import unittest import unittest
from bank_importer.parser import UnknownTemplateError, detect_header, parse_directory from openpyxl import Workbook
from bank_importer.parser import (
UnknownTemplateError,
analyze_workbook,
detect_header,
parse_directory,
)
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -83,5 +92,62 @@ class StatementParserTests(unittest.TestCase):
self.assertIn("日期、金额、备注", message) self.assertIn("日期、金额、备注", message)
class SheetResultTests(unittest.TestCase):
"""Per-worksheet outcomes: parsed / exception / ignored with evidence."""
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
def _write(self, sheets) -> Path:
workbook = Workbook()
workbook.remove(workbook.active)
for name, rows in sheets.items():
worksheet = workbook.create_sheet(name)
for row in rows:
worksheet.append(row)
path = Path(self.temp_dir.name) / "workbook.xlsx"
workbook.save(path)
return path
def test_every_sheet_has_an_independent_result(self) -> None:
path = self._write(
{
"正常": [
["客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)", "余额", "对方账号"],
["6228480000000000", "测试", "2026-01-05 10:00:00", "100.00", "", "99900.00", "1002003004"],
],
"未知": [["日期", "金额", "备注"], ["2026-01-01", "100", "x"]],
"空表": [],
}
)
results = analyze_workbook(path)
by_name = {result.sheet_name: result for result in results}
self.assertEqual({"正常", "未知", "空表"}, set(by_name))
self.assertEqual("parsed", by_name["正常"].outcome)
self.assertIsNotNone(by_name["正常"].batch)
self.assertEqual("exception", by_name["未知"].outcome)
self.assertIn("未识别到受支持的银行表头", by_name["未知"].message)
self.assertGreaterEqual(by_name["未知"].scanned_rows, 2)
self.assertTrue(any("日期、金额、备注" in row for row in by_name["未知"].candidate_headers))
self.assertEqual("ignored", by_name["空表"].outcome)
self.assertEqual(0, by_name["空表"].scanned_rows)
def test_single_cell_rows_are_candidate_evidence(self) -> None:
path = self._write({"候选": [["只有一个单元格"], ["又一格"]]})
(result,) = analyze_workbook(path)
self.assertEqual("exception", result.outcome)
self.assertEqual(2, result.scanned_rows)
self.assertTrue(result.candidate_headers)
self.assertIn("只有一个单元格", "".join(result.candidate_headers))
def test_messages_never_contain_stored_filename(self) -> None:
path = self._write({"流水": [["日期", "金额", "备注"]]})
(result,) = analyze_workbook(path)
self.assertEqual("exception", result.outcome)
self.assertNotIn(path.name, result.message)
self.assertNotIn(str(path), result.message)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+547
View File
@@ -0,0 +1,547 @@
from __future__ import annotations
from decimal import Decimal
import hashlib
import sqlite3
import tempfile
from pathlib import Path
import unittest
from openpyxl import Workbook
from bank_importer.db import applied_versions, connect, migrate, rollback, utc_now
from bank_importer.importing import import_statement
ROOT = Path(__file__).resolve().parents[1]
SAMPLES = ROOT / "流水模板"
SAMPLE_FILE = SAMPLES / "中国建设银行账户流水.xls"
class PersistenceTestCase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
def import_sample(self) -> object:
return import_statement(
self.connection,
self.storage,
SAMPLE_FILE.name,
SAMPLE_FILE.read_bytes(),
)
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
row["name"]
for row in self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
)
}
for table in (
"companies",
"bank_accounts",
"account_aliases",
"master_data_changes",
"source_files",
"import_batches",
"sheet_batches",
"sheet_reviews",
"source_rows",
"import_exceptions",
"users",
"sessions",
"login_attempts",
"audit_log",
"personal_transit_mappings",
"canonical_transfer_events",
"transfer_match_decisions",
"transfer_decision_observations",
"transfer_decision_participants",
"transfer_match_candidates",
"current_transfer_decisions",
"transfer_observation_claims",
"manual_records",
"manual_record_decisions",
"current_manual_record_decisions",
"ledger_events",
"ledger_event_revisions",
"current_ledger_event_revisions",
"ledger_event_bank_sources",
"ledger_event_manual_sources",
"ledger_subject_suggestions",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
row_count = self.connection.execute(
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"],
)
self.assertEqual(
"parsed",
self.connection.execute(
"SELECT status FROM import_batches LIMIT 1"
).fetchone()["status"],
)
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'"
).fetchone()
self.assertIsNone(remaining)
ledger_remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
).fetchone()
self.assertIsNone(ledger_remaining)
def test_event_layer_views_exist_after_migration(self) -> None:
view = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'view' AND name = 'eligible_intercompany_events'"
).fetchone()
self.assertIsNotNone(view)
def test_ledger_layer_views_exist_after_migration(self) -> None:
view = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'view' AND name = 'eligible_position_events'"
).fetchone()
self.assertIsNotNone(view)
table = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
).fetchone()
self.assertIsNotNone(table)
def test_ledger_revision_log_is_immutable(self) -> None:
with self.connection:
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(utc_now(), utc_now()),
)
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
(utc_now(), utc_now()),
)
company_a, company_b = [
row["id"]
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
]
self.connection.execute(
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
(utc_now(),),
)
event_id = self.connection.execute(
"SELECT id FROM ledger_events LIMIT 1"
).fetchone()["id"]
self.connection.execute(
"""
INSERT INTO ledger_event_revisions (
ledger_event_id, revision, state, effective_at, amount,
amount_scale, currency, payer_company_id, payee_company_id,
source_kind, posting_kind, created_at
) VALUES (?, 1, 'pending_subject', '2026-01-01T00:00:00', '1.00', 2,
'CNY', ?, ?, 'bank', 'normal', ?)
""",
(event_id, company_a, company_b, utc_now()),
)
for statement in (
"UPDATE ledger_event_revisions SET amount = '9.99'",
"DELETE FROM ledger_event_revisions",
"UPDATE ledger_events SET created_at = '2020-01-01T00:00:00'",
"DELETE FROM ledger_events",
):
with self.subTest(statement=statement):
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(statement)
self.connection.rollback()
def test_manual_record_facts_are_immutable(self) -> None:
with self.connection:
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(utc_now(), utc_now()),
)
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
(utc_now(), utc_now()),
)
company_a, company_b = [
row["id"]
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
]
self.connection.execute(
"""
INSERT INTO manual_records (
company_id, counterparty_company_id, occurred_at, direction,
amount, amount_scale, currency, funding_source, requested_subject,
request_key, created_at
) VALUES (?, ?, '2026-01-01T00:00:00', 'incoming', '1.00', 2,
'CNY', 'other', 'receivable', 'k1', ?)
""",
(company_a, company_b, utc_now()),
)
for statement in (
"UPDATE manual_records SET amount = '9.99'",
"DELETE FROM manual_records",
):
with self.subTest(statement=statement):
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(statement)
self.connection.rollback()
def test_decision_log_immutability_triggers(self) -> None:
self.import_sample()
source_row_id = self.connection.execute(
"SELECT id FROM source_rows LIMIT 1"
).fetchone()["id"]
with self.connection:
self.connection.execute(
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
(utc_now(),),
)
event_id = self.connection.execute(
"SELECT id FROM canonical_transfer_events LIMIT 1"
).fetchone()["id"]
self.connection.execute(
"""
INSERT INTO transfer_match_decisions (
event_id, revision, classification, pairing, mode, rule_version,
locked, created_at
) VALUES (?, 1, 'unresolved', 'not_applicable', 'auto', 'transfer-match-v1', 0, ?)
""",
(event_id, utc_now()),
)
decision_id = self.connection.execute(
"SELECT id FROM transfer_match_decisions LIMIT 1"
).fetchone()["id"]
self.connection.execute(
"""
INSERT INTO transfer_match_candidates (
decision_id, source_row_id, rule_tier, rule_version, created_at
) VALUES (?, ?, 'R1', 'transfer-match-v1', ?)
""",
(decision_id, source_row_id, utc_now()),
)
for statement in (
"UPDATE transfer_match_decisions SET classification = 'external'",
"DELETE FROM transfer_match_decisions",
"UPDATE transfer_match_candidates SET rule_tier = 'M1'",
"DELETE FROM transfer_match_candidates",
"DELETE FROM canonical_transfer_events",
):
with self.subTest(statement=statement):
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(statement)
self.connection.rollback()
def test_observation_claims_source_row_is_unique(self) -> None:
self.import_sample()
source_row_id = self.connection.execute(
"SELECT id FROM source_rows LIMIT 1"
).fetchone()["id"]
with self.connection:
self.connection.execute(
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
(utc_now(),),
)
event_id = self.connection.execute(
"SELECT id FROM canonical_transfer_events LIMIT 1"
).fetchone()["id"]
self.connection.execute(
"""
INSERT INTO transfer_match_decisions (
event_id, revision, classification, pairing, mode, locked, created_at
) VALUES (?, 1, 'unresolved', 'not_applicable', 'auto', 0, ?)
""",
(event_id, utc_now()),
)
decision_id = self.connection.execute(
"SELECT id FROM transfer_match_decisions LIMIT 1"
).fetchone()["id"]
self.connection.execute(
"""
INSERT INTO transfer_observation_claims (source_row_id, event_id, decision_id)
VALUES (?, ?, ?)
""",
(source_row_id, event_id, decision_id),
)
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(
"""
INSERT INTO transfer_observation_claims (source_row_id, event_id, decision_id)
VALUES (?, ?, ?)
""",
(source_row_id, event_id, decision_id),
)
self.connection.rollback()
class ImportPersistenceTests(PersistenceTestCase):
def test_import_persists_batch_sheets_and_rows(self) -> None:
result = self.import_sample()
self.assertEqual("parsed", result.status)
batch = self.connection.execute(
"SELECT status FROM import_batches WHERE id = ?", (result.batch_id,)
).fetchone()
self.assertEqual("parsed", batch["status"])
sheets = self.connection.execute(
"SELECT * FROM sheet_batches WHERE import_batch_id = ?", (result.batch_id,)
).fetchall()
self.assertEqual(1, len(sheets))
self.assertEqual("中国建设银行", sheets[0]["bank_name"])
self.assertEqual("ccb-account-detail-v1", sheets[0]["template_id"])
self.assertEqual(1, sheets[0]["template_version"])
rows = self.connection.execute(
"SELECT * FROM source_rows WHERE sheet_batch_id = ?", (sheets[0]["id"],)
).fetchall()
self.assertEqual(sheets[0]["transaction_count"], len(rows))
def test_amounts_keep_decimal_precision(self) -> None:
result = self.import_sample()
rows = self.connection.execute(
"""
SELECT income, expense FROM source_rows
WHERE sheet_batch_id IN (
SELECT id FROM sheet_batches WHERE import_batch_id = ?
)
""",
(result.batch_id,),
).fetchall()
self.assertTrue(rows)
for row in rows:
self.assertEqual(row["income"], str(Decimal(row["income"])))
self.assertEqual(row["expense"], str(Decimal(row["expense"])))
def test_every_row_traces_back_to_file_sheet_and_template(self) -> None:
result = self.import_sample()
row = self.connection.execute(
"""
SELECT r.source_row, s.sheet_name, s.template_id, s.template_version, f.sha256
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_batch_id
JOIN source_files f ON f.id = b.source_file_id
WHERE b.id = ?
LIMIT 1
""",
(result.batch_id,),
).fetchone()
self.assertIsNotNone(row)
self.assertEqual(
hashlib.sha256(SAMPLE_FILE.read_bytes()).hexdigest(), row["sha256"]
)
self.assertEqual("ccb-account-detail-v1", row["template_id"])
self.assertEqual(1, row["template_version"])
self.assertGreater(row["source_row"], 0)
self.assertTrue(row["sheet_name"])
def test_source_file_is_stored_immutably_by_content_hash(self) -> None:
self.import_sample()
stored = self.connection.execute(
"SELECT storage_path FROM source_files"
).fetchone()
stored_path = Path(stored["storage_path"])
self.assertTrue(stored_path.is_file())
self.assertEqual(SAMPLE_FILE.read_bytes(), stored_path.read_bytes())
self.assertIn(hashlib.sha256(SAMPLE_FILE.read_bytes()).hexdigest(), stored_path.name)
def test_data_survives_reconnect(self) -> None:
result = self.import_sample()
self.connection.close()
reopened = connect(self.db_path)
self.addCleanup(reopened.close)
batch = reopened.execute(
"SELECT status FROM import_batches WHERE id = ?", (result.batch_id,)
).fetchone()
self.assertEqual("parsed", batch["status"])
files = reopened.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
self.assertEqual(1, files["n"])
rows = reopened.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()
self.assertGreater(rows["n"], 0)
class IdempotencyTests(PersistenceTestCase):
def test_duplicate_upload_creates_no_second_facts(self) -> None:
first = self.import_sample()
second = self.import_sample()
self.assertEqual("parsed", first.status)
self.assertEqual("duplicate", second.status)
self.assertEqual(first.batch_id, second.batch_id)
self.assertTrue(second.duplicate_same_company)
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
self.assertEqual(1, files["n"])
sheets = self.connection.execute("SELECT COUNT(*) AS n FROM sheet_batches").fetchone()
self.assertEqual(1, sheets["n"])
rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()
self.assertEqual(first.batches[0].transactions.__len__(), rows["n"])
duplicate = self.connection.execute(
"SELECT status, duplicate_of_id FROM import_batches WHERE status = 'duplicate'"
).fetchone()
self.assertEqual(first.batch_id, duplicate["duplicate_of_id"])
def test_cross_company_duplicate_never_returns_other_companys_batch(self) -> None:
now = utc_now()
with self.connection:
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(now, now),
)
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
(now, now),
)
company_a, company_b = [
row["id"]
for row in self.connection.execute("SELECT id FROM companies ORDER BY id").fetchall()
]
first = import_statement(
self.connection, self.storage, SAMPLE_FILE.name,
SAMPLE_FILE.read_bytes(), company_id=company_a,
)
self.assertEqual("parsed", first.status)
second = import_statement(
self.connection, self.storage, SAMPLE_FILE.name,
SAMPLE_FILE.read_bytes(), company_id=company_b,
)
self.assertEqual("duplicate", second.status)
self.assertFalse(second.duplicate_same_company)
# The returned batch id is the uploader's own duplicate batch, never
# the other company's original batch.
self.assertNotEqual(first.batch_id, second.batch_id)
self.assertNotEqual(second.batch_id, first.batch_id)
own = self.connection.execute(
"SELECT company_id FROM import_batches WHERE id = ?", (second.batch_id,)
).fetchone()
self.assertEqual(company_b, own["company_id"])
def test_repeated_upload_under_a_different_filename_is_still_duplicate(self) -> None:
first = self.import_sample()
second = import_statement(
self.connection,
self.storage,
"改名后的流水.xls",
SAMPLE_FILE.read_bytes(),
)
self.assertEqual("duplicate", second.status)
self.assertEqual(first.batch_id, second.batch_id)
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
self.assertEqual(1, files["n"])
def test_immutability_triggers_block_updates_and_deletes(self) -> None:
self.import_sample()
for statement in (
"UPDATE source_rows SET income = '0'",
"DELETE FROM source_rows",
"UPDATE source_files SET sha256 = 'x'",
"DELETE FROM source_files",
"UPDATE sheet_batches SET bank_name = 'x'",
"DELETE FROM sheet_batches",
):
with self.subTest(statement=statement):
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(statement)
self.connection.rollback()
def test_row_level_unique_constraint_blocks_duplicate_rows(self) -> None:
self.import_sample()
row = self.connection.execute(
"SELECT sheet_batch_id, source_row FROM source_rows LIMIT 1"
).fetchone()
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense, created_at
) VALUES (?, ?, '2026-01-01T00:00:00', '1', '0', '2026-01-01T00:00:00Z')
""",
(row["sheet_batch_id"], row["source_row"]),
)
self.connection.rollback()
class FailedImportTests(PersistenceTestCase):
def _unknown_template_bytes(self) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "流水"
sheet.append(["日期", "金额", "备注"])
sheet.append(["2026-01-01", "100.00", "测试"])
target = Path(self.temp_dir.name) / "unknown.xlsx"
workbook.save(target)
return target.read_bytes()
def test_parse_failure_keeps_exception_batch_without_rows(self) -> None:
result = import_statement(
self.connection, self.storage, "未知银行.xlsx", self._unknown_template_bytes()
)
self.assertEqual("exception", result.status)
self.assertIsNotNone(result.message)
self.assertIn("未知银行.xlsx", result.message)
self.assertNotIn(result.sha256, result.message)
batch = self.connection.execute(
"SELECT status FROM import_batches WHERE id = ?", (result.batch_id,)
).fetchone()
self.assertEqual("exception", batch["status"])
exceptions = self.connection.execute(
"SELECT * FROM import_exceptions WHERE import_batch_id = ?",
(result.batch_id,),
).fetchall()
self.assertEqual(1, len(exceptions))
self.assertEqual("parse", exceptions[0]["stage"])
rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()
self.assertEqual(0, rows["n"])
# The source file is still preserved as evidence for later diagnosis.
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
self.assertEqual(1, files["n"])
def test_reupload_after_failure_is_tracked_as_duplicate(self) -> None:
content = self._unknown_template_bytes()
first = import_statement(self.connection, self.storage, "未知银行.xlsx", content)
second = import_statement(self.connection, self.storage, "未知银行.xlsx", content)
self.assertEqual("exception", first.status)
self.assertEqual("duplicate", second.status)
self.assertEqual(first.batch_id, second.batch_id)
if __name__ == "__main__":
unittest.main()
+430
View File
@@ -0,0 +1,430 @@
"""B-44 position aggregation tests: conservation, repayments, reversals across
cutoffs, currency isolation, Decimal precision, unresolved gross, normal
balance exceptions and keyset pagination."""
from __future__ import annotations
from decimal import Decimal, getcontext
import unittest
from bank_importer import ledger_events, manual_records, positions, subjects
from ledger_helpers import LedgerBase
def confirm(connection, event_id: int, perspective: int, subject: str, key: str, actor):
return subjects.confirm_subject(
connection, event_id,
perspective_company_id=perspective, subject_code=subject,
reason="审核确认", expected_revision=1, request_key=key, actor=actor,
)
class ConservationTests(LedgerBase):
def setUp(self) -> None:
super().setUp()
getcontext().prec = 50
def _confirm_all(self, subject: str = "other_receivable"):
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(
self.connection, event["id"],
revision["payer_company_id"], subject, f"subj-{event['id']}",
self.admin,
)
def test_both_perspectives_mirror_exactly(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_a, self.company_b, "30.00", at="2026-01-10T10:00:00")
self.pair(self.company_a, self.company_b, "70.00", at="2026-02-05T10:00:00")
self._confirm_all()
pair = positions.pair_detail(
self.connection, self.company_a, self.company_b,
from_="2026-01-01", cutoff="2026-07-31",
)
item = pair["items"][0]
self.assertTrue(item["conservation"]["opposite"])
self.assertTrue(item["conservation"]["abs_equal"])
self.assertEqual(Decimal("200.00"), Decimal(item["a"]["result"]["signed_amount"]))
self.assertEqual(Decimal("-200.00"), Decimal(item["b"]["result"]["signed_amount"]))
def test_repayment_reduces_the_original_balance(self) -> None:
# A lends B 100 (A pays) then B repays 40 (B pays).
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_b, self.company_a, "40.00", at="2026-03-01T10:00:00",
summary="还款", purpose="归还借款")
self._confirm_all()
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in balances["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(Decimal("60.00"), Decimal(item["result"]["signed_amount"]))
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["debit"]))
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["credit"]))
else:
self.assertEqual(Decimal("-60.00"), Decimal(item["result"]["signed_amount"]))
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["debit"]))
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["credit"]))
def test_over_repayment_flags_normal_balance_exception(self) -> None:
# A lends B 100, then B repays 150 while A books the repayment against
# its receivable: A's receivable and B's payable both go negative.
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_b, self.company_a, "150.00", at="2026-03-01T10:00:00",
summary="还款", purpose="归还借款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
# Book every event from A's perspective on the receivable side so
# the over-repayment produces abnormal balances.
confirm(
self.connection, event["id"], self.company_a,
"other_receivable", f"k-{event['id']}", self.admin,
)
pair = positions.pair_detail(
self.connection, self.company_a, self.company_b,
from_="2026-01-01", cutoff="2026-07-31",
)
item = pair["items"][0]
self.assertTrue(item["normal_balance_exception"])
self.assertIn("other_receivable", item["normal_balance_exception"])
self.assertIn("other_payable", item["normal_balance_exception"])
class CutoffAndReversalTests(LedgerBase):
def _loan(self) -> int:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
confirm(self.connection, event_id, self.company_a, "other_receivable", "k1", self.admin)
return event_id
def test_cutoff_before_reversal_keeps_original_impact(self) -> None:
event_id = self._loan()
# Reversal dated after the original event.
ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
effective_at="2026-06-15T00:00:00", reason="冲销", actor=self.admin,
)
before = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-05-31"
)
after = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in before["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
for item in after["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(Decimal("0"), Decimal(item["result"]["signed_amount"]))
def test_from_gt_cutoff_is_rejected(self) -> None:
with self.assertRaises(positions.PositionInputError):
positions.validate_window("2026-08-01", "2026-07-31")
def test_events_before_from_are_excluded_from_period(self) -> None:
self._loan()
balances = positions.company_balances(
self.connection, from_="2026-06-01", cutoff="2026-07-31"
)
self.assertEqual([], balances["items"])
def test_reversal_uses_approved_effective_date(self) -> None:
event_id = self._loan()
ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
effective_at="2026-08-01T00:00:00", reason="未来冲销", actor=self.admin,
)
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in balances["items"]:
if item["company_id"] == self.company_a:
# The future reversal does not rewrite the earlier cutoff.
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
class CurrencyAndPrecisionTests(LedgerBase):
def test_currencies_never_mix(self) -> None:
self.pair(self.company_a, self.company_b, "100.00", currency="CNY")
self.pair(self.company_a, self.company_b, "50.00", at="2026-01-20T10:00:00",
currency="USD")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
events = self.ledger_events()
for event in events:
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
by_company = {}
for item in balances["items"]:
by_company.setdefault(item["company_id"], {})[item["currency"]] = item["result"]
self.assertEqual(Decimal("100.00"), Decimal(by_company[self.company_a]["CNY"]["signed_amount"]))
self.assertEqual(Decimal("50.00"), Decimal(by_company[self.company_a]["USD"]["signed_amount"]))
def test_decimal_precision_is_preserved(self) -> None:
self.pair(self.company_a, self.company_b, "0.01")
self.pair(self.company_a, self.company_b, "12345.6789", at="2026-01-11T10:00:00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in balances["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(
Decimal("12345.6889"), Decimal(item["result"]["signed_amount"])
)
# The stored string keeps every decimal place.
self.assertIn("12345.6889", item["result"]["signed_amount"])
def test_manual_record_preserves_amount_scale(self) -> None:
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-02-01T09:00:00", direction="incoming",
amount="12.3400", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-prec",
actor=self.admin,
)
manual_records.decide(
self.connection, record["id"], "approve_new",
reason="确认", expected_decision_id=record["decision_id"],
request_key="dec-prec", actor=self.admin,
)
stored = self.connection.execute(
"SELECT amount, amount_scale FROM eligible_position_events"
).fetchone()
self.assertEqual("12.3400", stored["amount"])
self.assertEqual(4, stored["amount_scale"])
class UnresolvedTests(LedgerBase):
def test_unresolved_gross_never_nets(self) -> None:
# Two pending_subject events of opposite economic signs must sum their
# absolute values, never cancel.
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_a, self.company_b, "40.00", at="2026-01-20T10:00:00",
summary="还款", purpose="归还借款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
unresolved = positions.unresolved_for_company(
self.connection, self.company_a, "2026-07-31"
)
self.assertEqual(2, unresolved["count"])
self.assertEqual(
Decimal("140.00"), Decimal(unresolved["gross_amount"])
)
self.assertEqual(
Decimal("140.00"),
Decimal(unresolved["by_reason"]["subject_review"]["gross_amount"]),
)
def test_unmatched_single_reason_bucket(self) -> None:
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="88.00",
)
from bank_importer import matching
matching.reconcile_rows(self.connection, [1])
unresolved = positions.unresolved_for_company(
self.connection, self.company_a, "2026-07-31"
)
self.assertEqual(1, unresolved["count"])
self.assertEqual(
Decimal("88.00"),
Decimal(unresolved["by_reason"]["unmatched_single"]["gross_amount"]),
)
def test_pending_manual_only_counts_for_submitting_company(self) -> None:
manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-a",
actor=self.admin,
)
a_unresolved = positions.unresolved_for_company(
self.connection, self.company_a, "2026-07-31"
)
b_unresolved = positions.unresolved_for_company(
self.connection, self.company_b, "2026-07-31"
)
self.assertEqual(1, a_unresolved["count"])
self.assertEqual(
Decimal("50.00"), Decimal(a_unresolved["by_reason"]["manual_pending"]["gross_amount"])
)
# The counterparty never sees the unapproved declaration.
self.assertEqual(0, b_unresolved["count"])
class PaginationTests(LedgerBase):
def test_subject_filter_matches_stored_and_mirror_subjects(self) -> None:
# A books "receivable" from its own perspective; B sees the mirror
# "payable". Filtering by B's mirror subject must still find the event.
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
confirm(self.connection, event_id, self.company_a, "receivable", "k-mirror",
self.admin)
stored = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
subject="receivable",
)
self.assertTrue(any(
item["ledger_event_id"] == event_id for item in stored["items"]
))
# Company B filters by its own perspective: the stored "receivable" is
# the mirror of "payable", so it must be included.
mirror = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
subject="payable", viewer_company_id=self.company_b,
)
self.assertTrue(any(
item["ledger_event_id"] == event_id for item in mirror["items"]
), mirror)
for item in mirror["items"]:
if item["ledger_event_id"] == event_id:
self.assertEqual("payable", item["own_subject"])
def test_subject_filter_without_match_returns_empty(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
confirm(self.connection, event_id, self.company_a, "receivable", "k-none",
self.admin)
other = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
subject="other_receivable",
)
self.assertEqual([], other["items"])
def test_pagination_is_stable_and_complete(self) -> None:
for index in range(7):
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
seen: list[int] = []
cursor = None
pages = 0
while True:
page = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
limit=3, cursor=cursor,
)
pages += 1
seen.extend(item["ledger_event_id"] for item in page["items"])
cursor = page["next_cursor"]
if not page["has_more"]:
break
self.assertEqual(7, len(seen))
self.assertEqual(len(set(seen)), len(seen))
self.assertGreater(pages, 2)
def test_directory_pagination_complete(self) -> None:
for index in range(6):
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
seen: set[tuple[int, str]] = set()
cursor = None
while True:
page = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
limit=1, cursor=cursor,
)
seen.update((item["company_id"], item["currency"]) for item in page["items"])
cursor = page["next_cursor"]
if not page["has_more"]:
break
self.assertEqual(
{(self.company_a, "CNY"), (self.company_b, "CNY")}, seen
)
def test_event_payload_account_chip_and_repayment_flag(self) -> None:
self.pair(
self.company_a, self.company_b, "40.00",
summary="归还往来款", purpose="还款",
)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = int(self.ledger_events()[0]["id"])
detail = positions.event_detail(self.connection, event_id)
event = detail["event"]
self.assertEqual("visible", event["payer_account"]["visibility"])
self.assertEqual("测试 0001", event["payer_account"]["label"])
self.assertEqual("visible", event["payee_account"]["visibility"])
self.assertEqual("测试 0002", event["payee_account"]["label"])
self.assertEqual("归还往来款", event["summary"])
self.assertTrue(event["is_repayment"])
company_view = positions.event_payload(
self.connection,
self.connection.execute(
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
(event_id,),
).fetchone(),
viewer_company_id=self.company_a,
)
self.assertEqual("visible", company_view["payer_account"]["visibility"])
self.assertEqual("masked", company_view["payee_account"]["visibility"])
self.assertEqual("按对方授权不可见", company_view["payee_account"]["label"])
def test_park_subject_exception_hides_from_review_queue(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = int(self.ledger_events()[0]["id"])
current = self.current(event_id)
subjects.park_subject(
self.connection, event_id,
disposition="exception", reason="转异常核查",
expected_revision=current["id"], request_key="park-exc-1",
actor=self.admin,
)
queue = positions.subject_review_queue(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
)
self.assertFalse(
any(item["ledger_event_id"] == event_id for item in queue["items"])
)
revision = self.current(event_id)
self.assertEqual("pending_subject", revision["state"])
def test_park_subject_return_stays_in_review_queue(self) -> None:
self.pair(self.company_a, self.company_b, "80.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = int(self.ledger_events()[0]["id"])
current = self.current(event_id)
subjects.park_subject(
self.connection, event_id,
disposition="return", reason="退回补充摘要",
expected_revision=current["id"], request_key="park-ret-1",
actor=self.admin,
)
queue = positions.subject_review_queue(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
)
self.assertTrue(
any(item["ledger_event_id"] == event_id for item in queue["items"])
)
if __name__ == "__main__":
unittest.main()
+594
View File
@@ -0,0 +1,594 @@
"""HTTP integration tests for the B-44 intercompany position APIs.
Covers the admin balances/pair/events/evidence/subject-review/manual-record
endpoints and the company-side scoped reads with masked evidence, plus
cross-tenant 404s and company-forbidden admin writes. Every test method spins
up its own server with a fresh database so subject confirmations and matched
fixtures never leak across tests.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00",
currency: str = "RMB"):
return [own, "测试公司", at, amount, "", "50000.00", currency, "对方", cp, "某银行", "借款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
currency: str = "RMB"):
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", cp, "某银行", "借款", ""]
class IntercompanyApiTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self.initial_passwords: dict[str, str] = {}
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a", self.company_a)
self.cashier_b = self._login_company("cashier-b", self.company_b)
self.cashier_c = self._login_company("cashier-c", self.company_c)
self._approve_account(self.company_a, ACCOUNT_A)
self._approve_account(self.company_b, ACCOUNT_B)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords.setdefault(username, as_json(data)["initial_password"])
return as_json(data)["company_id"]
def _login_company(self, username: str, company_id: int) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str) -> None:
client = {self.company_a: self.cashier_a, self.company_b: self.cashier_b}[company_id]
status, _, data = client.post_json(
"/api/company/accounts",
{"bank_name": "中信银行", "account_type": "基本户",
"account_number": number, "start_date": "2026-01-01"},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
)
assert status == 200, data
def _upload_and_confirm(self, client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00",
currency: str = "RMB") -> dict:
"""Upload+confirm a new A<->B pair; returns the pending review item."""
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at, currency)],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"), currency)],
)
status, _, data = self.admin.get(
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
pending = [item for item in items if item["amount"] == amount]
self.assertEqual(1, len(pending), data)
return pending[0]
def _confirm(self, ledger_event_id: int) -> dict:
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{ledger_event_id}"
)
self.assertEqual(200, status, data)
detail = as_json(data)
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{ledger_event_id}/subject-decisions",
{
"perspective_company_id": detail["event"]["payer_company_id"],
"subject_code": "other_receivable",
"reason": "借款确认其他应收",
"expected_revision": detail["event"]["ledger_revision_id"],
"request_key": f"subj-{ledger_event_id}",
},
)
self.assertEqual(200, status, data)
return as_json(data)["revision"]
def balances(self, client=None, path="/api/admin/intercompany/balances") -> dict:
client = client or self.admin
status, _, data = client.get(path + "?from=2026-01-01&cutoff=2026-12-31")
self.assertEqual(200, status, data)
return as_json(data)
def _by_company(self, payload: dict, company_id: int) -> dict:
return next(item for item in payload["items"] if item["company_id"] == company_id)
# ------------------------------------------------------------------
# Admin balances / pair / events
# ------------------------------------------------------------------
def test_admin_balances_directory(self) -> None:
self._fresh_pair("100.00")
payload = self.balances()
self.assertEqual("2026-12-31", payload["window"]["cutoff"])
companies = {item["company_id"] for item in payload["items"]}
self.assertIn(self.company_a, companies)
self.assertIn(self.company_b, companies)
for item in payload["items"]:
self.assertEqual("unavailable", item["opening"]["status"])
self.assertIsNone(item["opening"]["amount"])
self.assertEqual("period_net_change", item["result"]["kind"])
self.assertIn("gross_amount", item["unresolved"])
self.assertIn("count", item["unresolved"])
self.assertIn("by_reason", item["unresolved"])
self.assertEqual("RMB", item["currency"])
def test_admin_balances_pending_subject_shows_unresolved(self) -> None:
self._fresh_pair("100.00")
payload = self.balances()
item = self._by_company(payload, self.company_a)
self.assertEqual(1, item["unresolved"]["count"])
self.assertEqual(
"100.00", item["unresolved"]["by_reason"]["subject_review"]["gross_amount"]
)
self.assertEqual("0", item["period"]["debit"])
self.assertEqual("0", item["period"]["credit"])
def test_admin_pair_detail_conserves(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.admin.get(
f"/api/admin/intercompany/pairs/{self.company_a}/{self.company_b}"
"?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
item = payload["items"][0]
self.assertTrue(item["conservation"]["opposite"])
self.assertTrue(item["conservation"]["abs_equal"])
self.assertEqual("unavailable", item["opening"]["status"])
self.assertIn("subjects", item)
self.assertEqual(1, item["unresolved"]["count"])
def test_admin_subject_decision_flows_to_balances(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
self.assertEqual("confirmed", revision["state"])
self.assertEqual("other_receivable", revision["subject_code"])
payload = self.balances()
item = self._by_company(payload, self.company_a)
self.assertEqual(0, item["unresolved"]["count"])
self.assertEqual("100.00", item["period"]["debit"])
self.assertEqual("100.00", item["result"]["signed_amount"])
def test_admin_subject_decision_stale_revision_conflicts(self) -> None:
pending = self._fresh_pair("100.00")
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
{
"perspective_company_id": pending["payer_company_id"],
"subject_code": "other_receivable", "reason": "确认",
"expected_revision": 999, "request_key": "stale-key",
},
)
self.assertEqual(409, status, data)
def test_admin_events_list_and_evidence(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
event = next(
item for item in items
if item["ledger_event_id"] == revision["ledger_event_id"]
)
self.assertEqual("confirmed", event["state"])
self.assertEqual("other_receivable", event["subject_code"])
self.assertIn("payer_company_name", event)
self.assertIn("amount", event)
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{event['ledger_event_id']}"
)
self.assertEqual(200, status, data)
self.assertEqual("confirmed", as_json(data)["state"])
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{event['ledger_event_id']}/evidence"
)
self.assertEqual(200, status, data)
blocks = as_json(data)["blocks"]
self.assertTrue(blocks)
for block in blocks:
self.assertEqual("visible", block["visibility"])
def test_admin_events_list_includes_pending_subject(self) -> None:
pending = self._fresh_pair("100.00")
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
item = next(
item for item in items
if item["ledger_event_id"] == pending["ledger_event_id"]
)
self.assertEqual("pending_subject", item["state"])
self.assertIsNone(item["subject_code"])
def test_admin_manual_record_decision_via_api(self) -> None:
status, _, data = self.cashier_a.post_json(
"/api/company/manual-records",
{
"counterparty_company_id": self.company_b,
"occurred_at": "2026-02-01T09:00:00",
"direction": "incoming", "amount": "20.00", "currency": "CNY",
"funding_source": "other", "requested_subject": "other_receivable",
"request_key": "mr-api-1", "summary": "还款",
},
)
self.assertEqual(200, status, data)
record = as_json(data)["record"]
self.assertEqual("pending", record["state"])
status, _, data = self.admin.get("/api/admin/manual-records")
self.assertEqual(200, status, data)
records = as_json(data)["records"]
self.assertTrue(any(item["id"] == record["id"] for item in records))
status, _, data = self.admin.post_json(
f"/api/admin/manual-records/{record['id']}/decisions",
{"action": "approve_new", "reason": "银行流水中无此事实",
"expected_decision_id": record["decision_id"], "request_key": "dec-api-1"},
)
self.assertEqual(200, status, data)
self.assertEqual("approved", as_json(data)["decision"]["state"])
status, _, data = self.cashier_a.get("/api/company/manual-records")
self.assertEqual(200, status, data)
own = [item for item in as_json(data)["records"] if item["id"] == record["id"]]
self.assertEqual(1, len(own))
self.assertEqual("approved", own[0]["state"])
status, _, data = self.cashier_b.get("/api/company/manual-records")
self.assertEqual(200, status, data)
self.assertEqual([], [
item for item in as_json(data)["records"] if item["id"] == record["id"]
])
def test_admin_adjustment_reverse_via_api(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{revision['ledger_event_id']}/adjustments",
{"action": "reverse", "reason": "科目误判,冲销"},
)
self.assertEqual(200, status, data)
self.assertEqual("reverse", as_json(data)["action"])
# ------------------------------------------------------------------
# Company scope and masking
# ------------------------------------------------------------------
def test_company_balances_scoped_to_own_company(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.cashier_a.get(
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
for item in payload["items"]:
self.assertEqual(self.company_a, item["company_id"])
self.assertIn("counterparties", payload)
self.assertTrue(payload["counterparties"])
def test_company_pair_and_events_are_own_scope(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.cashier_a.get(
f"/api/company/intercompany/pairs/{self.company_b}"
"?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
self.assertEqual(self.company_a, as_json(data)["companies"]["a"]["company_id"])
status, _, data = self.cashier_a.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
for item in items:
self.assertIn("direction", item)
self.assertIn("own_subject_label", item)
self.assertIn(item["direction"], ("incoming", "outgoing"))
def test_company_evidence_masks_counterparty_side(self) -> None:
pending = self._fresh_pair("100.00")
self._confirm(pending["ledger_event_id"])
status, _, data = self.cashier_a.get(
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
)
self.assertEqual(200, status, data)
blocks = as_json(data)["blocks"]
visibilities = {block["visibility"] for block in blocks}
self.assertTrue(visibilities & {"visible", "masked"})
for block in blocks:
if block["visibility"] == "masked":
self.assertEqual("按对方授权不可见", block["fields"].get("note"))
self.assertNotIn(ACCOUNT_B, json.dumps(block["fields"], ensure_ascii=False))
def test_company_cannot_read_or_write_admin_intercompany(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.cashier_a.get("/api/admin/intercompany/balances")
self.assertEqual(403, status, data)
status, _, data = self.cashier_a.get("/api/admin/manual-records")
self.assertEqual(403, status, data)
status, _, data = self.cashier_a.post_json(
"/api/admin/manual-records/1/decisions", {"action": "approve_new", "reason": "x"}
)
self.assertEqual(403, status, data)
status, _, data = self.cashier_a.post_json(
"/api/admin/intercompany/events/1/adjustments",
{"action": "reverse", "reason": "越权"},
)
self.assertEqual(403, status, data)
def test_cross_company_reads_are_404(self) -> None:
pending = self._fresh_pair("100.00")
status, _, data = self.cashier_c.get(
f"/api/company/intercompany/events/{pending['ledger_event_id']}"
)
self.assertEqual(404, status, data)
status, _, data = self.cashier_c.get(
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
)
self.assertEqual(404, status, data)
def test_company_counterparty_summary_splits_by_currency(self) -> None:
# A->B 100 CNY and A->B 50 USD must render two rows, never a mixed
# "CNY 150.00" bucket.
cny = self._fresh_pair("100.00", currency="CNY")
usd = self._fresh_pair("50.00", at="2026-01-20 10:00:00", currency="USD")
for pending in (cny, usd):
self._confirm(pending["ledger_event_id"])
status, _, data = self.cashier_a.get(
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
counterparties = as_json(data)["counterparties"]
rows = [row for row in counterparties if row["counterparty_company_id"] == self.company_b]
self.assertEqual(2, len(rows), counterparties)
by_currency = {row["currency"]: row for row in rows}
self.assertEqual({"CNY", "USD"}, set(by_currency))
self.assertEqual("100.00", by_currency["CNY"]["result"]["signed_amount"])
self.assertEqual("50.00", by_currency["USD"]["result"]["signed_amount"])
for row in rows:
self.assertNotEqual("150.00", row["result"]["signed_amount"])
self.assertEqual(1, row["event_count"])
def test_company_events_subject_filter_matches_mirror(self) -> None:
# A confirms subject "receivable" (stored from A's perspective); B must
# still find the event when filtering by the mirror subject "payable".
pending = self._fresh_pair("100.00")
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
{
"perspective_company_id": self.company_a,
"subject_code": "receivable", "reason": "确认应收",
"expected_revision": 1, "request_key": "mirror-subj",
},
)
self.assertEqual(200, status, data)
status, _, data = self.cashier_b.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31&subject=payable"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(any(
item["ledger_event_id"] == pending["ledger_event_id"] for item in items
), data)
def test_manual_reverse_via_api_uses_explicit_effective_date(self) -> None:
status, _, data = self.cashier_a.post_json(
"/api/company/manual-records",
{
"counterparty_company_id": self.company_b,
"occurred_at": "2026-02-01T09:00:00",
"direction": "incoming", "amount": "20.00", "currency": "CNY",
"funding_source": "other", "requested_subject": "other_receivable",
"request_key": "mr-rev-api", "summary": "还款",
},
)
self.assertEqual(200, status, data)
record = as_json(data)["record"]
status, _, data = self.admin.post_json(
f"/api/admin/manual-records/{record['id']}/decisions",
{"action": "approve_new", "reason": "确认入账",
"expected_decision_id": record["decision_id"], "request_key": "dec-rev-api"},
)
self.assertEqual(200, status, data)
event_id = as_json(data)["decision"]["ledger_event_id"]
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{event_id}"
)
self.assertEqual(200, status, data)
revision_id = as_json(data)["event"]["ledger_revision_id"]
status, _, data = self.admin.post_json(
f"/api/admin/manual-records/{record['id']}/decisions",
{"action": "reverse", "reason": "误录冲销", "effective_at": "2026-06-15",
"expected_decision_id": None, "request_key": "dec-rev-api-2"},
)
self.assertEqual(200, status, data)
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
reversals = [item for item in as_json(data)["items"] if item["posting_kind"] == "reversal"]
self.assertEqual(1, len(reversals))
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
def test_admin_events_include_account_chips(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
event = next(
item for item in as_json(data)["items"]
if item["ledger_event_id"] == revision["ledger_event_id"]
)
self.assertIn("payer_account", event)
self.assertIn("payee_account", event)
self.assertEqual("visible", event["payer_account"]["visibility"])
self.assertTrue(event["payer_account"]["label"])
self.assertIn("summary", event)
self.assertIn("is_repayment", event)
def test_subject_exception_drops_from_review_queue(self) -> None:
pending = self._fresh_pair("77.00")
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
{
"perspective_company_id": pending["payer_company_id"],
"action": "exception",
"reason": "转异常待核查",
"expected_revision": pending["revision_id"],
"request_key": "park-exc-api",
},
)
self.assertEqual(200, status, data)
self.assertEqual("pending_subject", as_json(data)["revision"]["state"])
status, _, data = self.admin.get(
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
ids = [item["ledger_event_id"] for item in as_json(data)["items"]]
self.assertNotIn(pending["ledger_event_id"], ids)
if __name__ == "__main__":
unittest.main()
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
import os
from unittest import TestCase
from unittest.mock import patch
import server
class ServerConfigurationTests(TestCase):
@patch.dict(os.environ, {"APP_HOST": "0.0.0.0", "APP_PORT": "8080"})
@patch("server.ThreadingHTTPServer")
def test_main_uses_configured_host_and_port(self, http_server) -> None:
server.main()
http_server.assert_called_once_with(("0.0.0.0", 8080), server.AppHandler)
http_server.return_value.serve_forever.assert_called_once_with()
+562
View File
@@ -0,0 +1,562 @@
"""HTTP integration tests for authentication, RBAC and tenant isolation.
Spins up a real ``ThreadingHTTPServer`` with a temp database/storage and
drives it with stdlib ``http.client`` (cookies handled by hand). The server
module reads ``APP_DB_PATH`` / ``APP_STORAGE_DIR`` from module globals at
request time, so tests patch them per class.
"""
from __future__ import annotations
from http.client import HTTPConnection
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from bank_importer import auth
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
SAMPLES = ROOT / "流水模板"
CCB_SAMPLE = SAMPLES / "中国建设银行账户流水.xls"
CITIC_SAMPLE = SAMPLES / "中信银行账户流水.xlsx"
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_A_PASSWORD = "CashierA123"
class Client:
"""Minimal HTTP client with a cookie jar."""
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.cookies: dict[str, str] = {}
def request(
self,
method: str,
path: str,
body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, dict[str, str], bytes]:
connection = HTTPConnection(self.host, self.port)
request_headers = dict(headers or {})
if self.cookies:
request_headers["Cookie"] = "; ".join(
f"{key}={value}" for key, value in self.cookies.items()
)
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
data = response.read()
response_headers = {key.lower(): value for key, value in response.getheaders()}
set_cookie = response_headers.get("set-cookie")
if set_cookie:
cookie = SimpleCookie()
cookie.load(set_cookie)
for key, morsel in cookie.items():
if morsel.value:
self.cookies[key] = morsel.value
else:
self.cookies.pop(key, None)
status = response.status
connection.close()
return status, response_headers, data
def get(self, path: str) -> tuple[int, dict[str, str], bytes]:
return self.request("GET", path)
def post_json(self, path: str, payload: dict) -> tuple[int, dict, bytes]:
return self.request(
"POST",
path,
body=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
def post_multipart(
self, path: str, fields: dict[str, str], filename: str, content: bytes
) -> tuple[int, dict, bytes]:
boundary = "----cwtestboundary7f3a9c1e"
parts: list[bytes] = []
for name, value in fields.items():
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
)
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n".encode()
+ content
+ b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
return self.request(
"POST",
path,
body=b"".join(parts),
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
def as_json(data: bytes) -> dict:
return json.loads(data.decode("utf-8"))
class ServerAuthMatrixTests(unittest.TestCase):
"""One live server; setUpClass builds the shared fixture via the API."""
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
generated = server.ensure_bootstrap_admin(connection)
assert generated is None, "env password set, nothing should be generated"
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None: # silence per-request logs
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.known_passwords = {BOOTSTRAP_PASSWORD, ADMIN_PASSWORD, CASHIER_A_PASSWORD}
# --- Admin bootstrap: must_change_password gate, then change. ---
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
assert as_json(data)["must_change_password"] is True
status, _, data = cls.admin.get("/api/batches")
assert status == 403, "must_change_password must block API access"
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
status, _, _ = cls.admin.get("/api/batches")
assert status == 200
# --- Companies A and B. ---
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "甲公司"})
assert status == 200, data
cls.company_a = as_json(data)["company_id"]
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
assert status == 200, data
cls.company_b = as_json(data)["company_id"]
# --- Company user A: random one-time initial password, forced change. ---
status, _, data = cls.admin.post_json(
"/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a}
)
assert status == 200, data
payload = as_json(data)
# Security: the initial password is random and never equals the username.
assert payload["initial_password"] != "cashier-a"
assert len(payload["initial_password"]) >= 12
cls.initial_password_a = payload["initial_password"]
cls.known_passwords.add(cls.initial_password_a)
cls.cashier_a = Client("127.0.0.1", cls.port)
status, _, data = cls.cashier_a.post_json(
"/api/login",
{
"username": "cashier-a",
"password": cls.initial_password_a,
"portal": "company",
},
)
assert status == 200, data
assert as_json(data)["must_change_password"] is True
status, _, _ = cls.cashier_a.get("/api/batches")
assert status == 403, "must_change_password must block company API access"
status, _, data = cls.cashier_a.post_json(
"/api/password/change",
{"old_password": cls.initial_password_a, "new_password": CASHIER_A_PASSWORD},
)
assert status == 200, data
# --- B gets a batch (admin upload), A gets its own batch. ---
status, _, data = cls.admin.post_multipart(
"/api/parse",
{"company_id": str(cls.company_b)},
CCB_SAMPLE.name,
CCB_SAMPLE.read_bytes(),
)
assert status == 200, data
cls.b_batch_id = as_json(data)["batch_id"]
cls.confirm_all_sheets(cls.admin, cls.b_batch_id)
status, _, data = cls.cashier_a.post_multipart(
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
)
assert status == 200, data
cls.a_batch_id = as_json(data)["batch_id"]
cls.confirm_all_sheets(cls.cashier_a, cls.a_batch_id)
@classmethod
def confirm_all_sheets(cls, client, batch_id: int) -> None:
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
assert status == 200, data
names = [
sheet["sheet_name"]
for sheet in as_json(data)["sheets"]
if sheet["outcome"] == "parsed"
]
if not names:
return
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def fresh_client(self) -> Client:
return Client("127.0.0.1", self.port)
def create_company_user(self, username: str) -> tuple[Client, str, int]:
status, _, data = self.admin.post_json(
"/api/admin/users", {"username": username, "company_id": self.company_a}
)
self.assertEqual(200, status, data)
payload = as_json(data)
initial = payload["initial_password"]
self.known_passwords.add(initial)
client = self.fresh_client()
status, _, data = client.post_json(
"/api/login",
{"username": username, "password": initial, "portal": "company"},
)
self.assertEqual(200, status, data)
new_password = "Changed123"
self.known_passwords.add(new_password)
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": new_password},
)
self.assertEqual(200, status, data)
return client, new_password, payload["user_id"]
# ------------------------------------------------------------------
# Unauthenticated matrix
# ------------------------------------------------------------------
def test_unauthenticated_api_calls_return_401(self) -> None:
anon = self.fresh_client()
for method_check in (
lambda: anon.post_multipart("/api/parse", {}, "x.xls", b"data"),
lambda: anon.get("/api/batches"),
lambda: anon.get(f"/api/batches/{self.a_batch_id}/rows"),
lambda: anon.get("/api/export.csv"),
lambda: anon.get("/api/admin/users"),
lambda: anon.get("/api/admin/companies"),
lambda: anon.get("/api/admin/audit-log"),
lambda: anon.get("/api/me"),
):
status, _, data = method_check()
self.assertEqual(401, status, data)
self.assertEqual("error", as_json(data)["status"])
def test_unauthenticated_portal_pages_redirect(self) -> None:
anon = self.fresh_client()
for page in ("/admin.html", "/company.html"):
status, headers, _ = anon.get(page)
self.assertEqual(302, status, page)
self.assertEqual("/", headers.get("location"))
def test_wrong_password_returns_generic_401(self) -> None:
anon = self.fresh_client()
status, _, data = anon.post_json(
"/api/login",
{"username": "group-admin", "password": "WrongPass1", "portal": "admin"},
)
self.assertEqual(401, status)
message = as_json(data)["message"]
self.assertNotIn("密码不正确", message.replace("账号或密码不正确", ""))
def test_rate_limit_after_five_failures(self) -> None:
anon = self.fresh_client()
for _ in range(5):
status, _, _ = anon.post_json(
"/api/login",
{"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
)
self.assertEqual(401, status)
status, _, _ = anon.post_json(
"/api/login",
{"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
)
self.assertEqual(429, status)
def test_portal_mismatch_returns_403(self) -> None:
anon = self.fresh_client()
status, _, data = anon.post_json(
"/api/login",
{"username": "group-admin", "password": ADMIN_PASSWORD, "portal": "company"},
)
self.assertEqual(403, status)
self.assertIn("端口", as_json(data)["message"])
def test_logout_revokes_session(self) -> None:
client, _, _ = self.create_company_user("cashier-logout")
status, _, _ = client.get("/api/me")
self.assertEqual(200, status)
status, _, _ = client.request("POST", "/api/logout")
self.assertEqual(200, status)
self.assertNotIn("cw_session", client.cookies)
status, _, _ = client.get("/api/me")
self.assertEqual(401, status)
def test_expired_session_returns_401(self) -> None:
client, _, user_id = self.create_company_user("cashier-expired")
connection = connect(self.db_path)
try:
with connection:
connection.execute(
"UPDATE sessions SET expires_at = ? WHERE user_id = ?",
("2000-01-01T00:00:00+00:00", user_id),
)
finally:
connection.close()
status, _, _ = client.get("/api/me")
self.assertEqual(401, status)
# ------------------------------------------------------------------
# Admin and company lifecycle
# ------------------------------------------------------------------
def test_duplicate_company_name_returns_409(self) -> None:
status, _, _ = self.admin.post_json("/api/admin/companies", {"name": "甲公司"})
self.assertEqual(409, status)
def test_me_returns_profile_without_password_material(self) -> None:
status, _, data = self.cashier_a.get("/api/me")
self.assertEqual(200, status)
payload = as_json(data)
self.assertEqual("cashier-a", payload["username"])
self.assertEqual("company", payload["role"])
self.assertEqual(self.company_a, payload["company_id"])
self.assertEqual("甲公司", payload["company_name"])
self.assertFalse(payload["must_change_password"])
def test_company_user_forbidden_on_all_admin_endpoints(self) -> None:
calls = (
lambda: self.cashier_a.get("/api/admin/companies"),
lambda: self.cashier_a.post_json("/api/admin/companies", {"name": "丙公司"}),
lambda: self.cashier_a.get("/api/admin/users"),
lambda: self.cashier_a.post_json(
"/api/admin/users", {"username": "x", "company_id": self.company_a}
),
lambda: self.cashier_a.request("POST", "/api/admin/users/1/disable"),
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
lambda: self.cashier_a.get("/api/admin/audit-log"),
)
for call in calls:
status, _, data = call()
self.assertEqual(403, status, data)
def test_admin_upload_requires_company_id(self) -> None:
status, _, data = self.admin.post_multipart(
"/api/parse", {}, CCB_SAMPLE.name, CCB_SAMPLE.read_bytes()
)
self.assertEqual(400, status, data)
def test_admin_batches_filter_by_company(self) -> None:
status, _, data = self.admin.get(f"/api/batches?company_id={self.company_b}")
self.assertEqual(200, status)
batches = as_json(data)["batches"]
self.assertTrue(batches)
for batch in batches:
self.assertEqual(self.company_b, batch["company_id"])
self.assertEqual("乙公司", batch["company_name"])
# ------------------------------------------------------------------
# Tenant isolation
# ------------------------------------------------------------------
def test_batches_scoped_to_own_company(self) -> None:
status, _, data = self.cashier_a.get("/api/batches")
self.assertEqual(200, status)
batches = as_json(data)["batches"]
self.assertTrue(batches)
for batch in batches:
self.assertEqual(self.company_a, batch["company_id"])
self.assertNotIn(self.b_batch_id, [batch["id"] for batch in batches])
def test_idor_batch_rows_of_other_company_return_404(self) -> None:
status, _, _ = self.cashier_a.get(f"/api/batches/{self.b_batch_id}/rows")
self.assertEqual(404, status)
status, _, data = self.cashier_a.get(f"/api/batches/{self.a_batch_id}/rows")
self.assertEqual(200, status)
self.assertTrue(as_json(data)["rows"])
def test_export_csv_forced_to_own_company(self) -> None:
status, _, _ = self.cashier_a.get(f"/api/export.csv?company_id={self.company_b}")
self.assertEqual(403, status)
status, headers, data = self.cashier_a.get("/api/export.csv")
self.assertEqual(200, status)
self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
text = data.decode("utf-8-sig")
lines = [line for line in text.splitlines() if line]
self.assertGreater(len(lines), 1)
for line in lines[1:]:
self.assertEqual(str(self.a_batch_id), line.split(",", 1)[0])
def test_cross_company_upload_is_recorded_under_own_company(self) -> None:
# A uploads B's file bytes while claiming company B in the form; the
# server must bind the new (duplicate) batch to A from the session.
status, _, data = self.cashier_a.post_multipart(
"/api/parse",
{"company_id": str(self.company_b)},
CCB_SAMPLE.name,
CCB_SAMPLE.read_bytes(),
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("duplicate", payload["status"])
# Cross-company duplicate: no original batch id, bank, template,
# period, transaction count or diagnostics may be exposed.
self.assertNotEqual(self.b_batch_id, payload.get("batch_id"))
for leaked_key in ("bank", "template", "header_row", "period_start", "period_end", "transactions", "warnings"):
self.assertNotIn(leaked_key, payload, leaked_key)
connection = connect(self.db_path)
try:
duplicate = connection.execute(
"SELECT company_id FROM import_batches WHERE status = 'duplicate'"
).fetchone()
finally:
connection.close()
self.assertIsNotNone(duplicate)
self.assertEqual(self.company_a, duplicate["company_id"])
def test_same_company_duplicate_keeps_idempotent_summary(self) -> None:
# A re-uploads its own file; the idempotent duplicate response keeps
# the original batch id and the parsed summary.
status, _, data = self.cashier_a.post_multipart(
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("duplicate", payload["status"])
self.assertEqual(self.a_batch_id, payload["batch_id"])
self.assertTrue(payload.get("sheets"))
self.assertEqual("中信银行", payload["sheets"][0].get("bank"))
self.assertTrue(payload["sheets"][0].get("transactions", 0) > 0)
self.assertIn("period_start", payload["sheets"][0])
self.assertIn("warnings", payload["sheets"][0])
# ------------------------------------------------------------------
# Disable / reset flows
# ------------------------------------------------------------------
def test_disabled_user_session_and_login_rejected(self) -> None:
client, password, user_id = self.create_company_user("cashier-disable")
status, _, _ = client.get("/api/me")
self.assertEqual(200, status)
status, _, data = self.admin.request("POST", f"/api/admin/users/{user_id}/disable")
self.assertEqual(200, status, data)
status, _, _ = client.get("/api/me")
self.assertEqual(401, status)
fresh = self.fresh_client()
status, _, data = fresh.post_json(
"/api/login",
{"username": "cashier-disable", "password": password, "portal": "company"},
)
self.assertEqual(403, status, data)
def test_reset_password_returns_once_and_revokes_sessions(self) -> None:
client, old_password, user_id = self.create_company_user("cashier-reset")
status, _, data = self.admin.request(
"POST", f"/api/admin/users/{user_id}/reset-password"
)
self.assertEqual(200, status, data)
new_password = as_json(data)["initial_password"]
self.known_passwords.add(new_password)
status, _, _ = client.get("/api/me")
self.assertEqual(401, status)
fresh = self.fresh_client()
status, _, data = fresh.post_json(
"/api/login",
{"username": "cashier-reset", "password": new_password, "portal": "company"},
)
self.assertEqual(200, status, data)
self.assertTrue(as_json(data)["must_change_password"])
# ------------------------------------------------------------------
# Secrets hygiene
# ------------------------------------------------------------------
def test_no_response_contains_password_hash(self) -> None:
bodies = []
status, _, data = self.admin.get("/api/admin/users")
self.assertEqual(200, status)
bodies.append(data)
status, _, data = self.admin.get("/api/admin/audit-log?limit=100")
self.assertEqual(200, status)
bodies.append(data)
status, _, data = self.cashier_a.get("/api/me")
bodies.append(data)
status, _, data = self.cashier_a.get("/api/batches")
bodies.append(data)
for body in bodies:
self.assertNotIn("password_hash", body.decode("utf-8"))
def test_audit_log_contains_no_plaintext_passwords(self) -> None:
connection = connect(self.db_path)
try:
rows = connection.execute(
"SELECT detail, target FROM audit_log"
).fetchall()
finally:
connection.close()
for password in self.known_passwords:
for row in rows:
self.assertNotIn(password, row["detail"] or "")
self.assertNotIn(password, row["target"] or "")
if __name__ == "__main__":
unittest.main()
+70 -62
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="往来台账总账管理端" /> <meta name="description" content="金牛集团总账管理端" />
<title>总账管理端 · 往来台账</title> <title>总账管理端 · 金牛集团</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body data-portal="admin"> <body data-portal="admin">
@@ -19,7 +19,7 @@
<a class="skip-link" href="#main-content">跳到主要内容</a> <a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="总账管理导航"> <aside class="sidebar" id="sidebar" aria-label="总账管理导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>往来台账</strong><small>总账管理端</small></span></div> <div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
<nav class="nav-list"> <nav class="nav-list">
<button class="nav-item is-active" data-view="dashboard"><svg><use href="icons.svg#layout-dashboard"/></svg><span>管理总览</span></button> <button class="nav-item is-active" data-view="dashboard"><svg><use href="icons.svg#layout-dashboard"/></svg><span>管理总览</span></button>
<button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来查询</span></button> <button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来查询</span></button>
@@ -36,20 +36,18 @@
</aside> </aside>
<div class="workspace"> <div class="workspace">
<header class="topbar">
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
<div class="workspace-name"><span>集团财务中心</span><strong id="currentViewName">管理总览</strong></div>
<span class="demo-badge">演示数据</span>
<div class="topbar-actions">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
</div>
</header>
<main id="main-content"> <main id="main-content">
<section class="app-view is-active" data-page="dashboard"> <section class="app-view is-active" data-page="dashboard">
<header class="page-heading"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div><button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button></header> <header class="page-heading dashboard-head"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div>
<div class="content-toolbar">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
<div class="toolbar-group">
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
<button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button>
</div>
</div>
</header>
<section class="metric-grid" aria-label="总账关键指标"> <section class="metric-grid" aria-label="总账关键指标">
<article class="metric-card danger" data-metric-link="audit" tabindex="0" role="button" aria-label="查看 7 项待审核事项"> <article class="metric-card danger" data-metric-link="audit" tabindex="0" role="button" aria-label="查看 7 项待审核事项">
@@ -81,27 +79,13 @@
<div class="period-warning"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>2 家公司存在起算缺口</strong><small>最早缺口始于 2026.01.01</small></span><button class="text-button" data-view-link="audit">去审核</button></div> <div class="period-warning"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>2 家公司存在起算缺口</strong><small>最早缺口始于 2026.01.01</small></span><button class="text-button" data-view-link="audit">去审核</button></div>
</section> </section>
<section class="panel deck-panel" aria-label="重点公司状态卡栈">
<div class="panel-heading"><div><h2>重点公司</h2><p>集团账户状态总览 · 按风险等级排列,荧光绿为公司端第一视角</p></div><button class="text-button" data-view-link="companies">查看全部 6 家</button></div>
<div class="stack-deck" role="list">
<div class="deck-stage">
<article class="deck-card" role="listitem" data-view-link="companies" tabindex="0" aria-label="C公司 全部账户连续"><span class="deck-pos">05</span><span class="deck-mark">C</span><div class="deck-name"><strong>C公司</strong><small>全部账户连续</small></div><div class="deck-meta"><strong>2 个账户</strong><small>01.01—07.31</small></div><div class="deck-foot"><span>连续 7 个月</span><em class="status success">正常</em></div></article>
<article class="deck-card" role="listitem" data-view-link="audit" tabindex="0" aria-label="E公司 科目待确认"><span class="deck-pos">04</span><span class="deck-mark">E</span><div class="deck-name"><strong>E公司</strong><small>科目待确认</small></div><div class="deck-meta"><strong>88.00 万元</strong><small>07.09 · 应收与其他应收</small></div><div class="deck-foot"><span>等待公司确认</span><em class="status warning"></em></div></article>
<article class="deck-card is-active is-danger" role="listitem" data-view-link="audit" tabindex="0" aria-label="A公司 工商银行账户断档 高风险"><span class="deck-pos">01</span><span class="deck-mark">A</span><div class="deck-name"><strong>A公司</strong><small>工商银行账户断档</small></div><div class="deck-meta"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><div class="deck-foot"><span>影响 7 月结账</span><em class="status danger"></em></div></article>
<article class="deck-card is-warning" role="listitem" data-view-link="audit" tabindex="0" aria-label="B公司 单边流水待匹配"><span class="deck-pos">02</span><span class="deck-mark">B</span><div class="deck-name"><strong>B公司</strong><small>单边流水待匹配</small></div><div class="deck-meta"><strong>312.00 万元</strong><small>6 笔 · B↔D</small></div><div class="deck-foot"><span>今日新增</span><em class="status warning"></em></div></article>
<article class="deck-card is-warning" role="listitem" data-view-link="audit" tabindex="0" aria-label="F公司 起算区间待校准"><span class="deck-pos">03</span><span class="deck-mark">F</span><div class="deck-name"><strong>F公司</strong><small>起算区间待校准</small></div><div class="deck-meta"><strong>缺口 16 天</strong><small>01.01—01.16</small></div><div class="deck-foot"><span>待公司确认</span><em class="status warning"></em></div></article>
<article class="deck-card" role="listitem" data-view-link="companies" tabindex="0" aria-label="D公司 全部账户连续"><span class="deck-pos">06</span><span class="deck-mark">D</span><div class="deck-name"><strong>D公司</strong><small>全部账户连续</small></div><div class="deck-meta"><strong>1 个账户</strong><small>01.01—07.31</small></div><div class="deck-foot"><span>连续 7 个月</span><em class="status success">正常</em></div></article>
</div>
</div>
</section>
<div class="admin-dashboard-grid"> <div class="admin-dashboard-grid">
<section class="panel priority-panel"> <section class="panel priority-panel">
<div class="panel-heading"><div><h2>优先处理</h2><p>影响当前账期可靠性的事项</p></div><button class="text-button" data-view-link="audit">查看全部 7 项</button></div> <div class="panel-heading"><div><h2>优先处理</h2><p>影响当前账期可靠性的事项</p></div><button class="text-button" data-view-link="audit">查看全部 7 项</button></div>
<div class="task-list compact-task-list"> <div class="task-list compact-task-list">
<button data-view-link="audit"><span class="task-level danger"></span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button> <button data-detail="gap-a"><span class="task-level danger"></span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button>
<button data-view-link="audit"><span class="task-level warning"></span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button> <button data-detail="match-bd"><span class="task-level warning"></span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button>
<button data-view-link="audit"><span class="task-level warning"></span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button> <button data-detail="calib-f"><span class="task-level warning"></span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button>
</div> </div>
</section> </section>
@@ -117,21 +101,6 @@
</aside> </aside>
</div> </div>
<section class="timeline-strip panel" aria-label="覆盖连续性时间轴">
<div class="timeline-legend"><strong>覆盖连续性</strong><span>2026.01.01 — 2026.07.31</span><small>11 条账户 · 1 个断档 · 今日 07.24</small></div>
<div class="timeline-body">
<div class="timeline-axis"><span>1月</span><span>2月</span><span>3月</span><span>4月</span><span>5月</span><span>6月</span><span>7月</span><span>8月</span><span>9月</span><span>10月</span><span>11月</span><span>12月</span></div>
<div class="timeline-rows">
<div class="timeline-row"><span>A公司 · 工行 9481</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><i class="danger" style="left:33%;right:67%"></i><span class="timeline-today" style="left:67%"></span></div></div>
<div class="timeline-row"><span>A公司 · 中信 5316</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
<div class="timeline-row"><span>A公司 · 建行 0845</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
<div class="timeline-row"><span>A公司 · 农行 3650</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
<div class="timeline-row"><span>B公司 · 工行 1234</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
<div class="timeline-row"><span>F公司 · 农行 3650</span><div class="timeline-track"><i class="warning" style="left:0%;right:33%"></i><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
</div>
</div>
</section>
<section class="panel company-ledger-panel"> <section class="panel company-ledger-panel">
<div class="panel-heading"><div><h2>公司借贷目录</h2><p>统计区间 2026.01.01—2026.07.31 · 单位:万元</p></div><label class="inline-search"><svg><use href="icons.svg#search"/></svg><input data-filter-target="companyLedgers" placeholder="快速检索公司" /></label></div> <div class="panel-heading"><div><h2>公司借贷目录</h2><p>统计区间 2026.01.01—2026.07.31 · 单位:万元</p></div><label class="inline-search"><svg><use href="icons.svg#search"/></svg><input data-filter-target="companyLedgers" placeholder="快速检索公司" /></label></div>
<div class="ledger-head"><span>公司</span><span>借方合计</span><span>贷方合计</span><span>期末方向</span><span>待审核</span><span></span></div> <div class="ledger-head"><span>公司</span><span>借方合计</span><span>贷方合计</span><span>期末方向</span><span>待审核</span><span></span></div>
@@ -147,31 +116,59 @@
<details class="company-ledger"><summary><span class="company-name"><i>C</i><b>C公司</b><small>2 个银行账户</small></span><strong class="amount debit">1,894.00</strong><strong class="amount credit">1,052.00</strong><span><em class="status success">应收</em><b class="amount">842.00</b></span><span class="status neutral">0 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary><div class="ledger-breakdown"><section><header><h3>借方明细</h3><strong>1,894.00</strong></header><button class="subject-row" data-pair-link="C公司|E公司"><span><b>E公司</b><small>应收 · 5 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section><section><header><h3>贷方明细</h3><strong>1,052.00</strong></header><button class="subject-row" data-pair-link="C公司|A公司"><span><b>A公司</b><small>其他应付 · 3 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section></div></details> <details class="company-ledger"><summary><span class="company-name"><i>C</i><b>C公司</b><small>2 个银行账户</small></span><strong class="amount debit">1,894.00</strong><strong class="amount credit">1,052.00</strong><span><em class="status success">应收</em><b class="amount">842.00</b></span><span class="status neutral">0 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary><div class="ledger-breakdown"><section><header><h3>借方明细</h3><strong>1,894.00</strong></header><button class="subject-row" data-pair-link="C公司|E公司"><span><b>E公司</b><small>应收 · 5 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section><section><header><h3>贷方明细</h3><strong>1,052.00</strong></header><button class="subject-row" data-pair-link="C公司|A公司"><span><b>A公司</b><small>其他应付 · 3 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section></div></details>
</div> </div>
</section> </section>
<section class="panel timeline-panel" aria-label="账期时间轴">
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status danger">1 处断档 · 7 月结账日 08.05</span></div>
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
<div class="timeline-bar">
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
<i class="timeline-today" style="left:99.1%"></i>
</div>
<div class="timeline-ticks" aria-hidden="true">
<span style="left:0">01.01<small>起算日</small></span>
<span style="left:14%">2月</span>
<span style="left:26.7%">3月</span>
<span style="left:40.7%">4月</span>
<span style="left:54.3%">5月</span>
<span style="left:68.3%">6月</span>
<span style="left:81.9%">7月</span>
<span style="left:95.9%">8月</span>
</div>
</div>
<ul class="timeline-legend">
<li><i class="dot success"></i>1—6 月已结账</li>
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
<li><i class="dot today"></i>今天 08.08</li>
</ul>
</section>
</section> </section>
<section class="app-view" data-page="pair"> <section class="app-view" data-page="pair">
<header class="page-heading"><div><h1>往来查询</h1><p>公司对查询双方口径、科目和逐笔凭证</p></div></header> <header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录、公司对明细与逐层追溯</p></div>
<span class="status neutral" id="balanceOpeningNote">期初不可用 · 仅显示期间净变动</span>
</header>
<section class="query-band"> <section class="query-band">
<form class="pair-query-form" data-pair-form> <form class="pair-query-form" data-balance-form>
<label class="field"><span>本方公司</span><select name="from"><option>A公司</option><option>B公司</option><option>C公司</option><option>D公司</option><option>E公司</option><option>F公司</option></select></label> <label class="field"><span>统计起始</span><input name="from" type="date" value="2026-01-01" /></label>
<button class="swap-button" type="button" data-swap aria-label="交换公司" title="交换公司"><svg><use href="icons.svg#arrow-left-right"/></svg></button> <label class="field"><span>统计截止</span><input name="cutoff" type="date" value="2026-07-31" /></label>
<label class="field"><span>对方公司</span><select name="to"><option>B公司</option><option>C公司</option><option>D公司</option><option>E公司</option><option>F公司</option><option>A公司</option></select></label> <label class="field"><span>币种</span><select name="currency"><option value="">全部币种</option><option value="CNY">CNY</option><option value="RMB">RMB</option></select></label>
<label class="field"><span>统计截止</span><input name="end" type="date" value="2026-07-31" /></label>
<button class="button primary" type="submit"><svg><use href="icons.svg#search"/></svg>查询</button> <button class="button primary" type="submit"><svg><use href="icons.svg#search"/></svg>查询</button>
</form> </form>
</section> </section>
<section class="pair-report" id="pairReport"> <section class="panel company-ledger-panel" aria-label="公司余额目录">
<header class="pair-report-heading"><div><h2><span data-pair-from>A公司</span><span data-pair-to>B公司</span></h2><p id="pairPeriod">统计口径 2026.01.01—2026.07.31</p></div><span class="status warning" id="pairReviewStatus">含 1 笔待审核</span></header> <div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
<div class="pair-balance-line"><div><span>期初余额</span><strong id="pairOpening">0.00</strong></div><div><span>本期借方</span><strong id="pairDebit">2,416.00</strong></div><div><span>本期贷方</span><strong id="pairCredit">1,136.00</strong></div><div class="pair-final"><span>期末结果</span><strong id="pairFinal">应收 1,280.00<small>万元</small></strong></div></div> <div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
<div class="subject-strip"><button class="is-active" data-subject-filter="all" aria-pressed="true"><span>全部往来</span><strong data-subject-total="all">5 笔</strong></button><button data-subject-filter="应收" aria-pressed="false"><span>应收</span><strong data-subject-total="应收">1,280.00</strong></button><button data-subject-filter="其他应收" aria-pressed="false"><span>其他应收</span><strong data-subject-total="其他应收">640.00</strong></button><button data-subject-filter="应付" aria-pressed="false"><span>应付</span><strong data-subject-total="应付">728.00</strong></button><button data-subject-filter="其他应付" aria-pressed="false"><span>其他应付</span><strong data-subject-total="其他应付">408.00</strong></button></div> <div id="balanceLedgers" class="company-ledgers" aria-live="polite"></div>
<div class="table-scroll"><table class="data-table" id="pairTransactions"><thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>本方账户</th><th>对方账户</th><th>摘要</th><th>匹配</th><th class="number">金额(万元)</th></tr></thead><tbody><tr data-subject="应收"><td>2026.07.18</td><td>转出</td><td>应收</td><td>中信 · 5316</td><td>工行 · 9481</td><td>往来款</td><td><span class="status success">双边匹配</span></td><td class="number">1,000.00</td></tr><tr data-subject="应收"><td>2026.07.06</td><td>转入</td><td>应收</td><td>中信 · 5316</td><td>工行 · 9481</td><td>归还往来款</td><td><span class="status success">双边匹配</span></td><td class="number">320.00</td></tr><tr data-subject="其他应收"><td>2026.06.27</td><td>转出</td><td>其他应收</td><td>建行 · 0845</td><td>工行 · 9481</td><td>资金调拨</td><td><span class="status warning">单边待核</span></td><td class="number">600.00</td></tr></tbody></table></div>
</section> </section>
</section> </section>
<section class="app-view" data-page="audit"> <section class="app-view" data-page="audit">
<header class="page-heading"><div><h1>审核中心</h1><p>统一处理核算异常、账户登记与公司手工记录</p></div></header> <header class="page-heading"><div><h1>审核中心</h1><p>统一处理核算异常、账户登记与公司手工记录</p></div></header>
<section class="filter-bar"><div class="segmented" role="group" aria-label="审核类型"><button class="is-active" data-audit-filter="all" data-label="全部" aria-pressed="true">全部 6</button><button data-audit-filter="断档" data-label="流水断档" aria-pressed="false">流水断档 1</button><button data-audit-filter="单边" data-label="单边匹配" aria-pressed="false">单边匹配 1</button><button data-audit-filter="科目" data-label="科目待确认" aria-pressed="false">科目待确认 1</button><button data-audit-filter="起算" data-label="起算校准" aria-pressed="false">起算校准 1</button><button data-audit-filter="账户" data-label="账户登记" aria-pressed="false">账户登记 1</button><button data-audit-filter="手工" data-label="手工记录" aria-pressed="false">手工记录 1</button></div><label class="field compact"><span>公司</span><select id="auditCompany"><option>全部公司</option><option>A公司</option><option>B公司</option><option>E公司</option><option>F公司</option></select></label></section> <section class="filter-bar"><div class="segmented" role="group" aria-label="审核类型"><button class="is-active" data-audit-filter="all" data-label="全部" aria-pressed="true">全部 6</button><button data-audit-filter="断档" data-label="流水断档" aria-pressed="false">流水断档 1</button><button data-audit-filter="单边" data-label="单边匹配" aria-pressed="false">单边匹配 1</button><button data-audit-filter="科目" data-label="科目待确认" aria-pressed="false">科目待确认 1</button><button data-audit-filter="起算" data-label="起算校准" aria-pressed="false">起算校准 1</button><button data-audit-filter="账户" data-label="账户登记" aria-pressed="false">账户登记 1</button><button data-audit-filter="手工" data-label="手工记录" aria-pressed="false">手工记录 1</button></div><label class="field compact"><span>公司</span><select id="auditCompany"><option>全部公司</option><option>A公司</option><option>B公司</option><option>E公司</option><option>F公司</option></select></label></section>
<section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger"></span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning"></span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning"></span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning"></span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="账户" data-company="A公司" data-record-kind="account" data-evidence="公司提交资料、开户行、账号、账户类型与启用日期"><td><span class="task-level warning"></span></td><td><strong>A公司 · 郑州银行 3028</strong><small>一般户 · 申请启用日 2026.08.01</small></td><td>账户登记</td><td>2026.08.01</td><td>账户识别与流水上传</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning"></span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section> <section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger"></span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning"></span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning"></span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning"></span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning"></span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section>
</section> </section>
<section class="app-view" data-page="flows"> <section class="app-view" data-page="flows">
@@ -182,7 +179,7 @@
<section class="app-view" data-page="companies"> <section class="app-view" data-page="companies">
<header class="page-heading"><div><h1>公司与账号</h1><p>维护公司主档、银行账户和一对一公司账号</p></div><button class="button primary" id="openCompanyDialog"><svg><use href="icons.svg#plus"/></svg>新增公司</button></header> <header class="page-heading"><div><h1>公司与账号</h1><p>维护公司主档、银行账户和一对一公司账号</p></div><button class="button primary" id="openCompanyDialog"><svg><use href="icons.svg#plus"/></svg>新增公司</button></header>
<section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>A公司</strong><small>COMP-001</small></td><td>91410100MA******</td><td>4 个</td><td>a-cashier</td><td>牛女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 A 公司主档">管理</button></td></tr><tr><td><strong>B公司</strong><small>COMP-002</small></td><td>91410100MB******</td><td>3 个</td><td>b-cashier</td><td>王女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 B 公司主档">管理</button></td></tr><tr><td><strong>筹备公司</strong><small>COMP-006</small></td><td>注册中</td><td>0 个</td><td>未创建</td><td>未指定</td><td><span class="status neutral">筹备中</span></td><td><button class="text-button" data-toast="已打开筹备公司主档">管理</button></td></tr></tbody></table></div></section> <section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody></tbody></table></div></section>
</section> </section>
<section class="app-view" data-page="settings"> <section class="app-view" data-page="settings">
@@ -208,7 +205,7 @@
<dialog id="companyDialog" class="dialog"> <dialog id="companyDialog" class="dialog">
<form method="dialog" id="companyForm"> <form method="dialog" id="companyForm">
<header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header> <header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>初始密码将在创建后显示一次,首次登录必须修改。</p></div> <div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>创建后生成随机初始密码,仅显示一次,首次登录必须修改。</p></div>
<footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer> <footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer>
</form> </form>
</dialog> </dialog>
@@ -222,7 +219,7 @@
<dialog id="auditDialog" class="dialog"> <dialog id="auditDialog" class="dialog">
<form id="auditForm"> <form id="auditForm">
<header><div><h2 id="auditDialogTitle">审核事项</h2><p id="auditDialogMeta">原始证据与处理决定将一并留痕</p></div><button type="button" class="icon-button" data-close-audit aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header> <header><div><h2 id="auditDialogTitle">审核事项</h2><p id="auditDialogMeta">原始证据与处理决定将一并留痕</p></div><button type="button" class="icon-button" data-close-audit aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><div class="evidence-block" id="auditEvidence"></div><label class="field"><span>处理决定</span><select name="decision" required><option value="">请选择</option><option>确认并纳入计算</option><option>退回公司补充材料</option><option>转为异常待后续处理</option></select></label><label class="field"><span>处理依据</span><textarea name="reason" rows="3" required placeholder="填写核验账号、摘要、回单或说明"></textarea></label></div> <div class="dialog-body"><div class="evidence-block" id="auditEvidence"></div><label class="field"><span>处理决定</span><select name="decision" required><option value="">请选择</option><option>确认并纳入计算</option><option>退回公司补充材料</option><option>转为异常</option></select></label><p class="decision-danger" id="auditExceptionNote" hidden>转为异常后,该记录暂不纳入余额计算,转入异常队列等待人工核查;此操作将写入审计日志。</p><label class="field"><span>处理依据</span><textarea name="reason" rows="3" required placeholder="填写核验账号、摘要、回单或说明"></textarea></label></div>
<footer><button type="button" class="button secondary" data-close-audit>取消</button><button class="button primary" type="submit">提交审核结果</button></footer> <footer><button type="button" class="button secondary" data-close-audit>取消</button><button class="button primary" type="submit">提交审核结果</button></footer>
</form> </form>
</dialog> </dialog>
@@ -233,6 +230,17 @@
<footer><button type="button" class="button secondary" data-close-closing>取消</button><button class="button primary" type="submit">确认执行结账</button></footer> <footer><button type="button" class="button secondary" data-close-closing>取消</button><button class="button primary" type="submit">确认执行结账</button></footer>
</form> </form>
</dialog> </dialog>
<div class="drawer-scrim" id="drawerScrim" data-close-drawer aria-hidden="true"></div>
<aside class="drawer" id="evidenceDrawer" aria-label="往来明细与源行证据抽屉" aria-hidden="true">
<header class="drawer-header">
<div>
<div class="drawer-breadcrumb" id="drawerBreadcrumb"></div>
<h2 id="drawerTitle">往来明细</h2>
</div>
<button type="button" class="icon-button" data-close-drawer aria-label="关闭抽屉" title="关闭"><svg><use href="icons.svg#x"/></svg></button>
</header>
<div class="drawer-body" id="drawerBody" tabindex="-1"></div>
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div> <div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+1988 -285
View File
File diff suppressed because it is too large Load Diff
+88 -34
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="往来台账公司业务端" /> <meta name="description" content="金牛集团公司业务端" />
<title>A公司业务端 · 往来台账</title> <title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body data-portal="company"> <body data-portal="company">
@@ -19,14 +19,15 @@
<a class="skip-link" href="#main-content">跳到主要内容</a> <a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="公司业务导航"> <aside class="sidebar" id="sidebar" aria-label="公司业务导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>往来台账</strong><small>公司业务端</small></span></div> <div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>金牛集团</strong><small>公司业务端</small></span></div>
<div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div> <div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div>
<nav class="nav-list"> <nav class="nav-list">
<button class="nav-item is-active" data-view="workspace"><svg><use href="icons.svg#layout-dashboard"/></svg><span>工作台</span><b>4</b></button> <button class="nav-item is-active" data-view="workspace"><svg><use href="icons.svg#layout-dashboard"/></svg><span>工作台</span><b>4</b></button>
<button class="nav-item" data-view="upload"><svg><use href="icons.svg#upload"/></svg><span>流水导入</span></button> <button class="nav-item" data-view="upload"><svg><use href="icons.svg#upload"/></svg><span>流水导入</span></button>
<button class="nav-item" data-view="manual"><svg><use href="icons.svg#plus"/></svg><span>手工记录</span></button> <button class="nav-item" data-view="manual"><svg><use href="icons.svg#plus"/></svg><span>手工记录</span></button>
<button class="nav-item" data-view="flows"><svg><use href="icons.svg#file-spreadsheet"/></svg><span>流水管理</span></button> <button class="nav-item" data-view="flows"><svg><use href="icons.svg#file-spreadsheet"/></svg><span>流水管理</span></button>
<button class="nav-item" data-view="reconcile"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来确认</span><b>2</b></button> <button class="nav-item" data-view="balances"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来余额</span></button>
<button class="nav-item" data-view="reconcile"><svg><use href="icons.svg#circle-check"/></svg><span>往来确认</span><b>2</b></button>
<button class="nav-item" data-view="accounts"><svg><use href="icons.svg#landmark"/></svg><span>银行账户</span></button> <button class="nav-item" data-view="accounts"><svg><use href="icons.svg#landmark"/></svg><span>银行账户</span></button>
<button class="nav-item" data-view="notifications"><svg><use href="icons.svg#bell"/></svg><span>通知</span><b>2</b></button> <button class="nav-item" data-view="notifications"><svg><use href="icons.svg#bell"/></svg><span>通知</span><b>2</b></button>
</nav> </nav>
@@ -37,20 +38,18 @@
</aside> </aside>
<div class="workspace"> <div class="workspace">
<header class="topbar">
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
<div class="workspace-name"><span>A公司</span><strong id="currentViewName">工作台</strong></div>
<span class="demo-badge">演示数据</span>
<div class="topbar-actions">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
</div>
</header>
<main id="main-content"> <main id="main-content">
<section class="app-view is-active" data-page="workspace"> <section class="app-view is-active" data-page="workspace">
<header class="page-heading"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p></div><span class="status warning" id="workspacePendingStatus">4 项待处理</span></header> <header class="page-heading dashboard-head"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p><span class="status warning" id="workspacePendingStatus">4 项待处理</span></div>
<div class="content-toolbar">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
<div class="toolbar-group">
<button class="icon-button" data-view-link="notifications" aria-label="通知,2 项未读" title="通知"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">2</i></button>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
</div>
</div>
</header>
<section class="metric-grid" aria-label="本公司关键指标"> <section class="metric-grid" aria-label="本公司关键指标">
<article class="metric-card danger" data-metric-action="upload" tabindex="0" role="button" aria-label="上传 1 个待补流水账户"> <article class="metric-card danger" data-metric-action="upload" tabindex="0" role="button" aria-label="上传 1 个待补流水账户">
<div class="metric-copy"><span class="metric-label">待上传账户</span><strong class="metric-value">1<small>/ 4</small></strong></div> <div class="metric-copy"><span class="metric-label">待上传账户</span><strong class="metric-value">1<small>/ 4</small></strong></div>
@@ -74,25 +73,23 @@
</article> </article>
</section> </section>
<section class="company-alert"><svg><use href="icons.svg#circle-alert"/></svg><div><strong>7 月数据尚不能结账</strong><p>工商银行账户缺少 07.01—07.21 流水,补齐后系统将重新计算匹配结果。</p></div><button class="button primary" data-open-upload>补传流水</button></section> <section class="company-alert"><svg><use href="icons.svg#circle-alert"/></svg><div><strong>7 月数据尚不能结账</strong><p>工商银行账户缺少 07.01—07.21 流水,补齐后系统将重新计算匹配结果。</p></div><button class="button primary" data-open-upload>补传流水</button></section>
<section class="panel deck-panel" aria-label="账户覆盖卡栈"> <section class="panel company-stack-panel" aria-label="账户覆盖">
<div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户 · 荧光绿为当前连续账户</p></div><span class="status warning">3 / 4 连续</span></div> <div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户</p></div><span class="status warning">3 / 4 连续</span></div>
<div class="stack-deck" role="list"> <div class="company-list" role="list">
<div class="deck-stage"> <article class="company-row" role="listitem" data-detail="acct-citic" tabindex="0" aria-label="中信银行 5316 全部连续,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>中信银行 · 5316</strong><small>基本户 · 最近导入 09:42</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="中信银行 5316 全部连续" style="--deck-offset:-200px;--deck-rotate:-14deg;--deck-scale:0.94;opacity:0.92;z-index:2"><span class="deck-pos">04</span><span class="deck-mark"></span><div class="deck-name"><strong>中信银行 · 5316</strong><small>基本户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 09:42</span><em class="status success">连续</em></div></article> <article class="company-row" role="listitem" data-detail="acct-abc" tabindex="0" aria-label="农业银行 3650 全部连续,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>农业银行 · 3650</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="农业银行 3650 全部连续" style="--deck-offset:-90px;--deck-rotate:-6deg;--deck-scale:0.98;opacity:0.96;z-index:3"><span class="deck-pos">03</span><span class="deck-mark"></span><div class="deck-name"><strong>农业银行 · 3650</strong><small>一般户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 08.01</span><em class="status success">连续</em></div></article> <article class="company-row is-danger" role="listitem" data-detail="acct-icbc" tabindex="0" aria-label="工商银行 9481 缺少 21 天,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>工商银行 · 9481</strong><small>一般户 · 已逾期 2 天</small></div><div class="company-row-figure"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><em class="status danger">断档</em></article>
<article class="deck-card is-active is-danger" role="listitem" data-open-upload tabindex="0" aria-label="工商银行 9481 缺少 21 天" style="transform:translate(0,0) rotate(0deg) scale(1);z-index:5"><span class="deck-pos">01</span><span class="deck-mark"></span><div class="deck-name"><strong>工商银行 · 9481</strong><small>一般户</small></div><div class="deck-meta"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><div class="deck-foot"><span>已逾期 2 天</span><em class="status danger">断档</em></div></article> <article class="company-row" role="listitem" data-detail="acct-ccb" tabindex="0" aria-label="建设银行 0845 全部连续,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>建设银行 · 0845</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="建设银行 0845 全部连续" style="--deck-offset:90px;--deck-rotate:6deg;--deck-scale:0.98;opacity:0.96;z-index:3"><span class="deck-pos">02</span><span class="deck-mark"></span><div class="deck-name"><strong>建设银行 · 0845</strong><small>一般户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 08.01</span><em class="status success">连续</em></div></article>
</div>
</div> </div>
</section> </section>
<div class="company-dashboard-grid"> <div class="company-dashboard-grid">
<section class="panel task-panel"> <section class="panel task-panel">
<div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div> <div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div>
<div class="cashier-tasks"> <div class="cashier-tasks">
<article data-task-type="upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article> <article data-task-type="upload" data-detail="task-upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
<article data-task-type="match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article> <article data-task-type="match" data-detail="task-match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
<article data-task-type="subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article> <article data-task-type="subject" data-detail="task-subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
<article><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article> <article data-detail="task-notice"><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
</div> </div>
</section> </section>
<aside class="panel coverage-panel"> <aside class="panel coverage-panel">
@@ -102,11 +99,38 @@
</aside> </aside>
</div> </div>
<section class="work-progress" aria-label="本月处理进度"><div><span>1</span><p><strong>流水上传</strong><small>3 / 4 个账户完成</small></p></div><i></i><div><span>2</span><p><strong>异常处理</strong><small>2 项待确认</small></p></div><i></i><div class="pending"><span>3</span><p><strong>公司确认</strong><small>尚未提交</small></p></div><i></i><div class="pending"><span>4</span><p><strong>集团结账</strong><small>次月 5 日</small></p></div></section> <section class="work-progress" aria-label="本月处理进度"><div><span>1</span><p><strong>流水上传</strong><small>3 / 4 个账户完成</small></p></div><i></i><div><span>2</span><p><strong>异常处理</strong><small>2 项待确认</small></p></div><i></i><div class="pending"><span>3</span><p><strong>公司确认</strong><small>尚未提交</small></p></div><i></i><div class="pending"><span>4</span><p><strong>集团结账</strong><small>次月 5 日</small></p></div></section>
<section class="panel timeline-panel" aria-label="账期时间轴">
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status warning">距 7 月结账日 08.05 还有 3 天</span></div>
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
<div class="timeline-bar">
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
<i class="timeline-today" style="left:99.1%"></i>
</div>
<div class="timeline-ticks" aria-hidden="true">
<span style="left:0">01.01<small>起算日</small></span>
<span style="left:14%">2月</span>
<span style="left:26.7%">3月</span>
<span style="left:40.7%">4月</span>
<span style="left:54.3%">5月</span>
<span style="left:68.3%">6月</span>
<span style="left:81.9%">7月</span>
<span style="left:95.9%">8月</span>
</div>
</div>
<ul class="timeline-legend">
<li><i class="dot success"></i>1—6 月已结账</li>
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
<li><i class="dot today"></i>今天 08.08</li>
</ul>
</section>
</section> </section>
<section class="app-view" data-page="upload"> <section class="app-view" data-page="upload">
<header class="page-heading"><div><h1>流水导入</h1><p>上传 A公司银行账户流水,系统按表头识别银行模板</p></div><button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button></header> <header class="page-heading"><div><h1>流水导入</h1><p>上传 A公司银行账户流水,系统按表头识别银行模板</p></div><button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button></header>
<section class="panel"><div class="table-summary"><span>最近导入批次</span><span>原始文件与解析结果将永久保留</span></div><div class="table-scroll"><table class="data-table"><thead><tr><th>批次</th><th>银行账户</th><th>流水期间</th><th>明细数</th><th>覆盖状态</th><th>解析状态</th><th>上传时间</th></tr></thead><tbody id="importRows"><tr><td><strong>IMP-260806-018</strong></td><td>中信银行 · 5316</td><td>07.01—07.31</td><td>128</td><td><span class="status success">连续</span></td><td><span class="status success">已确认</span></td><td>今天 09:42</td></tr><tr><td><strong>IMP-260731-012</strong></td><td>工商银行 · 9481</td><td>06.01—06.30</td><td>96</td><td><span class="status danger">后续断档</span></td><td><span class="status success">已确认</span></td><td>07.31 16:18</td></tr></tbody></table></div></section> <section class="panel"><div class="table-summary"><span>最近导入批次</span><span>仅已确认工作表参与匹配与计算</span></div><div class="table-scroll"><table class="data-table"><thead><tr><th>批次</th><th>银行账户</th><th>流水期间</th><th>明细数</th><th>覆盖状态</th><th>解析状态</th><th>上传时间</th></tr></thead><tbody id="importRows"></tbody></table></div></section>
</section> </section>
<section class="app-view" data-page="manual"> <section class="app-view" data-page="manual">
@@ -116,8 +140,8 @@
<div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div> <div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div>
<div class="form-body"> <div class="form-body">
<div class="form-grid"><label class="field"><span>交易日期</span><input name="transactionDate" type="date" value="2026-08-06" required /></label><label class="field"><span>收付方向</span><select name="direction" required><option>付款</option><option>收款</option></select></label></div> <div class="form-grid"><label class="field"><span>交易日期</span><input name="transactionDate" type="date" value="2026-08-06" required /></label><label class="field"><span>收付方向</span><select name="direction" required><option>付款</option><option>收款</option></select></label></div>
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option><option>个人过账</option></select></label></div> <div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>个人过账</option></select></label></div>
<div class="form-grid"><label class="field"><span>对方类型</span><select name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></label><label class="field"><span>对方名称</span><input name="counterparty" maxlength="100" placeholder="公司全称或个人姓名" required /></label></div> <div class="form-grid"><label class="field"><span>对方类型</span><select name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></label><label class="field"><span>对方公司</span><select name="counterparty" required><option value="">请选择集团内部公司</option></select></label></div>
<div class="form-grid"><label class="field"><span>对方账号</span><input name="counterpartyAccount" maxlength="64" placeholder="可选" /></label><label class="field"><span>往来科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label></div> <div class="form-grid"><label class="field"><span>对方账号</span><input name="counterpartyAccount" maxlength="64" placeholder="可选" /></label><label class="field"><span>往来科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label></div>
<label class="field"><span>业务摘要</span><input name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></label> <label class="field"><span>业务摘要</span><input name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></label>
<label class="field"><span>补充说明</span><textarea name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></label> <label class="field"><span>补充说明</span><textarea name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></label>
@@ -139,6 +163,25 @@
<section class="panel"><div class="table-summary"><span>当前结果 <strong id="flowCount">3</strong></span><span>金额单位:元</span></div><div class="table-scroll"><table class="data-table" id="flowTable"><thead><tr><th>日期</th><th>银行账户</th><th>方向</th><th>对方户名 / 账号</th><th>摘要</th><th>银行流水号</th><th>归集状态</th><th class="number">金额</th></tr></thead><tbody><tr data-bank="中信银行"><td>2026.07.18</td><td>中信银行 · 5316</td><td>转出</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>往来款</td><td>CIT260718018</td><td><span class="status success">双边匹配</span></td><td class="number">10,000,000.00</td></tr><tr data-bank="建设银行"><td>2026.07.12</td><td>建设银行 · 0845</td><td>转出</td><td><strong>A公司</strong><small>尾号 5316</small></td><td>同名账户调拨</td><td>CCB260712031</td><td><span class="status neutral">同公司调拨</span></td><td class="number">2,000,000.00</td></tr><tr data-bank="中信银行"><td>2026.07.06</td><td>中信银行 · 5316</td><td>转入</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>归还往来款</td><td>CIT260706041</td><td><span class="status success">双边匹配</span></td><td class="number">3,200,000.00</td></tr></tbody></table></div></section> <section class="panel"><div class="table-summary"><span>当前结果 <strong id="flowCount">3</strong></span><span>金额单位:元</span></div><div class="table-scroll"><table class="data-table" id="flowTable"><thead><tr><th>日期</th><th>银行账户</th><th>方向</th><th>对方户名 / 账号</th><th>摘要</th><th>银行流水号</th><th>归集状态</th><th class="number">金额</th></tr></thead><tbody><tr data-bank="中信银行"><td>2026.07.18</td><td>中信银行 · 5316</td><td>转出</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>往来款</td><td>CIT260718018</td><td><span class="status success">双边匹配</span></td><td class="number">10,000,000.00</td></tr><tr data-bank="建设银行"><td>2026.07.12</td><td>建设银行 · 0845</td><td>转出</td><td><strong>A公司</strong><small>尾号 5316</small></td><td>同名账户调拨</td><td>CCB260712031</td><td><span class="status neutral">同公司调拨</span></td><td class="number">2,000,000.00</td></tr><tr data-bank="中信银行"><td>2026.07.06</td><td>中信银行 · 5316</td><td>转入</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>归还往来款</td><td>CIT260706041</td><td><span class="status success">双边匹配</span></td><td class="number">3,200,000.00</td></tr></tbody></table></div></section>
</section> </section>
<section class="app-view" data-page="balances">
<header class="page-heading"><div><h1>往来余额</h1><p>本公司往来余额、对方公司明细与允许查看的源证据</p></div>
<span class="status neutral" id="companyOpeningNote">期初不可用 · 仅显示期间净变动</span>
</header>
<div class="company-alert balance-alert" id="companyUnresolvedAlert" hidden>
<svg><use href="icons.svg#circle-alert"/></svg>
<div><strong id="companyUnresolvedText"></strong><p>未确认事项不影响已确认余额,处理前请先核对证据。</p></div>
<button class="button secondary" data-view-link="reconcile">去确认</button>
</div>
<section class="pair-report" id="companyBalanceSummary">
<header class="pair-report-heading"><div><h2>本公司往来汇总</h2><p id="companyBalancePeriod">统计口径</p></div></header>
<div id="companyBalanceGroups" class="balance-currency-groups"></div>
</section>
<section class="panel company-stack-panel">
<div class="panel-heading"><div><h2>对方公司明细</h2><p>点击任意对方公司查看往来事件与允许的源证据</p></div></div>
<div class="company-list" id="companyCounterparties" aria-live="polite"></div>
</section>
</section>
<section class="app-view" data-page="reconcile"> <section class="app-view" data-page="reconcile">
<header class="page-heading"><div><h1>往来确认</h1><p>系统计算为主,只处理无法确定的匹配与科目</p></div></header> <header class="page-heading"><div><h1>往来确认</h1><p>系统计算为主,只处理无法确定的匹配与科目</p></div></header>
<section class="reconcile-summary"><div><span>自动确认</span><strong>18 笔</strong><small>无需人工处理</small></div><div><span>单边待匹配</span><strong id="matchPendingCount">1 笔</strong><small>需选择对方证据</small></div><div><span>科目待确认</span><strong id="subjectPendingCount">1 笔</strong><small>需选择会计科目</small></div></section> <section class="reconcile-summary"><div><span>自动确认</span><strong>18 笔</strong><small>无需人工处理</small></div><div><span>单边待匹配</span><strong id="matchPendingCount">1 笔</strong><small>需选择对方证据</small></div><div><span>科目待确认</span><strong id="subjectPendingCount">1 笔</strong><small>需选择会计科目</small></div></section>
@@ -147,7 +190,7 @@
<section class="app-view" data-page="accounts"> <section class="app-view" data-page="accounts">
<header class="page-heading"><div><h1>银行账户</h1><p>A公司登记账户与流水覆盖区间</p></div><button class="button secondary" id="openAccountDialog"><svg><use href="icons.svg#plus"/></svg>登记账户</button></header> <header class="page-heading"><div><h1>银行账户</h1><p>A公司登记账户与流水覆盖区间</p></div><button class="button secondary" id="openAccountDialog"><svg><use href="icons.svg#plus"/></svg>登记账户</button></header>
<section class="account-directory" id="accountDirectory"><article><header><span class="bank-mark"></span><div><strong>中信银行</strong><small>基本户 · 尾号 5316</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>今天 09:42</dd></div></dl></article><article><header><span class="bank-mark"></span><div><strong>工商银行</strong><small>一般户 · 尾号 9481</small></div><em class="status danger">断档</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>缺 07.01—07.21</dd></div><div><dt>最近导入</dt><dd>07.31 16:18</dd></div></dl></article><article><header><span class="bank-mark"></span><div><strong>建设银行</strong><small>一般户 · 尾号 0845</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>08.01 10:03</dd></div></dl></article></section> <section class="account-directory" id="accountDirectory"></section>
</section> </section>
<section class="app-view" data-page="notifications"> <section class="app-view" data-page="notifications">
@@ -161,7 +204,7 @@
<dialog id="uploadDialog" class="dialog upload-dialog"> <dialog id="uploadDialog" class="dialog upload-dialog">
<form id="uploadForm"> <form id="uploadForm">
<header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header> <header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div> <div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong id="parseTitle">文件解析完成</strong><p id="parseSummary"></p></span></div><div class="sheet-review" id="sheetReview" hidden><h3>工作表处理</h3><p class="sheet-review-hint">解析成功不等于业务确认:只有勾选并确认的工作表才会进入后续匹配与计算。</p><div class="sheet-list" id="sheetList" aria-live="polite"></div></div></div>
<footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer> <footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer>
</form> </form>
</dialog> </dialog>
@@ -172,6 +215,17 @@
<footer><button type="button" class="button secondary" data-close-account>取消</button><button class="button primary" type="submit">提交登记</button></footer> <footer><button type="button" class="button secondary" data-close-account>取消</button><button class="button primary" type="submit">提交登记</button></footer>
</form> </form>
</dialog> </dialog>
<div class="drawer-scrim" id="drawerScrim" data-close-drawer aria-hidden="true"></div>
<aside class="drawer" id="evidenceDrawer" aria-label="往来明细与源行证据抽屉" aria-hidden="true">
<header class="drawer-header">
<div>
<div class="drawer-breadcrumb" id="drawerBreadcrumb"></div>
<h2 id="drawerTitle">往来明细</h2>
</div>
<button type="button" class="icon-button" data-close-drawer aria-label="关闭抽屉" title="关闭"><svg><use href="icons.svg#x"/></svg></button>
</header>
<div class="drawer-body" id="drawerBody" tabindex="-1"></div>
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div> <div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+19 -13
View File
@@ -3,44 +3,50 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="集团内部银行流水与往来账务管理" /> <meta name="description" content="河南金牛实业集团内部资金往来与银行流水管理" />
<title>登录 · 往来台账</title> <title>登录 · 河南金牛实业集团</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body class="entry-page"> <body class="entry-page">
<!-- <!--
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。 THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。 OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
STORY: 用户确认工作身份后进入拥有独立导航、功能侧重和数据边界的对应端口 STORY: 桌面双区入口:左侧确认集团身份、系统名称与账期状态,右侧选择工作端口后登录
FIRST VIEWPORT: 左侧用大标题和三个系统事实建立产品身份,右侧玻璃表单首先选择角色再登录 FIRST VIEWPORT: 横向双区;左侧入口信息,右侧 420-460px 登录表单。端口选择为紧凑分段控件
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。 FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
--> -->
<main class="entry-shell"> <main class="entry-shell">
<section class="entry-context" aria-labelledby="product-name"> <section class="entry-context" aria-labelledby="product-name">
<div class="entry-brand"><span class="brand-mark"></span><span><strong id="product-name">往来台账</strong><small>集团资金往来管理系统</small></span></div> <div class="entry-brand"><span class="brand-mark"></span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div>
<p class="entry-status"><span>服务状态</span><b>运行中</b></p>
<div class="entry-statement"> <div class="entry-statement">
<h1>一笔往来,追溯到双方银行凭证。</h1> <h1>一笔往来,追溯到双方银行凭证。</h1>
<p>当前演示环境 · 数据均为模拟</p> <p>选择与账号一致的工作端口后进入对应工作台。总账管理端查看集团全貌,公司业务端只处理本公司账务。</p>
</div> </div>
<dl class="entry-facts"> <dl class="entry-facts">
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div> <div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div> <div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
<div><dt>银行模板</dt><dd>支持 6 家</dd></div> <div><dt>覆盖银行</dt><dd>对接 6 家</dd></div>
</dl> </dl>
</section> </section>
<section class="entry-form-wrap" aria-labelledby="login-title"> <section class="entry-form-wrap" aria-labelledby="login-title">
<form class="entry-form" id="loginForm"> <form class="entry-form" id="loginForm">
<header><h2 id="login-title">登录往来台账</h2><p>请选择与账号一致的工作端口</p></header> <header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header>
<div class="role-switch" role="radiogroup" aria-label="工作端口"> <div class="role-switch" role="radiogroup" aria-label="工作端口">
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label> <label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small class="sr-only">集团管理员</small></span></label>
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label> <label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small class="sr-only">公司出纳</small></span></label>
</div> </div>
<label class="field"><span>账号</span><input name="username" autocomplete="username" value="group-admin" required /></label> <label class="field"><span>账号</span><input name="username" autocomplete="username" required /></label>
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" value="demo123456" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label> <label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
<div id="changePassword" hidden>
<p class="entry-note">首次登录须修改密码,请设置新密码后再进入工作台。</p>
<label class="field"><span>新密码</span><input name="new_password" type="password" autocomplete="new-password" /></label>
<label class="field"><span>确认新密码</span><input name="confirm_password" type="password" autocomplete="new-password" /></label>
</div>
<p class="entry-note" id="loginError" role="alert" hidden></p>
<label class="check-field"><input type="checkbox" checked />记住本次登录</label> <label class="check-field"><input type="checkbox" checked />记住本次登录</label>
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button> <button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
<p class="entry-note">演示账号不会验证密码,正式系统将由服务端校验账号与公司绑定关系。</p>
</form> </form>
</section> </section>
</main> </main>
+436 -124
View File
@@ -2,12 +2,12 @@
color-scheme: dark; color-scheme: dark;
--font-ui: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif; --font-ui: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
--font-data: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif; --font-data: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
--color-bg: #050706; --color-bg: #050505;
--color-bg-soft: #090d0b; --color-bg-soft: #0a0a0a;
--color-nav: #090c0b; --color-nav: #0a0a0a;
--color-surface: rgba(20, 26, 23, 0.82); --color-surface: rgba(22, 22, 22, 0.82);
--color-surface-solid: #131916; --color-surface-solid: #161616;
--color-surface-raised: #18201c; --color-surface-raised: #1c1c1c;
--color-surface-muted: rgba(255, 255, 255, 0.035); --color-surface-muted: rgba(255, 255, 255, 0.035);
--color-line: rgba(255, 255, 255, 0.09); --color-line: rgba(255, 255, 255, 0.09);
--color-line-strong: rgba(255, 255, 255, 0.16); --color-line-strong: rgba(255, 255, 255, 0.16);
@@ -26,12 +26,14 @@
--color-danger-wash: rgba(255, 98, 109, 0.11); --color-danger-wash: rgba(255, 98, 109, 0.11);
--color-info: #66a8ff; --color-info: #66a8ff;
--color-info-wash: rgba(102, 168, 255, 0.11); --color-info-wash: rgba(102, 168, 255, 0.11);
--row-h-evidence: 55px;
--z-drawer: 150;
--radius-sm: 10px; --radius-sm: 10px;
--radius-md: 16px; --radius-md: 16px;
--radius-lg: 22px; --radius-lg: 22px;
--radius-xl: 28px; --radius-xl: 28px;
--shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035); --shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035);
--shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.025); --shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.06), inset 0 -1px 0 rgba(0, 0, 0, 0.22);
--shadow-glow: 0 0 28px rgba(55, 235, 137, 0.12); --shadow-glow: 0 0 28px rgba(55, 235, 137, 0.12);
--duration-fast: 150ms; --duration-fast: 150ms;
--duration-standard: 260ms; --duration-standard: 260ms;
@@ -40,7 +42,7 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { min-width: 320px; background: var(--color-bg); scroll-behavior: smooth; } html { min-width: 320px; background: var(--color-bg); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 13px/1.55 var(--font-ui); -webkit-font-smoothing: antialiased; } body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 14px/1.6 var(--font-ui); -webkit-font-smoothing: antialiased; }
button, input, select, textarea { font: inherit; } button, input, select, textarea { font: inherit; }
button, a, summary { -webkit-tap-highlight-color: transparent; } button, a, summary { -webkit-tap-highlight-color: transparent; }
button { color: inherit; } button { color: inherit; }
@@ -60,9 +62,9 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
/* Application frame */ /* Application frame */
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 252px minmax(0, 1fr); } .app-shell { min-height: 100vh; display: grid; grid-template-columns: 266px minmax(0, 1fr); }
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 234px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 16, 14, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); } .sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 248px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 12, 12, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 15px; border-bottom: 1px solid var(--color-line); } .brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 20px; border-bottom: 1px solid var(--color-line); }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; } .brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; }
.brand-copy { display: flex; flex-direction: column; min-width: 0; } .brand-copy { display: flex; flex-direction: column; min-width: 0; }
.brand-copy strong { font-size: 15px; } .brand-copy strong { font-size: 15px; }
@@ -70,8 +72,8 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
.company-context { min-height: 64px; display: flex; align-items: center; gap: 10px; margin: 12px 4px 2px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); } .company-context { min-height: 64px; display: flex; align-items: center; gap: 10px; margin: 12px 4px 2px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.company-context > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary); color: var(--color-primary-dark); font-weight: 800; } .company-context > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary); color: var(--color-primary-dark); font-weight: 800; }
.company-context div { display: flex; flex-direction: column; } .company-context div { display: flex; flex-direction: column; }
.nav-list { display: grid; gap: 5px; padding: 14px 2px; overflow-y: auto; } .nav-list { display: grid; gap: 8px; padding: 14px 2px; overflow-y: auto; }
.nav-item { position: relative; width: 100%; min-height: 43px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); } .nav-item { position: relative; width: 100%; min-height: 46px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
.nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); } .nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.075); color: var(--color-ink); box-shadow: inset 0 1px rgba(255, 255, 255, 0.045), 0 8px 22px rgba(0, 0, 0, 0.2); } .nav-item.is-active { border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.075); color: var(--color-ink); box-shadow: inset 0 1px rgba(255, 255, 255, 0.045), 0 8px 22px rgba(0, 0, 0, 0.2); }
.nav-item.is-active svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); } .nav-item.is-active svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); }
@@ -83,13 +85,14 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
.user-block > span:last-child { display: flex; flex-direction: column; } .user-block > span:last-child { display: flex; flex-direction: column; }
.avatar { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #242c28; color: var(--color-primary-strong); font-weight: 700; } .avatar { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #242c28; color: var(--color-primary-strong); font-weight: 700; }
.workspace { min-width: 0; grid-column: 2; } .workspace { min-width: 0; grid-column: 2; }
.topbar { position: sticky; top: 0; z-index: 90; height: 78px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 7, 6, 0.82); backdrop-filter: blur(22px); } .topbar { position: sticky; top: 0; z-index: 90; height: 56px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 5, 5, 0.82); backdrop-filter: blur(22px); }
.topbar-spacer { flex: 1; }
.workspace-name { display: flex; flex-direction: column; min-width: 150px; } .workspace-name { display: flex; flex-direction: column; min-width: 150px; }
.workspace-name span { color: var(--color-ink-muted); font-size: 10px; } .workspace-name span { color: var(--color-ink-muted); font-size: 10px; }
.workspace-name strong { font-size: 14px; } .workspace-name strong { font-size: 14px; }
.demo-badge { padding: 3px 8px; border: 1px solid rgba(255, 188, 82, 0.22); border-radius: 8px; background: var(--color-warning-wash); color: var(--color-warning); font-size: 10px; } .demo-badge { padding: 3px 8px; border: 1px solid rgba(255, 188, 82, 0.22); border-radius: 8px; background: var(--color-warning-wash); color: var(--color-warning); font-size: 10px; }
.topbar-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; } .topbar-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.search-box { width: clamp(230px, 26vw, 390px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); } .search-box { width: clamp(200px, 22vw, 340px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
.search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); } .search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); } .search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.search-box input::placeholder, input::placeholder, textarea::placeholder { color: #626b66; } .search-box input::placeholder, input::placeholder, textarea::placeholder { color: #626b66; }
@@ -100,12 +103,16 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
.period-button:hover, .icon-button:hover, .inside-icon:hover, .swap-button:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.07); color: var(--color-ink); transform: translateY(-1px); } .period-button:hover, .icon-button:hover, .inside-icon:hover, .swap-button:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.07); color: var(--color-ink); transform: translateY(-1px); }
.notification-dot { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; display: grid; place-items: center; border: 2px solid var(--color-bg); border-radius: 9px; background: var(--color-danger); color: #fff; font: 9px var(--font-data); } .notification-dot { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; display: grid; place-items: center; border: 2px solid var(--color-bg); border-radius: 9px; background: var(--color-danger); color: #fff; font: 9px var(--font-data); }
.menu-button { display: none; } .menu-button { display: none; }
main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; } main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
.app-view { display: none; } .app-view { display: none; }
.app-view.is-active { display: block; } .app-view.is-active { display: block; }
.page-heading { min-height: 70px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; } .page-heading { min-height: 56px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.page-heading h1 { font-size: clamp(26px, 3vw, 35px); line-height: 1.15; letter-spacing: 0; } .page-heading h1 { font-size: clamp(22px, 2.4vw, 30px); line-height: 1.2; letter-spacing: 0; }
.page-heading p { margin-top: 7px; color: var(--color-ink-muted); } .page-heading p { margin-top: 5px; color: var(--color-ink-muted); }
.content-toolbar { display: contents; }
.content-toolbar > .search-box { margin-inline: auto; width: clamp(260px, 42vw, 520px); }
.content-toolbar .toolbar-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.dashboard-head { align-items: center; gap: 20px; margin-bottom: 20px; }
/* Commands and fields */ /* Commands and fields */
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; font-weight: 700; cursor: pointer; transition: transform var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast); } .button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; font-weight: 700; cursor: pointer; transition: transform var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast); }
@@ -135,8 +142,9 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; } .swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; }
/* Glass surfaces */ /* Glass surfaces */
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { position: relative; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); } .panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { position: relative; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.82) 0%, rgba(16, 16, 16, 0.86) 50%, rgba(10, 10, 10, 0.88) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 32%); pointer-events: none; } .panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, rgba(255, 255, 255, 0) 70%); pointer-events: none; }
.panel:hover { box-shadow: 0 20px 48px rgba(0, 0, 0, 0.32), inset 0 1px rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
.panel { min-width: 0; overflow: hidden; } .panel { min-width: 0; overflow: hidden; }
.panel > * { position: relative; } .panel > * { position: relative; }
.panel-heading { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line); } .panel-heading { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line); }
@@ -153,14 +161,15 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); } .task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
/* Dashboard */ /* Dashboard */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin: 0 0 18px; } .metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0 0 18px; }
.metric-card { position: relative; min-height: 142px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 20px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); } .metric-card { position: relative; min-height: 164px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 24px 22px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.86) 0%, rgba(14, 14, 14, 0.88) 50%, rgba(8, 8, 8, 0.9) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transform: perspective(900px) rotateX(0.6deg) rotateY(-0.8deg); transform-origin: center bottom; transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 38%); pointer-events: none; } .metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 42%, rgba(255, 255, 255, 0) 72%); pointer-events: none; }
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; } .metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; transition: opacity var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card:hover { transform: translateY(-4px); border-color: rgba(255, 255, 255, 0.17); box-shadow: 0 24px 54px rgba(0, 0, 0, 0.3); } .metric-card:hover::after { opacity: 0.7; box-shadow: 0 -10px 34px currentColor; }
.metric-card:hover { transform: perspective(900px) rotateX(3.5deg) rotateY(-4.5deg) translateY(-10px); border-color: rgba(255, 255, 255, 0.32); box-shadow: 0 36px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 1px rgba(255, 255, 255, 0.14); }
.metric-copy { align-self: start; } .metric-copy { align-self: start; }
.metric-label { display: block; color: var(--color-ink-soft); font-size: 12px; } .metric-label { display: block; color: var(--color-ink-soft); font-size: 13px; }
.metric-value { display: block; margin-top: 7px; font: 700 31px/1 var(--font-data); letter-spacing: 0; } .metric-value { display: block; margin-top: 7px; font: 700 34px/1.05 var(--font-data); letter-spacing: 0; }
.metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); } .metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); }
.metric-icon { position: relative; width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--color-line); border-radius: 50%; background: radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 70%); color: var(--color-ink-soft); box-shadow: inset 0 1px rgba(255, 255, 255, 0.05); } .metric-icon { position: relative; width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--color-line); border-radius: 50%; background: radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 70%); color: var(--color-ink-soft); box-shadow: inset 0 1px rgba(255, 255, 255, 0.05); }
.metric-icon::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.12; pointer-events: none; } .metric-icon::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.12; pointer-events: none; }
@@ -202,49 +211,24 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.quick-result strong { display: block; margin: 4px 0; color: var(--color-primary-strong); font: 700 22px var(--font-data); } .quick-result strong { display: block; margin: 4px 0; color: var(--color-primary-strong); font: 700 22px var(--font-data); }
.quick-result small { margin-left: 4px; font: 10px var(--font-ui); } .quick-result small { margin-left: 4px; font: 10px var(--font-ui); }
/* Stack-deck: signature 3D card-stack visual */ /* Key-company / account-coverage flat list (replaces the floating 3D card stack) */
.stack-deck { position: relative; height: 188px; perspective: 1400px; perspective-origin: 50% 60%; } .company-list { display: grid; gap: 8px; padding: 10px 12px 14px; }
.deck-stage { position: absolute; inset: 0; display: grid; place-items: center; } .company-row { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 62px; padding: 11px 13px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(22, 22, 22, 0.7) 0%, rgba(12, 12, 12, 0.75) 100%); cursor: pointer; transition: background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard); }
.deck-card { position: absolute; width: 184px; min-height: 158px; display: flex; flex-direction: column; justify-content: space-between; gap: 14px; padding: 16px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(180deg, rgba(28, 36, 32, 0.92), rgba(14, 19, 16, 0.92)); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35), inset 0 1px rgba(255, 255, 255, 0.05); transform-origin: 50% 100%; transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard), opacity var(--duration-standard); cursor: pointer; } .company-row:hover { background: linear-gradient(160deg, rgba(32, 32, 32, 0.8) 0%, rgba(18, 18, 18, 0.85) 100%); border-color: var(--color-line-strong); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); }
.deck-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0) 36%); pointer-events: none; } .company-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
.deck-card > * { position: relative; } .company-row-mark { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
.deck-card .deck-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; } .company-row.is-warning .company-row-mark { background: var(--color-warning-wash); color: var(--color-warning); }
.deck-card .deck-name { display: flex; flex-direction: column; } .company-row.is-danger .company-row-mark { background: var(--color-danger-wash); color: var(--color-danger); }
.deck-card .deck-name strong { font-size: 14px; } .company-row-body { display: flex; flex-direction: column; min-width: 0; }
.deck-card .deck-name small { color: var(--color-ink-muted); font-size: 11px; } .company-row-body strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.deck-card .deck-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .company-row-body small { color: var(--color-ink-muted); font-size: 11px; }
.deck-card .deck-meta strong { font: 600 16px var(--font-data); color: var(--color-ink); } .company-row-figure { display: flex; flex-direction: column; align-items: flex-end; text-align: right; min-width: 92px; }
.deck-card .deck-meta small { color: var(--color-ink-muted); font-size: 11px; } .company-row-figure strong { font: 600 14px var(--font-data); color: var(--color-ink); }
.deck-card .deck-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 10px; color: var(--color-ink-muted); } .company-row-figure small { color: var(--color-ink-muted); font-size: 11px; }
.deck-card.is-active { border-color: rgba(55, 235, 137, 0.55); background: linear-gradient(180deg, rgba(20, 56, 36, 0.95), rgba(8, 22, 14, 0.95)); box-shadow: 0 28px 60px rgba(0, 0, 0, 0.45), 0 0 36px rgba(55, 235, 137, 0.22), inset 0 1px rgba(112, 255, 174, 0.18); } .company-row .status { flex: 0 0 auto; }
.deck-card.is-active .deck-mark { background: rgba(255, 255, 255, 0.08); color: var(--color-primary-strong); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.4); }
.deck-card.is-active .deck-name strong { color: var(--color-primary-strong); }
.deck-card.is-warning { border-color: rgba(255, 188, 82, 0.35); }
.deck-card.is-danger { border-color: rgba(255, 98, 109, 0.35); }
.deck-card .deck-pos { position: absolute; top: 12px; right: 12px; font: 700 10px var(--font-data); color: var(--color-ink-muted); letter-spacing: 0.06em; }
.deck-card.is-active .deck-pos { color: var(--color-primary-strong); }
/* Stack-deck fan layout */
.deck-stage .deck-card:hover { transform: translateY(-12px) scale(1.02); box-shadow: 0 28px 60px rgba(0, 0, 0, 0.5); opacity: 1; z-index: 6; }
.deck-stage .deck-card.is-active:hover { transform: translateY(-10px) scale(1.04); box-shadow: 0 32px 70px rgba(0, 0, 0, 0.55), 0 0 40px rgba(55, 235, 137, 0.3), inset 0 1px rgba(112, 255, 174, 0.18); }
.deck-panel { padding-bottom: 6px; overflow: visible; }
.deck-panel > .panel-heading { background: var(--color-surface); position: relative; z-index: 7; }
.deck-panel .stack-deck { overflow: visible; padding: 12px 16px 18px; }
/* Deck card fan layout driven by --deck-offset / --deck-rotate */
.deck-card { --deck-offset: 0px; --deck-rotate: 0deg; --deck-scale: 1; transform: translate(var(--deck-offset), 6px) rotate(var(--deck-rotate)) scale(var(--deck-scale)); }
.deck-stage .deck-card:nth-child(1) { --deck-offset: -280px; --deck-rotate: -22deg; --deck-scale: 0.92; z-index: 1; opacity: 0.85; }
.deck-stage .deck-card:nth-child(2) { --deck-offset: -165px; --deck-rotate: -12deg; --deck-scale: 0.96; z-index: 2; opacity: 0.92; }
.deck-stage .deck-card:nth-child(3) { --deck-offset: -55px; --deck-rotate: -4deg; z-index: 3; }
.deck-stage .deck-card:nth-child(4) { --deck-offset: 55px; --deck-rotate: 4deg; z-index: 4; }
.deck-stage .deck-card:nth-child(5) { --deck-offset: 165px; --deck-rotate: 12deg; --deck-scale: 0.96; z-index: 2; opacity: 0.92; }
.deck-stage .deck-card:nth-child(6) { --deck-offset: 280px; --deck-rotate: 22deg; --deck-scale: 0.92; z-index: 1; opacity: 0.85; }
.deck-stage .deck-card:nth-child(7) { --deck-offset: 395px; --deck-rotate: 30deg; --deck-scale: 0.88; z-index: 0; opacity: 0.7; }
.deck-stage .deck-card:nth-child(8) { --deck-offset: -395px; --deck-rotate: -30deg; --deck-scale: 0.88; z-index: 0; opacity: 0.7; }
.deck-card.is-active { z-index: 5; }
/* Timeline-strip: horizontal calculation window ribbon */ /* Timeline-strip: horizontal calculation window ribbon */
.timeline-strip { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 14px; align-items: stretch; padding: 16px 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(180deg, rgba(20, 26, 23, 0.86), rgba(10, 14, 12, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); } .timeline-strip { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 14px; align-items: stretch; padding: 16px 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(180deg, rgba(22, 22, 22, 0.86), rgba(10, 10, 10, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
.timeline-strip::before { content: ""; position: absolute; } .timeline-strip::before { content: ""; position: absolute; }
.timeline-legend { display: flex; flex-direction: column; justify-content: center; gap: 6px; } .timeline-legend { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
.timeline-legend strong { font-size: 13px; } .timeline-legend strong { font-size: 13px; }
@@ -277,8 +261,9 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); } .company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); }
.company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); } .company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); }
.company-ledger[open] summary > svg { transform: rotate(180deg); } .company-ledger[open] summary > svg { transform: rotate(180deg); }
.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; } .company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; min-width: 0; }
.company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; } .company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; }
.company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.company-name small { color: var(--color-ink-muted); } .company-name small { color: var(--color-ink-muted); }
.amount { font: 600 13px var(--font-data); } .amount { font: 600 13px var(--font-data); }
.amount.debit { color: var(--color-positive); } .amount.debit { color: var(--color-positive); }
@@ -409,7 +394,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.manual-records-table { min-width: 900px; } .manual-records-table { min-width: 900px; }
/* Dialogs and feedback */ /* Dialogs and feedback */
.dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 23, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); } .dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 17, 17, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
.dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); } .dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); }
.dialog > form > header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--color-line); } .dialog > form > header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--color-line); }
.dialog header p { color: var(--color-ink-muted); } .dialog header p { color: var(--color-ink-muted); }
@@ -430,39 +415,81 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.parse-result p { color: #9bd7b6; } .parse-result p { color: #9bd7b6; }
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); } .parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
.parse-result.is-exception p { color: #e7bd78; } .parse-result.is-exception p { color: #e7bd78; }
.sheet-review { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); }
.sheet-review h3 { font-size: 14px; }
.sheet-review-hint { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-list { display: grid; gap: 8px; margin-top: 10px; max-height: 220px; overflow-y: auto; }
.sheet-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.sheet-item.is-pending { border-color: rgba(255, 188, 82, 0.45); }
.sheet-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sheet-item-head strong { font-size: 13px; }
.sheet-item-head small { color: var(--color-ink-muted); }
.sheet-item-meta { margin-top: 5px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-item-actions { display: flex; align-items: center; gap: 6px; }
.sheet-item-actions .text-button { font-size: 12px; padding: 4px 8px; }
.sheet-item-reason { margin-top: 6px; padding: 6px 8px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 12px; }
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; } .toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 26, 23, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); } .toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 20, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
.toast strong, .toast small { display: block; } .toast strong, .toast small { display: block; }
.toast small { color: var(--color-ink-muted); } .toast small { color: var(--color-ink-muted); }
/* Login */ /* Login */
.entry-page { overflow-x: hidden; } .entry-page { overflow-x: hidden; }
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); } .entry-shell { width: 100%; max-width: 1180px; min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr) minmax(460px, 520px); margin: 0 auto; padding: 0; }
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #070a08; } .entry-context { position: relative; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(28px, 4vw, 56px) clamp(28px, 4vw, 64px); border-right: 1px solid var(--color-line); background: var(--color-bg-soft); }
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); } .entry-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.entry-brand { display: flex; align-items: center; gap: 12px; } .entry-brand > span:last-child { display: flex; flex-direction: column; min-width: 0; }
.entry-brand > span:last-child { display: flex; flex-direction: column; } .entry-brand strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-brand small { color: var(--color-ink-muted); } .entry-brand small { color: var(--color-ink-muted); }
.entry-statement { max-width: 680px; margin: 80px 0; } .entry-status { display: flex; align-items: center; gap: 8px; margin: 14px 0 0; color: var(--color-ink-soft); font-size: 12px; }
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; } .entry-status span { color: var(--color-ink-muted); }
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); } .entry-status b { color: var(--color-ink); font-weight: 700; }
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; } .entry-status b::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--color-primary); vertical-align: 1px; }
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); } .entry-statement { max-width: 640px; margin: 48px 0 auto; }
.entry-facts dt { color: var(--color-ink-muted); } .entry-statement h1 { max-width: 620px; font-size: clamp(30px, 3.4vw, 48px); line-height: 1.18; letter-spacing: 0; }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); } .entry-statement p { margin-top: 12px; color: var(--color-ink-muted); max-width: 520px; }
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); } .entry-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; max-width: 640px; }
.entry-form { width: min(420px, 100%); display: grid; gap: 18px; padding: 28px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: var(--color-surface); box-shadow: var(--shadow-panel); backdrop-filter: blur(24px); } .entry-facts div { min-width: 0; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); background: var(--color-surface-muted); }
.entry-form header h2 { font-size: 25px; } .entry-facts dt { color: var(--color-ink-muted); font-size: 11px; }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); overflow-wrap: anywhere; }
.entry-form-wrap { min-width: 0; min-height: 100vh; display: grid; place-items: center; padding: 28px 24px; background: var(--color-bg); }
.entry-form { width: min(440px, 100%); display: grid; gap: 11px; padding: 22px 22px 20px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(18px); }
.entry-form header h2 { font-size: 20px; }
.entry-form header p { margin-top: 4px; font-size: 12px; }
.entry-form header p, .entry-note { color: var(--color-ink-muted); } .entry-form header p, .entry-note { color: var(--color-ink-muted); }
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } .entry-form #loginError { color: var(--color-danger); }
.role-switch label { cursor: pointer; } .entry-form .field { gap: 4px; }
.role-switch input { position: absolute; opacity: 0; } .entry-form .field input:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }
.role-switch span { min-height: 83px; display: grid; grid-template-columns: 30px 1fr; align-content: center; gap: 1px 9px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.14); transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); } .role-switch { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); } .role-switch label { position: relative; flex: 1; min-width: 0; cursor: pointer; }
.role-switch small { color: var(--color-ink-muted); } .role-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); } .role-switch span { min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); transition: color var(--duration-fast), background var(--duration-fast); }
.role-switch svg { width: 15px; height: 15px; flex: 0 0 auto; }
.role-switch b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.role-switch input:checked + span { background: var(--color-primary-wash); color: var(--color-ink); }
.role-switch input:checked + span svg { color: var(--color-primary); } .role-switch input:checked + span svg { color: var(--color-primary); }
.entry-note { font-size: 10px; } .role-switch input:focus-visible + span { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
.entry-note { font-size: 12px; }
@media (max-width: 1023px) {
.entry-shell { grid-template-columns: minmax(0, 1fr) minmax(400px, 440px); }
.entry-context { padding: 24px 20px; }
.entry-statement { margin: 28px 0 auto; }
.entry-statement h1 { font-size: clamp(24px, 3.6vw, 34px); }
.entry-facts { grid-template-columns: 1fr; max-width: none; }
.entry-form-wrap { padding: 20px 16px; }
.entry-form { width: min(400px, 100%); padding: 18px; gap: 10px; }
}
@media (max-width: 720px) {
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: auto; padding: 22px 16px 16px; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-statement { margin: 18px 0 14px; }
.entry-statement h1 { font-size: 24px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { min-height: auto; padding: 16px 16px 32px; align-content: start; }
.entry-form { width: 100%; padding: 16px; }
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.app-shell { grid-template-columns: 88px minmax(0, 1fr); } .app-shell { grid-template-columns: 88px minmax(0, 1fr); }
@@ -490,8 +517,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; } .pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
.pair-query-form .button { grid-column: 3; } .pair-query-form .button { grid-column: 3; }
.ledger-head { display: none; } .ledger-head { display: none; }
.company-ledger summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; } .company-ledger:not(.is-balances) summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
.company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; } .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
.ledger-breakdown { grid-template-columns: 1fr; } .ledger-breakdown { grid-template-columns: 1fr; }
.ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; } .ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; }
.pair-balance-line { grid-template-columns: repeat(3, 1fr); } .pair-balance-line { grid-template-columns: repeat(3, 1fr); }
@@ -499,9 +526,6 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.subject-strip { grid-template-columns: repeat(3, 1fr); } .subject-strip { grid-template-columns: repeat(3, 1fr); }
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; } .work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
.work-progress i:nth-of-type(2) { display: none; } .work-progress i:nth-of-type(2) { display: none; }
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: 58vh; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-form-wrap { min-height: auto; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -519,7 +543,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.demo-badge { padding: 2px 6px; } .demo-badge { padding: 2px 6px; }
.period-button { display: none; } .period-button { display: none; }
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; } .topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
main { padding: 22px 13px 42px; } main { padding: 12px 13px 42px; }
.page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; } .page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; }
.page-heading h1 { font-size: 27px; } .page-heading h1 { font-size: 27px; }
.page-heading > .button { align-self: stretch; } .page-heading > .button { align-self: stretch; }
@@ -537,8 +561,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.task-list button > b { display: none; } .task-list button > b { display: none; }
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; } .quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
.quick-result { margin-inline: 12px; } .quick-result { margin-inline: 12px; }
.company-ledger summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; } .company-ledger:not(.is-balances) summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
.company-ledger summary > strong:nth-of-type(2), .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; } .company-ledger:not(.is-balances) summary > strong:nth-of-type(2), .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
.company-name { grid-template-columns: 30px minmax(0, 1fr); } .company-name { grid-template-columns: 30px minmax(0, 1fr); }
.company-name i { width: 28px; height: 28px; } .company-name i { width: 28px; height: 28px; }
.filter-bar { align-items: stretch; flex-direction: column; } .filter-bar { align-items: stretch; flex-direction: column; }
@@ -575,12 +599,6 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.toast-region { right: 12px; bottom: 12px; left: 12px; } .toast-region { right: 12px; bottom: 12px; left: 12px; }
.toast { min-width: 0; max-width: none; } .toast { min-width: 0; max-width: none; }
.entry-context { min-height: auto; padding: 30px 22px; }
.entry-statement { margin: 54px 0 42px; }
.entry-statement h1 { font-size: 39px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { padding: 32px 16px 48px; }
.entry-form { padding: 22px; }
} }
@media (max-width: 460px) { @media (max-width: 460px) {
@@ -588,38 +606,332 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
.metric-copy { min-height: 52px; } .metric-copy { min-height: 52px; }
.metric-label { font-size: 11px; } .metric-label { font-size: 11px; }
.metric-value { font-size: 23px; } .metric-value { font-size: 23px; }
.role-switch { grid-template-columns: 1fr; }
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.stack-deck { height: 168px; }
.deck-card { width: 168px; min-height: 144px; padding: 14px; }
.deck-stage .deck-card:nth-child(1), .deck-stage .deck-card:nth-child(6) { opacity: 0; pointer-events: none; }
.deck-stage .deck-card:nth-child(1) { transform: translate(-260px, 14px) rotate(-22deg) scale(0.85); }
.deck-stage .deck-card:nth-child(6) { transform: translate(260px, 14px) rotate(22deg) scale(0.85); }
.deck-stage .deck-card:nth-child(2) { transform: translate(-160px, 6px) rotate(-12deg) scale(0.94); }
.deck-stage .deck-card:nth-child(5) { transform: translate(160px, 6px) rotate(12deg) scale(0.94); }
.timeline-strip { grid-template-columns: 1fr; } .timeline-strip { grid-template-columns: 1fr; }
.timeline-legend { padding-bottom: 4px; } .timeline-legend { padding-bottom: 4px; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.stack-deck { height: auto; padding: 0 4px 8px; } .company-row:not(.is-balance-counterparty) { grid-template-columns: 34px minmax(0, 1fr) auto; }
.deck-stage { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .company-row:not(.is-balance-counterparty) .company-row-figure { display: none; }
.deck-stage .deck-card { position: relative; transform: none !important; opacity: 1 !important; width: auto; }
.deck-card .deck-pos { right: 10px; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.stack-deck { height: auto; padding-bottom: 4px; }
.deck-stage { grid-template-columns: 1fr; gap: 8px; }
.deck-card { width: auto; min-height: 0; padding: 12px; }
.timeline-strip { padding: 14px; } .timeline-strip { padding: 14px; }
.timeline-rows { gap: 6px; } .timeline-rows { gap: 6px; }
.timeline-row { grid-template-columns: 80px minmax(0, 1fr); } .timeline-row { grid-template-columns: 80px minmax(0, 1fr); }
.timeline-axis span { font-size: 9px; } .timeline-axis span { font-size: 9px; }
} }
/* ===== 参考图对齐:胶囊搜索条 / 账期时间轴 / 右侧详情面板 ===== */
.content-toolbar > .search-box { border-radius: 999px; background: rgba(255, 255, 255, 0.045); backdrop-filter: blur(18px) saturate(120%); }
.timeline-panel { margin-top: 18px; padding: 18px; }
.timeline { padding: 10px 4px 0; }
.timeline-bar { position: relative; height: 14px; border: 1px solid var(--color-line); border-radius: 999px; background: rgba(255, 255, 255, 0.05); }
.timeline-seg, .timeline-gap { position: absolute; top: 2px; bottom: 2px; border-radius: 999px; }
.timeline-seg.is-closed { background: linear-gradient(90deg, rgba(55, 235, 137, 0.5), rgba(55, 235, 137, 0.28)); }
.timeline-seg.is-current { background: linear-gradient(90deg, rgba(255, 188, 82, 0.4), rgba(255, 188, 82, 0.18)); }
.timeline-gap { background: repeating-linear-gradient(45deg, rgba(255, 98, 109, 0.75) 0 6px, rgba(255, 98, 109, 0.35) 6px 12px); box-shadow: 0 0 12px rgba(255, 98, 109, 0.25); }
.timeline-today { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 10px rgba(55, 235, 137, 0.6); }
.timeline-today::after { content: "今天"; position: absolute; top: -19px; right: 5px; font-size: 10px; font-style: normal; color: var(--color-primary-strong); white-space: nowrap; }
.timeline-ticks { position: relative; height: 34px; margin-top: 8px; }
.timeline-ticks span { position: absolute; display: flex; flex-direction: column; transform: translateX(-50%); font-size: 10px; color: var(--color-ink-muted); text-align: center; }
.timeline-ticks span:first-child { transform: none; text-align: left; }
.timeline-ticks span:last-child { transform: translateX(-100%); text-align: right; }
.timeline-ticks small { color: var(--color-ink-soft); }
.timeline-panel .timeline-legend { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px 18px; margin: 10px 0 0; padding: 0; list-style: none; font-size: 11px; color: var(--color-ink-soft); }
.timeline-panel .timeline-legend li { display: flex; align-items: center; gap: 6px; }
.timeline-panel .dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.success { background: var(--color-positive); }
.dot.warning { background: var(--color-warning); }
.dot.danger { background: var(--color-danger); }
.dot.today { background: var(--color-primary-strong); box-shadow: 0 0 6px rgba(55, 235, 137, 0.7); }
.detail-drawer { position: fixed; top: 16px; right: 16px; bottom: 16px; z-index: 80; width: min(380px, calc(100vw - 32px)); display: flex; flex-direction: column; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.92), rgba(12, 12, 12, 0.94)); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); transform: translateX(calc(100% + 32px)); visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), visibility var(--duration-standard); }
.detail-drawer.is-open { transform: none; visibility: visible; }
.detail-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid var(--color-line); }
.detail-drawer h2 { margin-top: 8px; font-size: 17px; }
.detail-drawer .detail-desc { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields { display: grid; gap: 10px; margin: 0; padding: 14px 18px; overflow-y: auto; }
.detail-fields > div { display: flex; justify-content: space-between; gap: 14px; padding-bottom: 10px; border-bottom: 1px dashed var(--color-line); }
.detail-fields dt { flex-shrink: 0; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields dd { margin: 0; text-align: right; font-size: 12px; }
.detail-tip { margin: 0 18px 12px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-ink-soft); font-size: 12px; }
.detail-tip strong { display: block; margin-bottom: 4px; color: var(--color-ink); }
.detail-drawer > footer { display: flex; gap: 10px; margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--color-line); }
.detail-drawer > footer .button { flex: 1; justify-content: center; }
.detail-drawer .status { align-self: flex-start; }
[data-detail] { cursor: pointer; }
@media (max-width: 720px) {
.detail-drawer { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); }
.timeline-panel { padding: 14px; }
}
/* ===== 质感打磨:玻璃光泽 / 氛围光 / 对比与呼吸感 ===== */
:root {
--radius-lg: 24px;
--radius-xl: 30px;
}
body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(52vw 42vh at 62% 32%, rgba(55, 235, 137, 0.055), transparent 62%), radial-gradient(40vw 34vh at 90% 4%, rgba(102, 168, 255, 0.032), transparent 60%), radial-gradient(46vw 40vh at 28% 96%, rgba(55, 235, 137, 0.028), transparent 60%); }
.app-shell { position: relative; z-index: 0; }
.metric-card { border-color: rgba(255, 255, 255, 0.12); background: radial-gradient(120% 90% at 50% 108%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 42%, transparent 60%), linear-gradient(160deg, rgba(41, 41, 41, 0.86) 0%, rgba(23, 23, 23, 0.88) 50%, rgba(12, 12, 12, 0.9) 100%); }
.metric-card::before { background: radial-gradient(130% 100% at 50% 112%, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 45%, transparent 62%), linear-gradient(165deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.05) 42%, transparent 72%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.05), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, transparent 70%); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.16); background: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06)); box-shadow: inset 0 1px rgba(255, 255, 255, 0.14), 0 10px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(55, 235, 137, 0.08); }
.metric-value { font-size: clamp(28px, 2.6vw, 38px); font-weight: 800; color: #ffffff; white-space: nowrap; }
.metric-grid { gap: 20px; margin-bottom: 20px; }
.admin-dashboard-grid { gap: 20px; margin-bottom: 20px; }
.company-dashboard-grid { gap: 20px; }
.page-heading { margin-bottom: 22px; }
@media (max-width: 720px) {
.metric-grid { gap: 10px; margin-bottom: 14px; }
.page-heading { margin-bottom: 18px; }
}
/* ===== 反馈修正:侧栏质感 + 模块间距统一 ===== */
.sidebar { border-color: rgba(255, 255, 255, 0.13); background: linear-gradient(170deg, rgba(26, 26, 26, 0.92), rgba(10, 10, 10, 0.94)); box-shadow: var(--shadow-panel), 0 0 44px rgba(55, 235, 137, 0.05); }
.sidebar::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(170deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 45%, transparent 70%); pointer-events: none; }
.nav-list { gap: 10px; }
.nav-item { min-height: 48px; border-radius: 16px; }
.user-block { border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-surface-muted); }
.company-alert { margin-bottom: 20px; }
.period-ribbon { margin-bottom: 20px; }
.company-stack-panel { margin-bottom: 20px; }
.work-progress { margin-top: 20px; }
.timeline-panel { margin-top: 20px; }
/* ===== 反馈修正:整体提亮 + 列表改为玻璃行(去掉线条隔断) ===== */
:root {
--color-ink-soft: #b6c0ba;
--color-ink-muted: #99a39d;
}
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { border-color: rgba(255, 255, 255, 0.12); background: linear-gradient(160deg, rgba(37, 37, 37, 0.84) 0%, rgba(21, 21, 21, 0.87) 50%, rgba(12, 12, 12, 0.89) 100%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.13) 0%, rgba(255, 255, 255, 0.04) 40%, transparent 70%); }
.task-list, .cashier-tasks, .coverage-list, .notification-list, .review-list { display: grid; gap: 8px; padding: 12px 14px 14px; }
.task-list button, .cashier-tasks article, .coverage-list article, .notification-list article, .review-list > article, .subject-row { border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); transition: background var(--duration-fast), border-color var(--duration-fast), box-shadow var(--duration-fast), transform var(--duration-fast); }
.task-list button:hover, .cashier-tasks article:hover, .coverage-list article:hover, .notification-list article:hover, .review-list > article:hover, .subject-row:hover { border-color: rgba(255, 255, 255, 0.2); background: linear-gradient(160deg, rgba(44, 44, 44, 0.82) 0%, rgba(22, 22, 22, 0.86) 100%); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); }
.notification-list article.is-unread { border-color: rgba(102, 168, 255, 0.3); background: linear-gradient(160deg, rgba(102, 168, 255, 0.1), rgba(102, 168, 255, 0.04)); }
.company-ledger { margin: 8px 14px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); overflow: hidden; }
.company-ledger[open] { border-color: rgba(255, 255, 255, 0.18); }
.company-ledger:last-child { margin-bottom: 14px; }
.data-table { border-collapse: separate; border-spacing: 0 8px; }
.data-table th { height: 28px; padding: 0 13px; border-bottom: 0; background: transparent; }
.data-table td { border-bottom: 0; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); }
.data-table td:first-child { border-left: 1px solid var(--color-line); border-radius: 12px 0 0 12px; }
.data-table td:last-child { border-right: 1px solid var(--color-line); border-radius: 0 12px 12px 0; }
.data-table tbody tr:hover td { background: linear-gradient(160deg, rgba(44, 44, 44, 0.85) 0%, rgba(22, 22, 22, 0.88) 100%); }
.data-table tbody tr:last-child td { border-bottom: 1px solid var(--color-line); }
.table-scroll { padding: 2px 14px 8px; }
.table-summary { margin: 0 14px; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
} }
/* ---- B-44 intercompany balances ---- */
.status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); }
.amount-neutral { color: var(--color-ink-muted); }
.currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; }
.amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; }
.amount-with-currency.is-compact { font-size: 11px; }
.account-cell { white-space: nowrap; }
.summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; }
.candidate-list li { padding: 4px 0; border-bottom: 1px dashed var(--color-line); }
.candidate-list li:last-child { border-bottom: 0; }
.reason-full { max-height: calc(1.6em * 6); overflow: auto; white-space: pre-wrap; }
.decision-danger { margin-top: 8px; color: var(--color-danger); font-size: 12px; }
.balance-currency-groups { display: grid; gap: 12px; }
.balance-currency-groups .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; }
/* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */
.ledger-head.is-balances, .company-ledger.is-balances summary {
grid-template-columns: minmax(180px, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
}
.company-ledger.is-balances summary { min-height: 72px; }
.company-ledger.is-balances summary .company-name { min-width: 96px; }
.company-ledger.is-balances summary .ledger-cutoff { color: var(--color-ink-muted); font-size: 11px; white-space: nowrap; }
.company-ledger.is-balances .ledger-result { display: flex; align-items: center; gap: 7px; min-width: 0; }
.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 0; }
.company-ledger.is-balances .ledger-unresolved { font-size: 11px; white-space: nowrap; }
.company-ledger.is-balances .ledger-unresolved.is-empty { color: var(--color-ink-muted); }
.company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); }
.company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; }
.company-ledger.is-balances .subject-row small { white-space: nowrap; }
@media (max-width: 1179px) {
.ledger-head.is-balances, .company-ledger.is-balances summary {
grid-template-columns: minmax(120px, 1.2fr) minmax(136px, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
}
.ledger-head.is-balances .ledger-hide-md,
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
.company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; }
}
@media (min-width: 376px) and (max-width: 900px) {
.ledger-head.is-balances { display: grid; }
.ledger-head.is-balances, .company-ledger.is-balances summary {
grid-template-columns: minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px;
}
.company-ledger.is-balances summary .ledger-result {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
}
.company-ledger.is-balances .ledger-result b { font-size: 11px; }
.company-ledger.is-balances .ledger-unresolved {
min-width: 0;
white-space: normal;
}
.company-ledger.is-balances .ledger-unresolved .status {
width: auto;
max-width: 100%;
height: auto;
white-space: normal;
justify-content: flex-start;
text-align: left;
}
}
@media (max-width: 375px) {
.ledger-head.is-balances { display: none; }
.company-ledger.is-balances summary {
grid-template-columns: minmax(88px, 1fr) minmax(0, max-content) 24px;
grid-template-rows: auto auto;
align-items: center;
column-gap: 10px;
row-gap: 4px;
}
.company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; min-width: 88px; }
.company-ledger.is-balances summary .ledger-result {
grid-column: 2; grid-row: 1; justify-self: end;
flex-direction: column; align-items: flex-end; gap: 2px;
}
.company-ledger.is-balances summary .ledger-result .currency-code { display: none; }
.company-ledger.is-balances summary .ledger-unresolved {
grid-column: 1; grid-row: 2; min-width: 0; white-space: normal;
}
.company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; }
.company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; }
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
}
/* Pair report six-cell: page may be six-across; drawer is always two rows of three */
.pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); }
.pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
.pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
.pair-balance-line .pair-unresolved b { color: var(--color-warning); }
.pair-balance-line .pair-unresolved.is-empty b { color: var(--color-ink); }
.pair-balance-line .pair-cutoff { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
@media (max-width: 1180px) {
.pair-balance-line.is-six { grid-template-columns: repeat(3, 1fr); }
.pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
.pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
}
.amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; }
.amount-with-currency.is-compact { font-size: 11px; }
.amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; }
/* Evidence drawer: <768 full, 7681179 480px, >=1180 640px */
.drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); }
.drawer-scrim.is-open { opacity: 1; pointer-events: auto; }
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); }
.drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; }
@media (max-width: 1179px) { .drawer { width: 480px; } }
@media (max-width: 767px) { .drawer { width: 100%; } }
.drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
.drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; }
.drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; }
.drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; }
.drawer-header h2 { margin-top: 6px; font-size: 17px; }
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; }
.drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; }
.drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; }
.drawer .pair-balance-line strong { overflow-wrap: anywhere; }
.drawer .pair-balance-line .amount-with-currency.is-compact { white-space: normal; overflow-wrap: anywhere; }
.drawer .pair-balance-line .pair-cutoff,
.drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; }
.drawer .pair-balance-line.is-six {
grid-template-columns: repeat(3, 1fr);
}
.drawer .pair-balance-line.is-six .pair-final { grid-column: auto; }
.drawer .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
.drawer .pair-balance-line.is-six div:nth-child(5),
.drawer .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
.drawer .event-table { min-width: 860px; }
.drawer .event-table td { height: var(--row-h-evidence); }
.drawer .event-row { cursor: pointer; }
.drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; }
.drawer .evidence-stack { display: flex; flex-direction: column; gap: 10px; }
.drawer .evidence-midline { display: flex; align-items: center; gap: 10px; color: var(--color-ink-muted); font-size: 11px; }
.drawer .evidence-midline::before, .drawer .evidence-midline::after { content: ""; flex: 1; height: 1px; background: var(--color-line); }
.evidence-block.is-missing { border-color: rgba(102, 168, 255, 0.28); background: var(--color-info-wash); }
.evidence-block .evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 14px; margin-top: 10px; }
.evidence-block .evidence-grid div { display: flex; flex-direction: column; min-width: 0; }
.evidence-block .evidence-grid dt { color: var(--color-ink-muted); font-size: 10px; }
.evidence-block .evidence-grid dd { color: var(--color-ink-soft); font-size: 12px; word-break: break-all; }
.evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; }
@media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } }
.company-row.is-balance-counterparty { grid-template-columns: 34px minmax(0, 1fr) minmax(0, auto); }
.company-row.is-balance-counterparty .company-row-figure {
display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0;
}
.company-row.is-balance-counterparty .company-row-figure strong { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
@media (max-width: 900px) {
.company-row.is-balance-counterparty {
grid-template-columns: 34px minmax(0, 1fr);
row-gap: 8px;
}
.company-row.is-balance-counterparty .company-row-figure {
grid-column: 2;
align-items: flex-start;
text-align: left;
flex-wrap: wrap;
flex-direction: row;
column-gap: 10px;
row-gap: 4px;
}
}
/* Loading skeletons */
.skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); }
.skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); }
.skeleton-row span:nth-child(1) { width: 22%; } .skeleton-row span:nth-child(2) { width: 14%; }
.skeleton-row span:nth-child(3) { width: 14%; } .skeleton-row span:nth-child(4) { width: 18%; }
.skeleton-row span:nth-child(5) { width: 12%; }
.skeleton-block { animation: skeleton-breathe 1200ms ease-in-out infinite; }
/* Empty / error / no-permission states */
.state-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; justify-content: center; min-height: 240px; padding: 28px 18px; text-align: center; color: var(--color-ink-muted); }
.state-panel .state-icon { width: 40px; height: 40px; color: var(--color-ink-muted); opacity: 0.7; }
.state-panel strong { color: var(--color-ink-soft); font-size: 14px; }
.state-panel p { max-width: 420px; font-size: 12px; }
.state-panel .button { margin-top: 6px; }
.state-panel.is-error { border: 1px solid rgba(255, 98, 109, 0.3); border-radius: var(--radius-lg); background: var(--color-danger-wash); }
.state-panel.is-error strong { color: var(--color-danger); }
.state-panel.is-denied { border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); }
/* Company portal alert variant + direction chips */
.balance-alert { border-color: rgba(255, 188, 82, 0.24); background: var(--color-warning-wash); }
.balance-alert > svg { color: var(--color-warning); }
.balance-alert .button { color: var(--color-warning); }
.company-row .result-direction { font-weight: 600; font-variant-numeric: tabular-nums; }
.subject-strip button { white-space: nowrap; }
/* Direction chips: text-first, arrows as affordance, never color-only */
.direction-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 23px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); font-size: 10px; white-space: nowrap; }
@keyframes skeleton-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .skeleton-block { animation: none; } }