Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5754ee09 | ||
|
|
eefdc92ab6 | ||
|
|
fdc5616115 | ||
|
|
c4f7573653 | ||
|
|
5b3ebad757 | ||
|
|
b42b6481f5 | ||
|
|
e04f47b9d2 | ||
|
|
7fb97119ba | ||
|
|
815b68c1fc | ||
|
|
609ebffdc9 | ||
|
|
77fc625ded | ||
|
|
ece3e53472 | ||
|
|
5816e8aa71 | ||
|
|
4add17f2b8 | ||
|
|
95ad9c939a | ||
|
|
1fe073066e | ||
|
|
f16e8aad34 | ||
|
|
d59576739f | ||
|
|
85293b79df | ||
|
|
f99917321b | ||
|
|
df517d4a68 | ||
|
|
486842963e | ||
|
|
7f1a93f6a6 | ||
|
|
545837446c | ||
|
|
51c301d9df | ||
|
|
f7aa4a8d06 | ||
|
|
763a940683 | ||
|
|
c292fb791d |
+24
@@ -2,3 +2,27 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.tmp-*/
|
||||
|
||||
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
||||
data/
|
||||
uploads/
|
||||
exports/
|
||||
*.local
|
||||
server.pid
|
||||
server.out.log
|
||||
server.err.log
|
||||
nul
|
||||
|
||||
# 本地协作工具运行目录
|
||||
.multica/
|
||||
.opencode/
|
||||
.agent_context/
|
||||
.kimi/
|
||||
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# local vendor for agent test env (not shipped)
|
||||
.vendor/
|
||||
vendor_wheels/
|
||||
|
||||
+6
-6
@@ -26,12 +26,12 @@ Body type is `13px/1.55`. Page headlines use `clamp(26px, 3vw, 35px)` with `1.15
|
||||
|
||||
| Token | Value | Use |
|
||||
| --- | --- | --- |
|
||||
| `--color-bg` | `#050706` | Continuous application canvas |
|
||||
| `--color-bg-soft` | `#090d0b` | Soft near-black tonal alternative |
|
||||
| `--color-nav` | `#090c0b` | Navigation frame fallback |
|
||||
| `--color-surface` | `rgba(20, 26, 23, 0.82)` | Primary graphite glass |
|
||||
| `--color-surface-solid` | `#131916` | Opaque graphite fallback |
|
||||
| `--color-surface-raised` | `#18201c` | Raised or denser surface |
|
||||
| `--color-bg` | `#050505` | Continuous application canvas |
|
||||
| `--color-bg-soft` | `#0a0a0a` | Soft near-black tonal alternative |
|
||||
| `--color-nav` | `#0a0a0a` | Navigation frame fallback |
|
||||
| `--color-surface` | `rgba(22, 22, 22, 0.82)` | Primary neutral graphite glass |
|
||||
| `--color-surface-solid` | `#161616` | Opaque graphite fallback |
|
||||
| `--color-surface-raised` | `#1c1c1c` | Raised or denser surface |
|
||||
| `--color-surface-muted` | `rgba(255, 255, 255, 0.035)` | Quiet inset field and row treatment |
|
||||
|
||||
### Structure and Ink
|
||||
|
||||
@@ -19,18 +19,44 @@ The original Excel file is never modified.
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
$env:PYTHONPATH = "src"
|
||||
python -m bank_importer.db # 创建/迁移 SQLite 数据库(默认 data/app.db)
|
||||
python -m bank_importer.cli "流水模板"
|
||||
python -m unittest discover -s tests -v
|
||||
python server.py
|
||||
```
|
||||
|
||||
访问地址:
|
||||
数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按
|
||||
SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。
|
||||
同一公司重复上传返回原批次摘要;不同公司相同内容只返回通用重复状态,
|
||||
不暴露其他公司的批次标识或摘要。
|
||||
技术决策见 `docs/decisions/002-persistence.md`。
|
||||
|
||||
## 登录与账号
|
||||
|
||||
系统已启用真实身份认证(决策见 `docs/decisions/003-auth.md`):
|
||||
|
||||
- 首次启动若无管理员,自动创建引导管理员:用户名取环境变量
|
||||
`APP_ADMIN_USERNAME`(默认 `group-admin`),密码取
|
||||
`APP_BOOTSTRAP_ADMIN_PASSWORD`;未设置该变量则生成随机初始密码并
|
||||
只在控制台打印一次。
|
||||
- 引导管理员及管理员创建的公司账号首次登录都必须修改密码。
|
||||
- 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或
|
||||
`/api/admin/companies`、`/api/admin/users` 动态创建;
|
||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||
|
||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||
|
||||
- 登录入口:`http://127.0.0.1:4173/`
|
||||
- 总账管理端:`http://127.0.0.1:4173/admin.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
|
||||
details.
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ bank_importer 解析内核
|
||||
### 测试与运行
|
||||
|
||||
1. 现有 6 项测试只覆盖解析内核,没有 `/api/parse`、认证、导入持久化、匹配、核算、权限或端到端测试。
|
||||
2. `server.py` 基于标准库开发服务器,只绑定 `127.0.0.1`,没有生产部署、TLS、日志、备份或监控方案。
|
||||
2. `server.py` 基于标准库开发服务器,默认绑定 `0.0.0.0`(可用 `APP_HOST` 覆盖),没有生产部署、TLS、日志、备份或监控方案。
|
||||
3. `流水模板/` 已进入 Git 历史;必须确认均为合成或已脱敏数据,真实生产流水不得继续提交到仓库。
|
||||
|
||||
## 6. 已作出的重要技术决策及原因
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 总账项目手册
|
||||
|
||||
最后核对:2026-08-23。核对依据是当前 `main` 最新代码(`5f21527`)、数据库迁移、接口、测试和近期提交记录。
|
||||
|
||||
这是接手本项目的第一站。阅读顺序如下:
|
||||
|
||||
1. [项目需求](项目需求.md):先知道系统要解决什么问题,以及哪些规则绝不能碰。
|
||||
2. [最新进度](最新进度.md):再确认哪些能力已经真实完成,哪些仍未完成。
|
||||
3. [任务清单](任务清单.md):最后挑选下一项工作,避免重复做已经完成的事。
|
||||
4. 需要技术细节时,再查看 `decisions/` 中的技术决定和 `issues/` 中保留的历史任务规格。
|
||||
|
||||
## 维护规矩
|
||||
|
||||
- 每新增、开始、完成、取消一项任务,负责的智能体都要在同一次提交里更新 `最新进度.md` 和 `任务清单.md`。
|
||||
- 新功能改变产品规则时,同时更新 `项目需求.md`;技术方案有变化时,补充 `decisions/` 的决定记录。
|
||||
- 写进手册的“已完成”必须能在代码、测试或已合并提交中找到依据;只有计划、页面演示或口头讨论的内容,一律写为“未完成”。
|
||||
- 不删除历史文档。旧内容与当前代码不一致时,在旧文件开头加“已过时,仅留档备查”提示,并在本目录写新版说明。
|
||||
|
||||
## 旧交接说明
|
||||
|
||||
`HANDOFF.md` 是 2026-08-06 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 002 持久化层技术决策
|
||||
|
||||
对应 Issue:B-39(`docs/issues/002-p0-persistence-and-immutable-imports.md`)。
|
||||
|
||||
## 数据库:SQLite(Python 标准库 `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` 自动化验证。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 003 身份认证与租户隔离技术决策
|
||||
|
||||
对应 Issue:B-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、时间。
|
||||
- 初始密码、新旧密码均不进入审计内容。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 004 动态主数据技术决策
|
||||
|
||||
对应 Issue:B-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 规范转账事件与双边归并技术决策
|
||||
|
||||
对应 Issue:B-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`.
|
||||
@@ -0,0 +1,29 @@
|
||||
# 任务清单
|
||||
|
||||
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
|
||||
|
||||
## 正在做
|
||||
|
||||
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
|
||||
|
||||
## 已做完
|
||||
|
||||
- 六类银行流水模板识别与解析。
|
||||
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
|
||||
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
|
||||
- 动态公司、用户、银行账户、别名与账户审核流程。
|
||||
- 导入接口加固、逐工作表确认、失败诊断和导出。
|
||||
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
|
||||
- 登录页视觉融合改版。
|
||||
|
||||
## 还没安排
|
||||
|
||||
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
|
||||
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
|
||||
- 月结、重开、调整/冲销审批与审计报告。
|
||||
- 服务端流水查询、筛选、分页和可追溯导出。
|
||||
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
|
||||
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
|
||||
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
|
||||
|
||||
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 最新进度
|
||||
|
||||
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
|
||||
|
||||
## 已经真实完成
|
||||
|
||||
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
|
||||
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
|
||||
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
|
||||
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
|
||||
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
|
||||
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
|
||||
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
|
||||
|
||||
## 仍未完成或不能当成已完成
|
||||
|
||||
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
|
||||
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
|
||||
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
|
||||
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
|
||||
|
||||
## 最近验证
|
||||
|
||||
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 项目需求
|
||||
|
||||
## 这个系统做什么
|
||||
|
||||
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
|
||||
|
||||
系统有两个独立入口:
|
||||
|
||||
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
|
||||
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
|
||||
|
||||
## 必须做到的业务规则
|
||||
|
||||
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
|
||||
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
|
||||
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
|
||||
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
|
||||
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
|
||||
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
|
||||
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
|
||||
|
||||
## 当前技术边界
|
||||
|
||||
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls` 与 `.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
|
||||
|
||||
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Admin dashboard aggregates over existing B-43 eligible events and review queues.
|
||||
|
||||
No fabricated demo amounts. Opening balances are unavailable until B-45; peer
|
||||
group ``opening`` is always null and ``ending`` equals period net change.
|
||||
Period closing status is unknown without the monthly-close module — returned
|
||||
as null so the UI shows an em dash rather than a guessed label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import sqlite3
|
||||
|
||||
|
||||
WAN = Decimal("10000")
|
||||
ZERO = Decimal("0")
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def today_shanghai() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
|
||||
|
||||
def _q2(value: Decimal) -> str:
|
||||
return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _to_wan(amount: Decimal) -> str:
|
||||
return _q2(amount / WAN)
|
||||
|
||||
|
||||
def _parse_day(iso_ts: str | None) -> str | None:
|
||||
if not iso_ts:
|
||||
return None
|
||||
text = str(iso_ts)
|
||||
return text[:10] if len(text) >= 10 else None
|
||||
|
||||
|
||||
def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
"""Derive pending review counts from real queues only.
|
||||
|
||||
Priority mapping (aligned with current admin audit UI semantics):
|
||||
- high: unresolved / needs_review match exceptions
|
||||
- medium: pending bank-account registrations + pending manual records
|
||||
- low: reserved for future low-risk queues (currently always 0)
|
||||
|
||||
Homepage card, sidebar badge and audit-center heading must all read this
|
||||
same payload (via ``/api/admin/dashboard`` → ``audit``).
|
||||
"""
|
||||
high = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
WHERE e.lifecycle = 'active'
|
||||
AND d.classification IN ('unresolved', 'needs_review')
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium_accounts = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'"
|
||||
).fetchone()["n"]
|
||||
medium_manuals = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
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 d.state = 'pending'
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium = int(medium_accounts) + int(medium_manuals)
|
||||
low = 0
|
||||
return {
|
||||
"total": int(high) + int(medium) + int(low),
|
||||
"high": int(high),
|
||||
"medium": int(medium),
|
||||
"low": int(low),
|
||||
}
|
||||
|
||||
|
||||
def _load_eligible(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency,
|
||||
e.payer_company_id, e.payee_company_id, e.pairing,
|
||||
cpayer.name AS payer_name, cpayee.name AS payee_name
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN companies cpayer ON cpayer.id = e.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = e.payee_company_id
|
||||
WHERE date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
ORDER BY e.effective_at, e.event_id
|
||||
""",
|
||||
(from_date, cutoff),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, name FROM companies
|
||||
WHERE status != 'disabled'
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def company_summaries(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
companies = _company_rows(connection)
|
||||
events = _load_eligible(connection, from_date=from_date, cutoff=cutoff)
|
||||
|
||||
debit_total = ZERO
|
||||
credit_total = ZERO
|
||||
by_id: dict[int, dict[str, object]] = {}
|
||||
for company in companies:
|
||||
by_id[int(company["id"])] = {
|
||||
"id": int(company["id"]),
|
||||
"name": company["name"],
|
||||
"detail_count": 0,
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"period_status": None,
|
||||
"period_status_label": "—",
|
||||
}
|
||||
|
||||
for event in events:
|
||||
amount = Decimal(str(event["amount"]))
|
||||
debit_total += amount
|
||||
credit_total += amount
|
||||
payer_id = int(event["payer_company_id"])
|
||||
payee_id = int(event["payee_company_id"])
|
||||
if payer_id in by_id:
|
||||
row = by_id[payer_id]
|
||||
row["detail_count"] = int(row["detail_count"]) + 1
|
||||
row["debit"] = Decimal(row["debit"]) + amount
|
||||
if payee_id in by_id:
|
||||
row = by_id[payee_id]
|
||||
row["detail_count"] = int(row["detail_count"]) + 1
|
||||
row["credit"] = Decimal(row["credit"]) + amount
|
||||
|
||||
items: list[dict[str, object]] = []
|
||||
for company in companies:
|
||||
row = by_id[int(company["id"])]
|
||||
debit = Decimal(row["debit"])
|
||||
credit = Decimal(row["credit"])
|
||||
# Payee inflow − payer outflow = signed net from this company's view.
|
||||
net = credit - debit
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"detail_count": row["detail_count"],
|
||||
"debit_wan": _to_wan(debit),
|
||||
"credit_wan": _to_wan(credit),
|
||||
"net_wan": _to_wan(net),
|
||||
"period_status": row["period_status"],
|
||||
"period_status_label": row["period_status_label"],
|
||||
}
|
||||
)
|
||||
|
||||
totals = {
|
||||
"company_count": len(items),
|
||||
"detail_count": len(events),
|
||||
"debit_wan": _to_wan(debit_total),
|
||||
"credit_wan": _to_wan(credit_total),
|
||||
"net_wan": _to_wan(debit_total - credit_total),
|
||||
}
|
||||
return items, totals
|
||||
|
||||
|
||||
def company_peer_groups(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
*,
|
||||
from_date: str,
|
||||
cutoff: str,
|
||||
) -> dict[str, object]:
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise KeyError(company_id)
|
||||
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.effective_at, e.amount, e.currency,
|
||||
e.payer_company_id, e.payee_company_id,
|
||||
cpayer.name AS payer_name, cpayee.name AS payee_name
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN companies cpayer ON cpayer.id = e.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = e.payee_company_id
|
||||
WHERE (e.payer_company_id = ? OR e.payee_company_id = ?)
|
||||
AND date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
ORDER BY e.effective_at, e.event_id
|
||||
""",
|
||||
(company_id, company_id, from_date, cutoff),
|
||||
).fetchall()
|
||||
|
||||
groups: dict[int, dict[str, object]] = {}
|
||||
for event in events:
|
||||
amount = Decimal(str(event["amount"]))
|
||||
payer_id = int(event["payer_company_id"])
|
||||
payee_id = int(event["payee_company_id"])
|
||||
if payer_id == company_id:
|
||||
peer_id = payee_id
|
||||
peer_name = event["payee_name"]
|
||||
direction = "debit"
|
||||
summary = f"付往 {peer_name}"
|
||||
else:
|
||||
peer_id = payer_id
|
||||
peer_name = event["payer_name"]
|
||||
direction = "credit"
|
||||
summary = f"收自 {peer_name}"
|
||||
|
||||
bucket = groups.get(peer_id)
|
||||
if bucket is None:
|
||||
bucket = {
|
||||
"peer_id": peer_id,
|
||||
"peer_name": peer_name,
|
||||
"count": 0,
|
||||
"opening": None,
|
||||
"opening_status": "unavailable",
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"lines": [],
|
||||
}
|
||||
groups[peer_id] = bucket
|
||||
|
||||
bucket["count"] = int(bucket["count"]) + 1
|
||||
if direction == "debit":
|
||||
bucket["debit"] = Decimal(bucket["debit"]) + amount
|
||||
else:
|
||||
bucket["credit"] = Decimal(bucket["credit"]) + amount
|
||||
|
||||
day = _parse_day(event["effective_at"]) or ""
|
||||
bucket["lines"].append(
|
||||
{
|
||||
"event_id": int(event["event_id"]),
|
||||
"date": day,
|
||||
"direction": direction,
|
||||
"summary": summary,
|
||||
"amount_wan": _to_wan(amount),
|
||||
"currency": event["currency"] or "CNY",
|
||||
}
|
||||
)
|
||||
|
||||
result_groups: list[dict[str, object]] = []
|
||||
for peer_id in sorted(groups.keys(), key=lambda i: groups[i]["peer_name"]):
|
||||
bucket = groups[peer_id]
|
||||
debit = Decimal(bucket["debit"])
|
||||
credit = Decimal(bucket["credit"])
|
||||
ending = credit - debit
|
||||
result_groups.append(
|
||||
{
|
||||
"peer_id": bucket["peer_id"],
|
||||
"peer_name": bucket["peer_name"],
|
||||
"count": bucket["count"],
|
||||
"opening": None,
|
||||
"opening_status": "unavailable",
|
||||
"debit_wan": _to_wan(debit),
|
||||
"credit_wan": _to_wan(credit),
|
||||
"ending_wan": _to_wan(ending),
|
||||
"result_kind": "period_net_change",
|
||||
"lines": bucket["lines"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"company_id": int(company["id"]),
|
||||
"company_name": company["name"],
|
||||
"from_date": from_date,
|
||||
"cutoff": cutoff,
|
||||
"groups": result_groups,
|
||||
}
|
||||
|
||||
|
||||
def weekly_flow(
|
||||
connection: sqlite3.Connection, *, cutoff: str, days: int = 7
|
||||
) -> dict[str, object]:
|
||||
end = datetime.strptime(cutoff, "%Y-%m-%d").date()
|
||||
start = end - timedelta(days=days - 1)
|
||||
labels: list[str] = []
|
||||
inflow = [ZERO] * days
|
||||
outflow = [ZERO] * days
|
||||
index: dict[str, int] = {}
|
||||
for offset in range(days):
|
||||
day = start + timedelta(days=offset)
|
||||
key = day.isoformat()
|
||||
index[key] = offset
|
||||
labels.append(f"{day.month:02d}-{day.day:02d}")
|
||||
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT date(e.effective_at) AS day, e.amount
|
||||
FROM eligible_intercompany_events e
|
||||
WHERE date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
""",
|
||||
(start.isoformat(), cutoff),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
day = row["day"]
|
||||
if day not in index:
|
||||
continue
|
||||
amount = Decimal(str(row["amount"]))
|
||||
# Group-level flow: every eligible transfer is both an outflow (payer)
|
||||
# and an inflow (payee); plot both series with the same absolute amount.
|
||||
inflow[index[day]] += amount
|
||||
outflow[index[day]] += amount
|
||||
|
||||
return {
|
||||
"labels": labels,
|
||||
"inflow_wan": [_to_wan(v) for v in inflow],
|
||||
"outflow_wan": [_to_wan(v) for v in outflow],
|
||||
}
|
||||
|
||||
|
||||
def build_dashboard(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
from_date: str = "2026-01-01",
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff_date = cutoff or today_shanghai()
|
||||
try:
|
||||
datetime.strptime(from_date, "%Y-%m-%d")
|
||||
datetime.strptime(cutoff_date, "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise ValueError("日期必须是 YYYY-MM-DD") from exc
|
||||
if from_date > cutoff_date:
|
||||
raise ValueError("from 不能晚于 cutoff")
|
||||
|
||||
period = datetime.strptime(cutoff_date, "%Y-%m-%d")
|
||||
# Display month for the status column header: use the calendar month of cutoff.
|
||||
period_month = period.month
|
||||
companies, totals = company_summaries(
|
||||
connection, from_date=from_date, cutoff=cutoff_date
|
||||
)
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"cutoff": cutoff_date,
|
||||
"period_month": period_month,
|
||||
"period_label": f"{period.year}-{period.month:02d}",
|
||||
"audit": audit_counts(connection),
|
||||
"totals": totals,
|
||||
"companies": companies,
|
||||
"weekly_flow": weekly_flow(connection, cutoff=cutoff_date),
|
||||
"opening_status": "unavailable",
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
"""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;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=7,
|
||||
name="0007_system_settings_and_reminders",
|
||||
# System settings (closing day, global start date, auto-reminder) are
|
||||
# persisted as a key/value table with an append-only change history
|
||||
# (operator + before/after) for the audit requirement. Reminders are a
|
||||
# separate append-only table so reminder history survives re-sends.
|
||||
up="""
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users (id),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE system_setting_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
before_value TEXT,
|
||||
after_value TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'unread'
|
||||
CHECK (status IN ('unread', 'done')),
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
""",
|
||||
down="""
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS system_setting_changes;
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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
@@ -39,3 +39,24 @@ class StatementBatch:
|
||||
period_end: date | None
|
||||
transactions: tuple[NormalizedTransaction, ...]
|
||||
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
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .models import NormalizedTransaction, StatementBatch
|
||||
from .models import NormalizedTransaction, SheetResult, StatementBatch
|
||||
from .reader import RawSheet, read_workbook
|
||||
from .templates import BankTemplate, TEMPLATES, normalize_header
|
||||
|
||||
@@ -24,8 +24,11 @@ class AmbiguousTemplateError(StatementParseError):
|
||||
pass
|
||||
|
||||
|
||||
SCAN_LIMIT = 50
|
||||
|
||||
|
||||
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]]:
|
||||
candidates: list[tuple[int, BankTemplate, int, dict[str, int]]] = []
|
||||
for row_index, row in enumerate(rows[:scan_limit]):
|
||||
@@ -42,8 +45,8 @@ def detect_header(
|
||||
|
||||
if not candidates:
|
||||
inspected = min(len(rows), scan_limit)
|
||||
candidate_rows = _header_candidate_summary(rows[:scan_limit])
|
||||
detail = f";候选表头:{candidate_rows}" if candidate_rows else ""
|
||||
candidate_rows = _header_candidate_rows(rows[:scan_limit])
|
||||
detail = f";候选表头:{';'.join(candidate_rows)}" if candidate_rows else ""
|
||||
raise UnknownTemplateError(
|
||||
f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}。"
|
||||
)
|
||||
@@ -60,21 +63,69 @@ def detect_header(
|
||||
|
||||
|
||||
def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]:
|
||||
source = Path(path)
|
||||
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}")
|
||||
|
||||
batches = tuple(result.batch for result in analyze_workbook(path) if result.batch)
|
||||
if not batches:
|
||||
detail = "; ".join(errors) or "Workbook contains no readable worksheets."
|
||||
raise UnknownTemplateError(f"{source.name}: {detail}")
|
||||
return tuple(batches)
|
||||
raise UnknownTemplateError(f"{Path(path).name}: 工作簿中没有可解析的工作表。")
|
||||
return 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, ...]:
|
||||
@@ -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))
|
||||
|
||||
|
||||
def _header_candidate_summary(
|
||||
def _header_candidate_rows(
|
||||
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 = {
|
||||
alias
|
||||
for template in TEMPLATES
|
||||
@@ -102,7 +158,7 @@ def _header_candidate_summary(
|
||||
candidates: list[tuple[int, int, int, tuple[str, ...]]] = []
|
||||
for row_index, row in enumerate(rows):
|
||||
values = tuple(_text(value) for value in row if _text(value))
|
||||
if len(values) < 2:
|
||||
if not values:
|
||||
continue
|
||||
matched = sum(normalize_header(value) in known_headers for value in values)
|
||||
candidates.append((matched, len(values), row_index, values))
|
||||
@@ -114,7 +170,7 @@ def _header_candidate_summary(
|
||||
if len(values) > 8:
|
||||
preview += "……"
|
||||
summaries.append(f"第 {row_index + 1} 行「{preview}」")
|
||||
return ";".join(summaries)
|
||||
return tuple(summaries)
|
||||
|
||||
|
||||
def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
|
||||
@@ -190,6 +246,7 @@ def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
|
||||
else None,
|
||||
transactions=tuple(transactions),
|
||||
warnings=tuple(warnings),
|
||||
template_version=template.version,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -10,6 +10,23 @@ class UnsupportedWorkbookError(RuntimeError):
|
||||
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)
|
||||
class RawSheet:
|
||||
name: str
|
||||
@@ -18,11 +35,18 @@ class RawSheet:
|
||||
|
||||
def read_workbook(path: Path) -> tuple[RawSheet, ...]:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".xlsx":
|
||||
return _read_xlsx(path)
|
||||
if suffix == ".xls":
|
||||
return _read_xls(path)
|
||||
raise UnsupportedWorkbookError(f"Unsupported workbook type: {suffix}")
|
||||
try:
|
||||
if suffix == ".xlsx":
|
||||
return _read_xlsx(path)
|
||||
if suffix == ".xls":
|
||||
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, ...]:
|
||||
@@ -35,13 +59,24 @@ def _read_xlsx(path: Path) -> tuple[RawSheet, ...]:
|
||||
|
||||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
return tuple(
|
||||
RawSheet(
|
||||
name=worksheet.title,
|
||||
rows=tuple(tuple(row) for row in worksheet.iter_rows(values_only=True)),
|
||||
)
|
||||
for worksheet in workbook.worksheets
|
||||
)
|
||||
sheets: list[RawSheet] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
rows: list[tuple[Any, ...]] = []
|
||||
for row_index, row in enumerate(worksheet.iter_rows(values_only=True)):
|
||||
if row_index >= MAX_ROWS_PER_SHEET:
|
||||
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:
|
||||
workbook.close()
|
||||
|
||||
@@ -57,8 +92,19 @@ def _read_xls(path: Path) -> tuple[RawSheet, ...]:
|
||||
workbook = xlrd.open_workbook(path, on_demand=True)
|
||||
sheets: list[RawSheet] = []
|
||||
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)
|
||||
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, ...]] = []
|
||||
for row_index in range(worksheet.nrows):
|
||||
values: list[Any] = []
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""System settings and reminder item generation.
|
||||
|
||||
System-wide parameters (closing day, global start date, auto-reminder toggle
|
||||
and lead days) are persisted in ``system_settings`` with an append-only
|
||||
``system_setting_changes`` trail. Reminder pending items are derived from real
|
||||
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
|
||||
rather than hard-coded rosters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import utc_now
|
||||
|
||||
# Defaults are applied when a key is absent; the value type is always string.
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
"closing_day": "5",
|
||||
"start_date": "2026-01-01",
|
||||
"auto_remind": "1",
|
||||
"remind_days": "3",
|
||||
}
|
||||
|
||||
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _valid_date(value: str) -> bool:
|
||||
if not _DATE_RE.match(value):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
rows = connection.execute(
|
||||
"SELECT key, value FROM system_settings"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
|
||||
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
|
||||
cleaned: dict[str, str] = {}
|
||||
if "closing_day" in values:
|
||||
raw = str(values["closing_day"]).strip()
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
if day < 1 or day > 28:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
cleaned["closing_day"] = str(day)
|
||||
if "start_date" in values:
|
||||
raw = str(values["start_date"]).strip()
|
||||
if not _valid_date(raw):
|
||||
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
|
||||
cleaned["start_date"] = raw
|
||||
if "auto_remind" in values:
|
||||
raw = str(values["auto_remind"]).strip()
|
||||
if raw not in {"0", "1"}:
|
||||
return cleaned, "自动提醒开关须为 0 或 1。"
|
||||
cleaned["auto_remind"] = raw
|
||||
if "remind_days" in values:
|
||||
raw = str(values["remind_days"]).strip()
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "提前提醒天数须为不小于 1 的整数。"
|
||||
if days < 1 or days > 30:
|
||||
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
|
||||
cleaned["remind_days"] = str(days)
|
||||
return cleaned, None
|
||||
|
||||
|
||||
def update_settings(
|
||||
connection: sqlite3.Connection,
|
||||
values: dict[str, object],
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, str]:
|
||||
cleaned, error = validate_settings(values)
|
||||
if error is not None:
|
||||
raise ValueError(error)
|
||||
if not cleaned:
|
||||
raise ValueError("没有需要保存的设置项。")
|
||||
current = get_settings(connection)
|
||||
with connection:
|
||||
for key, new_value in cleaned.items():
|
||||
old_value = current.get(key)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, new_value, actor["id"], utc_now()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
old_value,
|
||||
new_value,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Reminder pending items
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _current_period() -> tuple[str, str]:
|
||||
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
|
||||
today = datetime.now(timezone.utc)
|
||||
start = today.strftime("%Y-%m-01")
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
end = f"{year + 1}-01-01"
|
||||
else:
|
||||
end = f"{year}-{month + 1:02d}-01"
|
||||
return start, end
|
||||
|
||||
|
||||
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
|
||||
"""Derive the list of pending reminder items for one company."""
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
return items
|
||||
|
||||
period_start, _ = _current_period()
|
||||
|
||||
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
|
||||
active_accounts = connection.execute(
|
||||
"""
|
||||
SELECT id, account_number FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'active'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
confirmed_this_period = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
|
||||
AND rv.sheet_batch_id IN (
|
||||
SELECT id FROM sheet_batches s
|
||||
WHERE s.period_end >= ?
|
||||
)
|
||||
""",
|
||||
(company_id, period_start),
|
||||
).fetchone()["n"]
|
||||
if active_accounts and confirmed_this_period == 0:
|
||||
items.append(
|
||||
{
|
||||
"kind": "流水未提交",
|
||||
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
|
||||
}
|
||||
)
|
||||
|
||||
# 2) 待确认工作表:仍有未确认的解析结果。
|
||||
pending_sheets = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_sheets:
|
||||
items.append(
|
||||
{
|
||||
"kind": "待确认工作表",
|
||||
"content": f"有 {pending_sheets} 个导入工作表尚未确认,请核对后确认。",
|
||||
}
|
||||
)
|
||||
|
||||
# 3) 待审核账户登记:处于待复核状态的银行账户。
|
||||
pending_accounts = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_accounts:
|
||||
items.append(
|
||||
{
|
||||
"kind": "账户登记",
|
||||
"content": f"有 {pending_accounts} 个银行账户登记待审核。",
|
||||
}
|
||||
)
|
||||
|
||||
# 4) 待确认往来事项:尚未解决的往来匹配。
|
||||
pending_transfers = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants p
|
||||
ON p.decision_id = d.id AND p.company_id = ?
|
||||
WHERE d.classification IN ('unresolved', 'needs_review')
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_transfers:
|
||||
items.append(
|
||||
{
|
||||
"kind": "往来待确认",
|
||||
"content": f"有 {pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def send_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
actor: sqlite3.Row,
|
||||
) -> tuple[list[dict[str, str]], str | None]:
|
||||
"""Create reminder rows for a company's pending items.
|
||||
|
||||
Returns ``(created, deadline)``. ``created`` is the list of persisted
|
||||
reminder payloads; ``deadline`` is derived from the closing-day setting.
|
||||
"""
|
||||
items = pending_items(connection, company_id)
|
||||
if not items:
|
||||
return [], None
|
||||
|
||||
settings = get_settings(connection)
|
||||
try:
|
||||
closing_day = int(settings["closing_day"])
|
||||
except ValueError:
|
||||
closing_day = 5
|
||||
today = datetime.now(timezone.utc)
|
||||
# Deadline: next month's closing day (current month if today is before it).
|
||||
if today.day < closing_day:
|
||||
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
|
||||
else:
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
year, month = year + 1, 1
|
||||
else:
|
||||
month += 1
|
||||
deadline = f"{year}-{month:02d}-{closing_day:02d}"
|
||||
|
||||
created: list[dict[str, str]] = []
|
||||
with connection:
|
||||
for item in items:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, kind, content, deadline, source,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
item["kind"],
|
||||
item["content"],
|
||||
deadline,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
created.append(
|
||||
{
|
||||
"id": cursor.lastrowid,
|
||||
"company_id": company_id,
|
||||
"kind": item["kind"],
|
||||
"content": item["content"],
|
||||
"deadline": deadline,
|
||||
"source": "manual",
|
||||
"status": "unread",
|
||||
"actor_username": actor["username"],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
)
|
||||
return created, deadline
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class BankTemplate:
|
||||
bank_name: str
|
||||
columns: dict[str, tuple[str, ...]]
|
||||
required: tuple[str, ...]
|
||||
version: int = 1
|
||||
|
||||
|
||||
def normalize_header(value: object) -> str:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""HEL-168: company workspace pending unilateral counts sync after confirm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "Cashier123"
|
||||
|
||||
CCB_HEADER = [
|
||||
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
|
||||
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
|
||||
]
|
||||
|
||||
ACCOUNT_A = "6222000000001001"
|
||||
ACCOUNT_B = "6222000000001002"
|
||||
|
||||
|
||||
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) -> list:
|
||||
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
|
||||
|
||||
|
||||
class CompanyWorkspaceTodoTests(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
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
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.initial_passwords: dict[str, str] = {}
|
||||
cls.company_a = cls._create_company("甲公司", "ws-cashier-a")
|
||||
cls.company_b = cls._create_company("乙公司", "ws-cashier-b")
|
||||
cls.cashier_a = cls._login_company("ws-cashier-a")
|
||||
cls.cashier_b = cls._login_company("ws-cashier-b")
|
||||
cls._approve_account(cls.cashier_a, ACCOUNT_A)
|
||||
cls._approve_account(cls.cashier_b, ACCOUNT_B)
|
||||
|
||||
@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()
|
||||
|
||||
@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
|
||||
payload = as_json(data)
|
||||
cls.initial_passwords[username] = payload["initial_password"]
|
||||
return payload["company_id"]
|
||||
|
||||
@classmethod
|
||||
def _login_company(cls, username: str) -> 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_client: 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",
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
def _seed_unilateral(self, amount: str, at: str, cp_account: str = "9999999999999999") -> dict:
|
||||
status, _, data = self.admin.post_multipart(
|
||||
"/api/parse",
|
||||
{"company_id": str(self.company_a)},
|
||||
f"单边-{amount}.xlsx",
|
||||
workbook_bytes([outgoing(ACCOUNT_A, cp_account, amount, at)]),
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
batch_id = as_json(data)["batch_id"]
|
||||
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
|
||||
self.assertEqual(200, status, data)
|
||||
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/workspace")
|
||||
self.assertEqual(200, status, data)
|
||||
events = [
|
||||
e for e in as_json(data)["unilateral_events"] if e["amount"] == amount
|
||||
]
|
||||
self.assertEqual(1, len(events), data)
|
||||
return events[0]
|
||||
|
||||
def _workspace(self, client: Client) -> dict:
|
||||
status, _, data = client.get("/api/company/workspace")
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertEqual("ok", payload["status"])
|
||||
return payload
|
||||
|
||||
def test_seed_three_confirm_sync_and_isolation(self) -> None:
|
||||
# Seed 3 pending unilaterals for company A.
|
||||
e1 = self._seed_unilateral("11.00", "2026-03-01 10:00:00")
|
||||
e2 = self._seed_unilateral("22.00", "2026-03-02 10:00:00")
|
||||
e3 = self._seed_unilateral("33.00", "2026-03-03 10:00:00")
|
||||
seeded_ids = {e1["event_id"], e2["event_id"], e3["event_id"]}
|
||||
|
||||
ws = self._workspace(self.cashier_a)
|
||||
own = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
|
||||
self.assertEqual(3, len(own))
|
||||
self.assertEqual(ws["pending_unilateral"], ws["pending_total"])
|
||||
self.assertGreaterEqual(ws["pending_unilateral"], 3)
|
||||
# 两处数字与列表同一权威口径
|
||||
self.assertEqual(ws["pending_unilateral"], len(ws["unilateral_events"]))
|
||||
|
||||
# 另一公司看不到甲公司这 3 笔
|
||||
ws_b = self._workspace(self.cashier_b)
|
||||
self.assertEqual(
|
||||
[],
|
||||
[e for e in ws_b["unilateral_events"] if e["event_id"] in seeded_ids],
|
||||
)
|
||||
|
||||
# match-exceptions 与 workspace 同集合
|
||||
status, _, data = self.cashier_a.get("/api/company/match-exceptions")
|
||||
self.assertEqual(200, status, data)
|
||||
exceptions = as_json(data)["exceptions"]
|
||||
exception_ids = {e["event_id"] for e in exceptions}
|
||||
self.assertTrue(seeded_ids.issubset(exception_ids))
|
||||
|
||||
remaining = [e1, e2, e3]
|
||||
for expected in (2, 1, 0):
|
||||
event = remaining.pop(0)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{event['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": event["revision"],
|
||||
"request_key": f"hel168-{event['event_id']}-ok",
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
body = as_json(data)
|
||||
self.assertIn("workspace", body)
|
||||
left = [
|
||||
e
|
||||
for e in body["workspace"]["unilateral_events"]
|
||||
if e["event_id"] in seeded_ids
|
||||
]
|
||||
self.assertEqual(expected, len(left))
|
||||
self.assertEqual(body["workspace"]["pending_unilateral"], len(body["workspace"]["unilateral_events"]))
|
||||
|
||||
# 刷新等价:重新 GET 仍为同步后的数字
|
||||
ws = self._workspace(self.cashier_a)
|
||||
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
|
||||
self.assertEqual(expected, len(left))
|
||||
|
||||
# 重复确认:事件已离开待确认集合且 request_key 不同 → 404,数字不得误减
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{e1['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e1["revision"],
|
||||
"request_key": f"hel168-{e1['event_id']}-again",
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
ws = self._workspace(self.cashier_a)
|
||||
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
|
||||
self.assertEqual(0, len(left))
|
||||
|
||||
# 同一 request_key 重放:幂等成功,数字不误减
|
||||
e4 = self._seed_unilateral("44.00", "2026-03-04 10:00:00")
|
||||
key = f"hel168-{e4['event_id']}-replay"
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{e4['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e4["revision"],
|
||||
"request_key": key,
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
after_first = self._workspace(self.cashier_a)["pending_unilateral"]
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{e4['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e4["revision"],
|
||||
"request_key": key,
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual(after_first, as_json(data)["workspace"]["pending_unilateral"])
|
||||
self.assertEqual(after_first, self._workspace(self.cashier_a)["pending_unilateral"])
|
||||
|
||||
# 失败请求不得误减:用不存在的事件
|
||||
before = self._workspace(self.cashier_a)["pending_unilateral"]
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/transfer-events/999999/confirm",
|
||||
{
|
||||
"expected_revision": 1,
|
||||
"request_key": "hel168-fail",
|
||||
"counterparty_company_id": self.company_b,
|
||||
},
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
self.assertEqual(before, self._workspace(self.cashier_a)["pending_unilateral"])
|
||||
|
||||
# 乙公司不能确认甲公司事件(即使猜到 id)
|
||||
status, _, data = self.cashier_b.post_json(
|
||||
f"/api/company/transfer-events/{e2['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e2["revision"],
|
||||
"request_key": "hel168-cross",
|
||||
"counterparty_company_id": self.company_a,
|
||||
},
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
def test_admin_forbidden_on_company_workspace(self) -> None:
|
||||
status, _, data = self.admin.get("/api/company/workspace")
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Dashboard aggregate helpers and admin HTTP endpoints (no openpyxl dependency)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import dashboard, 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"
|
||||
|
||||
|
||||
class DashboardUnitTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
migrate(self.connection)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def test_empty_dashboard(self) -> None:
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(0, payload["audit"]["total"])
|
||||
self.assertEqual([], payload["companies"])
|
||||
self.assertEqual(7, len(payload["weekly_flow"]["labels"]))
|
||||
|
||||
def test_pending_account_counts_as_medium(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
company_id = int(cursor.lastrowid)
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000001",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_pending_manual_counts_as_medium(self) -> None:
|
||||
from bank_importer import auth, manual_records
|
||||
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
self.connection.commit()
|
||||
auth.create_user(
|
||||
self.connection, "cashier-a", "CashierPass123", "company", company_id=int(a)
|
||||
)
|
||||
actor = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual_records.submit(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
counterparty_company_id=int(b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-manual-1",
|
||||
actor=actor,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_audit_total_equals_queue_sum(self) -> None:
|
||||
"""Homepage / badge / audit heading must share one backend total."""
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000002",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
|
||||
'single', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(counts, payload["audit"])
|
||||
self.assertEqual(
|
||||
counts["total"],
|
||||
counts["high"] + counts["medium"] + counts["low"],
|
||||
)
|
||||
self.assertEqual(1, counts["high"])
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(2, counts["total"])
|
||||
|
||||
def test_company_summaries_from_eligible_events(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'intercompany',
|
||||
'paired', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_decision_participants (
|
||||
decision_id, role, company_id, bank_account_id, resolve_method, created_at
|
||||
) VALUES (?, 'payer', ?, NULL, 'manual', ?), (?, 'payee', ?, NULL, 'manual', ?)
|
||||
""",
|
||||
(decision_id, a, now, decision_id, b, now),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
items, totals = dashboard.company_summaries(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, totals["detail_count"])
|
||||
by_name = {row["name"]: row for row in items}
|
||||
self.assertEqual(1, by_name["甲公司"]["detail_count"])
|
||||
self.assertEqual("10.00", by_name["甲公司"]["debit_wan"])
|
||||
self.assertEqual("-10.00", by_name["甲公司"]["net_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["credit_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["net_wan"])
|
||||
|
||||
detail = dashboard.company_peer_groups(
|
||||
self.connection, int(a), from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, len(detail["groups"]))
|
||||
self.assertEqual("乙公司", detail["groups"][0]["peer_name"])
|
||||
self.assertIsNone(detail["groups"][0]["opening"])
|
||||
|
||||
|
||||
class DashboardApiTests(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
|
||||
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/companies", {"name": "甲公司", "username": "cashier-a"}
|
||||
)
|
||||
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
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_dashboard_ok_for_admin(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("ok", data["status"])
|
||||
self.assertIn("audit", data)
|
||||
self.assertEqual(1, len(data["companies"]))
|
||||
self.assertEqual("甲公司", data["companies"][0]["name"])
|
||||
|
||||
def test_audit_counts_sync_after_account_review(self) -> None:
|
||||
"""Pending account raises dashboard.audit; approve brings total back down."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000099",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
before = as_json(raw)["audit"]
|
||||
self.assertEqual(200, status)
|
||||
self.assertGreaterEqual(before["medium"], 1)
|
||||
self.assertGreaterEqual(before["total"], 1)
|
||||
|
||||
status, _, raw = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 sync test"},
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
after = as_json(raw)["audit"]
|
||||
self.assertEqual(after["medium"], before["medium"] - 1)
|
||||
self.assertEqual(after["total"], before["total"] - 1)
|
||||
self.assertEqual(
|
||||
after["total"], after["high"] + after["medium"] + after["low"]
|
||||
)
|
||||
|
||||
def test_three_queue_audit_parity_and_dispose_sync(self) -> None:
|
||||
"""Seed account+manual+exception: list sizes == dashboard.audit; dispose syncs -1."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import auth, manual_records, master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
# Second company for manual counterparty.
|
||||
status, _, raw = self.admin.post_json(
|
||||
"/api/admin/companies", {"name": "乙公司", "username": "cashier-b-hel157"}
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
company_b = as_json(raw).get("company_id") or as_json(raw).get("id")
|
||||
self.assertIsNotNone(company_b)
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000157",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
|
||||
cashier = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
if cashier is None:
|
||||
auth.create_user(
|
||||
connection,
|
||||
"cashier-a",
|
||||
"CashierPass123",
|
||||
"company",
|
||||
company_id=company_id,
|
||||
)
|
||||
cashier = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual = manual_records.submit(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
counterparty_company_id=int(company_b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-queue-manual",
|
||||
actor=cashier,
|
||||
)
|
||||
|
||||
now = md.utc_now()
|
||||
event_id = connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
|
||||
'single', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
connection.commit()
|
||||
manual_id = manual["id"]
|
||||
manual_decision_id = manual["decision_id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def queue_sizes():
|
||||
st, _, body = self.admin.get("/api/admin/accounts?status=pending")
|
||||
self.assertEqual(200, st, body)
|
||||
accounts_n = len(as_json(body)["accounts"])
|
||||
st, _, body = self.admin.get("/api/admin/manual-records?state=pending")
|
||||
self.assertEqual(200, st, body)
|
||||
manuals_n = len(as_json(body)["records"])
|
||||
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||
self.assertEqual(200, st, body)
|
||||
exceptions_n = len(as_json(body)["exceptions"])
|
||||
return accounts_n, manuals_n, exceptions_n, accounts_n + manuals_n + exceptions_n
|
||||
|
||||
def assert_parity(expected_total: int) -> dict:
|
||||
st, _, body = self.admin.get("/api/admin/dashboard")
|
||||
self.assertEqual(200, st, body)
|
||||
audit = as_json(body)["audit"]
|
||||
accounts_n, manuals_n, exceptions_n, list_total = queue_sizes()
|
||||
self.assertEqual(expected_total, audit["total"])
|
||||
self.assertEqual(expected_total, list_total)
|
||||
self.assertEqual(
|
||||
audit["total"], audit["high"] + audit["medium"] + audit["low"]
|
||||
)
|
||||
self.assertGreaterEqual(accounts_n, 1 if expected_total >= 3 else 0)
|
||||
return {
|
||||
"audit": audit,
|
||||
"accounts": accounts_n,
|
||||
"manuals": manuals_n,
|
||||
"exceptions": exceptions_n,
|
||||
}
|
||||
|
||||
before = assert_parity(3)
|
||||
self.assertEqual(1, before["accounts"])
|
||||
self.assertEqual(1, before["manuals"])
|
||||
self.assertEqual(1, before["exceptions"])
|
||||
self.assertEqual(1, before["audit"]["high"])
|
||||
self.assertEqual(2, before["audit"]["medium"])
|
||||
|
||||
# Dispose account → total 2
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 three-queue dispose account"},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_account = assert_parity(2)
|
||||
|
||||
# Dispose manual → total 1
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{manual_id}/decisions",
|
||||
{
|
||||
"action": "approve_new",
|
||||
"reason": "HEL-157 three-queue dispose manual",
|
||||
"expected_decision_id": manual_decision_id,
|
||||
"request_key": "hel157-dispose-manual",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_manual = assert_parity(1)
|
||||
self.assertEqual(0, after_manual["manuals"])
|
||||
|
||||
# Dispose match exception via reverse → total 0
|
||||
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||
exceptions = as_json(body)["exceptions"]
|
||||
self.assertEqual(1, len(exceptions))
|
||||
target = exceptions[0]
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/transfer-events/{target['event_id']}/decisions",
|
||||
{
|
||||
"action": "reverse",
|
||||
"reason": "HEL-157 three-queue dispose match",
|
||||
"expected_revision": target["revision"],
|
||||
"request_key": "hel157-dispose-match",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_match = assert_parity(0)
|
||||
self.assertEqual(0, after_match["exceptions"])
|
||||
self.assertEqual(after_account["audit"]["total"] - 1, after_manual["audit"]["total"])
|
||||
self.assertEqual(after_manual["audit"]["total"] - 1, after_match["audit"]["total"])
|
||||
|
||||
def test_company_detail_missing(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(404, status)
|
||||
self.assertEqual("error", data["status"])
|
||||
|
||||
def test_company_detail_ok(self) -> None:
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
data = as_json(raw)
|
||||
company_id = data["companies"][0]["id"]
|
||||
status, _, raw = self.admin.get(
|
||||
f"/api/admin/dashboard/companies/{company_id}?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
detail = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], detail["groups"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -1,10 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
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]
|
||||
@@ -83,5 +92,62 @@ class StatementParserTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
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, 7], 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",
|
||||
"system_settings",
|
||||
"system_setting_changes",
|
||||
"reminders",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([7, 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, 7], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], 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([7, 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Integration tests for system settings persistence and reminder flow."""
|
||||
|
||||
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.db import connect, migrate
|
||||
from bank_importer import auth, settings
|
||||
|
||||
import server
|
||||
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.cookies: dict[str, str] = {}
|
||||
|
||||
def request(self, method, path, body=None, headers=None):
|
||||
connection = HTTPConnection(self.host, self.port)
|
||||
request_headers = dict(headers or {})
|
||||
if self.cookies:
|
||||
request_headers["Cookie"] = "; ".join(
|
||||
f"{k}={v}" for k, v in self.cookies.items()
|
||||
)
|
||||
connection.request(method, path, body=body, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
set_cookie = dict(response.getheaders()).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, data
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post_json(self, path, payload):
|
||||
return self.request(
|
||||
"POST", path, body=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def as_json(data: bytes):
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
class SettingsAndRemindersTests(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)
|
||||
server.ensure_bootstrap_admin(connection)
|
||||
# One active account for company 1 so pending items are generated.
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, 'active', 't', 't')"
|
||||
)
|
||||
company_id = connection.execute(
|
||||
"SELECT id FROM companies WHERE name = '甲公司'"
|
||||
).fetchone()["id"]
|
||||
connection.execute(
|
||||
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
|
||||
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
|
||||
(company_id,),
|
||||
)
|
||||
cls.company_id = company_id
|
||||
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
|
||||
|
||||
@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 test_settings_roundtrip_persists(self) -> None:
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
updated = as_json(data)["settings"]
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("2026-01-05", updated["start_date"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
self.assertEqual("5", updated["remind_days"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
def test_invalid_closing_day_rejected(self) -> None:
|
||||
for bad in ("0", "29", "abc"):
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
|
||||
self.assertEqual(400, status, (bad, data))
|
||||
|
||||
def test_setting_change_is_audited(self) -> None:
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
|
||||
self.assertEqual(200, status)
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT key, before_value, after_value, actor_username "
|
||||
"FROM system_setting_changes WHERE key = 'closing_day' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual("7", rows[0]["after_value"])
|
||||
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
|
||||
self.assertEqual("group-admin", rows[0]["actor_username"])
|
||||
|
||||
def test_reminder_pending_and_send(self) -> None:
|
||||
status, data = self.admin.get(
|
||||
f"/api/admin/reminders/pending?company_id={self.company_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/reminders/send", {"company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertGreaterEqual(len(payload["reminders"]), 1)
|
||||
self.assertTrue(payload["deadline"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/reminders")
|
||||
self.assertEqual(200, status)
|
||||
history = as_json(data)["reminders"]
|
||||
self.assertGreaterEqual(len(history), 1)
|
||||
self.assertEqual(self.company_id, history[0]["company_id"])
|
||||
|
||||
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
|
||||
# A company user must not be able to read or write admin settings.
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
initial = as_json(data)["initial_password"]
|
||||
cashier = Client("127.0.0.1", self.port)
|
||||
status, data = cashier.post_json(
|
||||
"/api/login",
|
||||
{"username": "cashier-x", "password": initial, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
status, data = cashier.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": "Changed123"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
|
||||
for path in ("/api/admin/settings", "/api/admin/reminders"):
|
||||
status, _ = cashier.get(path)
|
||||
self.assertEqual(403, status, path)
|
||||
|
||||
# Company session must get 403 on admin write endpoints (settings /
|
||||
# reminders / review decisions), not just GETs.
|
||||
write_calls = (
|
||||
("POST /api/admin/settings", lambda: cashier.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "10", "start_date": "2026-01-01", "auto_remind": "0", "remind_days": "3"},
|
||||
)),
|
||||
("POST /api/admin/reminders/send", lambda: cashier.post_json(
|
||||
"/api/admin/reminders/send", {"company_id": self.company_id}
|
||||
)),
|
||||
("POST /api/admin/accounts/1/review", lambda: cashier.post_json(
|
||||
"/api/admin/accounts/1/review", {"decision": "approve"}
|
||||
)),
|
||||
("POST /api/admin/transfer-events/1/decisions", lambda: cashier.post_json(
|
||||
"/api/admin/transfer-events/1/decisions",
|
||||
{"action": "confirm_single", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/manual-records/1/decisions", lambda: cashier.post_json(
|
||||
"/api/admin/manual-records/1/decisions",
|
||||
{"action": "approve_new", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/intercompany/events/1/subject-decisions", lambda: cashier.post_json(
|
||||
"/api/admin/intercompany/events/1/subject-decisions",
|
||||
{"subject": "other_receivable", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/intercompany/events/1/adjustments", lambda: cashier.post_json(
|
||||
"/api/admin/intercompany/events/1/adjustments",
|
||||
{"action": "reverse", "reason": "越权"},
|
||||
)),
|
||||
)
|
||||
for label, call in write_calls:
|
||||
status, data = call()
|
||||
self.assertEqual(403, status, f"{label}: {data}")
|
||||
# Settings must be unchanged after the forbidden write attempt.
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("5", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
|
||||
class SettingsModuleTests(unittest.TestCase):
|
||||
"""Unit tests for the settings module on a fresh in-memory database."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
|
||||
def test_defaults_applied_when_no_row_exists(self) -> None:
|
||||
values = settings.get_settings(self.connection)
|
||||
self.assertEqual("5", values["closing_day"])
|
||||
self.assertEqual("2026-01-01", values["start_date"])
|
||||
self.assertEqual("1", values["auto_remind"])
|
||||
self.assertEqual("3", values["remind_days"])
|
||||
|
||||
def test_validate_rejects_bad_values(self) -> None:
|
||||
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
|
||||
_, error = settings.validate_settings(bad)
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"start_date": "2026-13-40"})
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"auto_remind": "2"})
|
||||
self.assertIsNotNone(error)
|
||||
|
||||
def test_update_writes_value_and_trail(self) -> None:
|
||||
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
|
||||
user = self.connection.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
updated = settings.update_settings(
|
||||
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
|
||||
)
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
changes = self.connection.execute(
|
||||
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(changes))
|
||||
self.assertEqual("5", changes[0]["before_value"])
|
||||
self.assertEqual("1", changes[0]["after_value"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+898
-190
File diff suppressed because it is too large
Load Diff
+2560
-449
File diff suppressed because it is too large
Load Diff
+772
-131
@@ -3,176 +3,817 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="往来台账公司业务端" />
|
||||
<title>A公司业务端 · 往来台账</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<meta name="description" content="金牛集团公司业务端" />
|
||||
<title>公司业务端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-portal="company">
|
||||
<!--
|
||||
THESIS: 公司端是面向出纳的当月处理驾驶舱,拒绝复制总账端的管理视角。
|
||||
OWN-WORLD: 与集团一致的深黑玻璃系统,以荧光绿表示可执行动作,以琥珀红区分断档和待确认风险。
|
||||
STORY: 出纳从四个本公司指标进入上传、匹配和复核,补齐证据后完成当月确认。
|
||||
FIRST VIEWPORT: 浮动公司侧栏与账期顶栏围合工作区;四张待办统计卡领衔,阻断结账的异常和任务队列位于其下。
|
||||
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<div class="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>
|
||||
<div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div>
|
||||
<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" 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="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="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>
|
||||
<div class="side-brand">
|
||||
<div class="brand-name">金牛实业 · 资金往来</div>
|
||||
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
|
||||
<span class="side-role company">公司业务端</span>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<div class="nav-group">业务</div>
|
||||
<a class="active" data-view="workspace" href="#workspace"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></svg><span class="nav-label">工作台</span></a>
|
||||
<a data-view="upload" href="#upload"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 16V4m0 0l-4 4m4-4l4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg><span class="nav-label">流水导入</span></a>
|
||||
<a data-view="manual" href="#manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg><span class="nav-label">手工记录</span></a>
|
||||
<a data-view="flows" href="#flows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="nav-label">流水管理</span></a>
|
||||
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
|
||||
<div class="nav-group">账户与消息</div>
|
||||
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
|
||||
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge">3</span></a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a class="nav-item" href="index.html"><svg><use href="icons.svg#log-out"/></svg><span>退出登录</span></a>
|
||||
<div class="user-block"><span class="avatar">牛</span><span><strong>牛女士</strong><small>A公司出纳</small></span></div>
|
||||
<div class="side-foot">
|
||||
<div class="user-row">
|
||||
<span class="avatar">牛</span>
|
||||
<div>
|
||||
<div class="user-name">牛女士</div>
|
||||
<div class="user-meta">金牛煤业 · 煤业出纳</div>
|
||||
</div>
|
||||
<button class="logout" type="button">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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 class="main">
|
||||
<div class="topbar">
|
||||
<span class="crumb">公司业务端 / <b id="currentViewName">工作台</b></span>
|
||||
<div class="topbar-right">
|
||||
<span class="tag">账期 2026-07</span>
|
||||
<span class="tag">统计截止 2026-08-20</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main id="main-content">
|
||||
<main class="content" id="main-content">
|
||||
<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>
|
||||
<section class="metric-grid" aria-label="本公司关键指标">
|
||||
<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>
|
||||
<span class="metric-icon"><svg><use href="icons.svg#upload"/></svg></span>
|
||||
<p class="metric-foot"><strong>工商银行 9481</strong> · 缺少 21 天</p>
|
||||
</article>
|
||||
<article class="metric-card warning" data-metric-link="reconcile" tabindex="0" role="button" aria-label="确认 1 笔待匹配流水">
|
||||
<div class="metric-copy"><span class="metric-label">待确认流水</span><strong class="metric-value">1<small>笔</small></strong></div>
|
||||
<span class="metric-icon"><svg><use href="icons.svg#arrow-left-right"/></svg></span>
|
||||
<p class="metric-foot"><strong>280.00 万元</strong> · 有 2 个候选</p>
|
||||
</article>
|
||||
<article class="metric-card" data-metric-link="manual" tabindex="0" role="button" aria-label="查看 1 笔待复核手工记录">
|
||||
<div class="metric-copy"><span class="metric-label">待复核记录</span><strong class="metric-value">1<small>笔</small></strong></div>
|
||||
<span class="metric-icon"><svg><use href="icons.svg#list-checks"/></svg></span>
|
||||
<p class="metric-foot"><strong>已提交总账</strong> · 等待管理员处理</p>
|
||||
</article>
|
||||
<article class="metric-card success">
|
||||
<div class="metric-copy"><span class="metric-label">本月完成进度</span><strong class="metric-value">75<small>%</small></strong></div>
|
||||
<span class="metric-icon"><svg><use href="icons.svg#circle-check"/></svg></span>
|
||||
<p class="metric-foot"><strong>3 / 4 个账户</strong> · 距结账还有 3 天</p>
|
||||
</article>
|
||||
</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="账户覆盖卡栈">
|
||||
<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="deck-stage">
|
||||
<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="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="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="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 class="page-head">
|
||||
<div>
|
||||
<h1>工作台</h1>
|
||||
<p class="page-sub"><span class="company-identity">河南金牛煤业有限公司</span> · 2026 年 7 月账期。本月 18 项账期任务已完成 13 项,剩余 4 项待办,其中 3 项阻断结账。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn" data-open-upload>上传流水</button>
|
||||
<button class="btn btn-primary" id="workspaceUnilateralCta" data-view-link="reconcile">去确认单边流水 (0)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期流程 · 2026-07<span class="sub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
|
||||
<span class="pill pill-warn">结账日顺延至 08-29</span>
|
||||
</div>
|
||||
<div class="flow">
|
||||
<a class="flow-step part" data-view-link="upload" href="#upload">
|
||||
<div class="fs-top"><span class="fs-idx">01</span><span class="fs-dot"></span><span class="fs-name">流水导入</span></div>
|
||||
<div class="fs-state">部分完成</div>
|
||||
<div class="fs-meta">2/3 账户已覆盖 · 交行 7710 待审核</div>
|
||||
</a>
|
||||
<a class="flow-step done" data-view-link="manual" href="#manual">
|
||||
<div class="fs-top"><span class="fs-idx">02</span><span class="fs-dot"></span><span class="fs-name">手工补录</span></div>
|
||||
<div class="fs-state">已完成</div>
|
||||
<div class="fs-meta">7 月手工记录 5 笔已提交</div>
|
||||
</a>
|
||||
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
|
||||
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
|
||||
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
|
||||
<div class="fs-meta" id="workspaceConfirmMeta">正在读取待确认单边流水</div>
|
||||
</a>
|
||||
<div class="flow-step wait">
|
||||
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
|
||||
<div class="fs-state">等待集团</div>
|
||||
<div class="fs-meta">2 笔复核在途 · 无需本公司操作</div>
|
||||
</div>
|
||||
<div class="flow-step wait">
|
||||
<div class="fs-top"><span class="fs-idx">05</span><span class="fs-dot"></span><span class="fs-name">账期结账</span></div>
|
||||
<div class="fs-state">08-29</div>
|
||||
<div class="fs-meta">需先完成上方前 3 步</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-3-2" style="margin-top: 14px;">
|
||||
<div class="stack">
|
||||
<div class="card" id="workspaceTodos">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">按权威待确认单边流水同步</span></span>
|
||||
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
|
||||
</div>
|
||||
<div id="workspaceTodoList"></div>
|
||||
<div class="table-foot" id="workspaceTodoFoot">
|
||||
<span>正在读取待办…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账户覆盖情况<span class="sub">本公司已登记银行账户与流水上传覆盖区间</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="accounts">管理账户 →</button>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr><th>银行账户</th><th>流水覆盖区间</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="cell-main">工商银行<span class="cell-sub">尾号 3305 · 基本户</span></td>
|
||||
<td class="meta">2026-01-01 ~ 2026-08-20</td>
|
||||
<td><span class="pill pill-success">已覆盖</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-main">中国银行<span class="cell-sub">尾号 9916 · 一般户</span></td>
|
||||
<td class="meta">2026-01-01 ~ 2026-08-12</td>
|
||||
<td><span class="pill pill-warn">部分覆盖</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-main">交通银行<span class="cell-sub">尾号 7710 · 一般户</span></td>
|
||||
<td class="meta">未上传</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
<div class="ms-label">借方合计</div>
|
||||
<div class="ms-value">18,420.50<span class="unit">万元</span></div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="ms-label">贷方合计</div>
|
||||
<div class="ms-value">12,386.00<span class="unit">万元</span></div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="ms-label">明细笔数</div>
|
||||
<div class="ms-value">128<span class="unit">笔</span></div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="ms-label">期末净往来 · 应收方向</div>
|
||||
<div class="ms-value pos">+6,034.50<span class="unit">万元</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" data-view-link="flows" style="width: 100%; margin-top: 12px;">查看本公司逐笔流水 →</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3) →</button>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span class="pill pill-warn">提醒</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">7 月账期结账日顺延至 08-29</div>
|
||||
<div class="lr-sub">请在此之前完成往来确认事项</div>
|
||||
</div>
|
||||
<span class="lr-side meta">08-18</span>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span class="pill pill-warn">提醒</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">3 笔单边流水待确认</div>
|
||||
<div class="lr-sub">涉及金牛置业、金牛贸易往来</div>
|
||||
</div>
|
||||
<span class="lr-side meta">08-16</span>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span class="pill pill-success">通过</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">中行尾号 9916 流水导入成功</div>
|
||||
<div class="lr-sub">流水覆盖至 08-12</div>
|
||||
</div>
|
||||
<span class="lr-side meta">08-12</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="company-dashboard-grid">
|
||||
<section class="panel task-panel">
|
||||
<div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div>
|
||||
<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="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><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>
|
||||
</section>
|
||||
<aside class="panel coverage-panel">
|
||||
<div class="panel-heading"><div><h2>账户覆盖</h2><p>起算日 2026.01.01</p></div><span class="status warning">3 / 4 连续</span></div>
|
||||
<div class="coverage-list"><article><span><strong>中信银行 · 5316</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>建设银行 · 0845</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>农业银行 · 3650</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>工商银行 · 9481</strong><small>缺少 07.01—07.21</small></span><em class="status danger">断档</em></article></div>
|
||||
<button class="text-button full-text-button" data-view-link="accounts">查看全部账户</button>
|
||||
</aside>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>流水导入</h1>
|
||||
<p class="page-sub">上传银行导出的 Excel 流水文件,系统按表头识别银行模板、解析期间与明细并做覆盖校验。当前账期 2026-07,仅已审核通过的账户可导入。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-1-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">上传流水<span class="sub">先选账户,再选择或拖入流水文件</span></span>
|
||||
</div>
|
||||
<form id="uploadForm" novalidate>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="accountSelect">选择银行账户</label>
|
||||
<select class="select" id="accountSelect" required><option value="">请选择账户</option></select>
|
||||
<span class="hint">仅已审核通过的账户可上传;选择账户与文件后方可解析</span>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label>流水文件</label>
|
||||
<input type="file" id="fileInput" accept=".xls,.xlsx" hidden />
|
||||
<div class="dropzone" id="dropzone" tabindex="0" role="button" aria-label="拖放或点击选择流水文件">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 16V5m0 0L8 9m4-4l4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||
<div class="dz-title">拖放银行流水文件到此处</div>
|
||||
<div class="dz-sub">或 <span class="dz-browse">点击选择文件</span>,支持银行导出的 Excel</div>
|
||||
<div class="dz-formats">.XLS / .XLSX · MAX 20MB</div>
|
||||
</div>
|
||||
<div class="file-chip" id="filePreview" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>
|
||||
<div class="fc-info">
|
||||
<div class="fc-name" id="fileName"></div>
|
||||
<div class="fc-meta" id="fileMeta"></div>
|
||||
</div>
|
||||
<button type="button" class="fc-remove" id="removeFile" aria-label="移除已选文件">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice info" id="parseResult" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 18px; height: 18px; flex: none; margin-top: 1px;"><use href="icons.svg#circle-check"/></svg>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="n-title" id="parseTitle">文件解析完成</div>
|
||||
<div class="n-body" id="parseSummary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sheetReview" hidden>
|
||||
<div class="card-title" style="margin: 14px 0 6px;">工作表处理</div>
|
||||
<span class="hint" style="display: block; margin-bottom: 10px;">解析成功不等于业务确认:只有勾选并确认的工作表才会进入后续匹配与计算。</span>
|
||||
<div id="sheetList" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="parseButton" type="submit" disabled style="width: 100%; margin-top: 14px;"><span>开始解析</span></button>
|
||||
<span class="hint" style="display: block; margin-top: 8px; text-align: center;">请先选择流水文件,选择后此按钮方可点击</span>
|
||||
|
||||
<div class="detail-box" style="margin-top: 14px;">
|
||||
<div style="font-weight: 650; margin-bottom: 6px;">导入规则</div>
|
||||
<div class="stack" style="gap: 5px;">
|
||||
<div>· 同一账户同一月份重复导入时,新批次覆盖旧批次</div>
|
||||
<div>· 导入后系统自动与对方公司流水轧算往来余额</div>
|
||||
<div>· 无法解析的行转入审核中心,不影响其余明细入库</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">历史导入批次<span class="sub">2026 年 7 月账期 · 本公司全部账户</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>批次</th><th>银行账户</th><th>流水期间</th>
|
||||
<th class="num-col">明细数</th><th>覆盖状态</th><th>解析状态</th>
|
||||
<th>上传时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="importRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span id="importFoot">暂无批次</span>
|
||||
<span class="meta">同一账户同一月份重复导入时,新批次覆盖旧批次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="manual">
|
||||
<header class="page-heading"><div><h1>手工记录</h1><p>登记个人过账或银行流水之外的往来事实</p></div><span class="status warning" id="manualPendingStatus">1 笔待总账复核</span></header>
|
||||
<div class="manual-layout">
|
||||
<form class="panel manual-form" id="manualEntryForm">
|
||||
<div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div>
|
||||
<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="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><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><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><textarea name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></label>
|
||||
<div class="form-grid"><label class="field"><span>关联银行流水号</span><input name="bankReference" maxlength="80" placeholder="可选" /></label><label class="field"><span>证明附件</span><input name="evidence" type="file" accept=".pdf,.png,.jpg,.jpeg,.xls,.xlsx" /></label></div>
|
||||
<p class="form-callout"><svg><use href="icons.svg#circle-alert"/></svg>手工记录与银行原始流水分开留痕;总账复核通过后才纳入往来计算。</p>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>手工记录</h1>
|
||||
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由管理员审核,通过后计入本公司往来台账。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-1-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">登记手工往来<span class="sub">带 * 为必填项,提交后进入待审核状态</span></span>
|
||||
</div>
|
||||
<footer><button class="button primary" type="submit"><svg><use href="icons.svg#send"/></svg>提交总账复核</button></footer>
|
||||
</form>
|
||||
<section class="panel manual-records-panel">
|
||||
<div class="panel-heading"><div><h2>本公司记录</h2><p>按提交时间倒序</p></div></div>
|
||||
<div class="table-scroll"><table class="data-table manual-records-table"><thead><tr><th>编号 / 日期</th><th>方向 / 科目</th><th>资金来源</th><th>对方</th><th>摘要</th><th class="number">金额(元)</th><th>状态</th></tr></thead><tbody id="manualRecordRows"><tr data-demo-record><td><strong>MR-DEMO-001</strong><small>2026.07.29</small></td><td><strong>付款</strong><small>其他应收</small></td><td>个人过账</td><td><strong>B公司</strong><small>集团内部公司</small></td><td>个人代付后转回</td><td class="number">80,000.00</td><td><span class="status warning">待总账复核</span></td></tr></tbody></table></div>
|
||||
</section>
|
||||
<form id="manualEntryForm" novalidate>
|
||||
<div class="grid grid-2" style="gap: 12px;">
|
||||
<div class="field"><label>交易日期 *</label><input class="input num-input" name="transactionDate" type="date" value="2026-08-06" required /></div>
|
||||
<div class="field"><label>收付方向 *</label><select class="select" name="direction" required><option>付款</option><option>收款</option></select></div>
|
||||
<div class="field"><label>金额(元)*</label><input class="input num-input" name="amount" type="number" min="0.01" step="0.01" required /></div>
|
||||
<div class="field"><label>资金来源 *</label><select class="select" name="sourceAccount" required><option value="">请选择</option><option>个人过账</option></select></div>
|
||||
<div class="field"><label>对方类型 *</label><select class="select" name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></div>
|
||||
<div class="field"><label>对方名称 *</label><input class="input" name="counterparty" maxlength="100" placeholder="公司全称或个人姓名" required /></div>
|
||||
<div class="field"><label>对方账号</label><input class="input" name="counterpartyAccount" maxlength="64" placeholder="可选" /></div>
|
||||
<div class="field"><label>往来科目 *</label><select class="select" name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></div>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;"><label>业务摘要 *</label><input class="input" name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></div>
|
||||
<div class="field" style="margin-top: 12px;"><label>补充说明 *</label><textarea class="textarea" name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></div>
|
||||
<div class="grid grid-2" style="gap: 12px; margin-top: 12px;">
|
||||
<div class="field"><label>关联银行流水号</label><input class="input" name="bankReference" maxlength="80" placeholder="可选" /></div>
|
||||
<div class="field"><label>证明附件</label><input class="input" name="evidence" type="file" accept=".pdf,.png,.jpg,.jpeg,.xls,.xlsx" /></div>
|
||||
</div>
|
||||
<div class="notice info" style="margin-top: 14px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
|
||||
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">管理员复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交管理复核</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待管理复核</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th><th>方向</th><th>对方</th><th>科目</th>
|
||||
<th class="num-col">金额(元)</th><th class="wrap">摘要</th><th>审核状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualRecordRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty" id="manualEmpty" hidden>
|
||||
<div class="e-title">暂无已提交记录</div>
|
||||
登记的手工往来提交后会显示在这里。
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span id="manualFoot">共 0 条</span>
|
||||
<span class="meta">审核由管理员在 1 个工作日内完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="flows">
|
||||
<header class="page-heading"><div><h1>流水管理</h1><p>仅查询和导出 A公司的银行流水</p></div><button class="button secondary" id="exportFlows"><svg><use href="icons.svg#download"/></svg>导出当前结果</button></header>
|
||||
<section class="filter-grid flow-filters"><label class="field"><span>银行</span><select id="flowBank"><option>全部银行</option><option>中信银行</option><option>工商银行</option><option>建设银行</option></select></label><label class="field"><span>账户</span><select id="flowAccount"><option>全部账户</option><option>5316</option><option>9481</option><option>0845</option></select></label><label class="field"><span>开始日期</span><input id="flowStart" type="date" value="2026-07-01" /></label><label class="field"><span>结束日期</span><input id="flowEnd" type="date" value="2026-07-31" /></label><label class="field grow"><span>摘要 / 对方户名 / 流水号</span><input id="flowKeyword" placeholder="输入关键词" /></label><button class="button primary" id="applyFlowFilters"><svg><use href="icons.svg#search"/></svg>查询</button></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>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>流水管理</h1>
|
||||
<p class="page-sub">本公司名下银行账户的原始流水及归集状态。集团内往来流水将进入往来确认与归集链路,外部流水不参与归集。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="exportFlows">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 15px; height: 15px;"><path d="M12 4v11m0 0l-4-4m4 4l4-4"/><path d="M4 17v2.5A1.5 1.5 0 0 0 5.5 21h13a1.5 1.5 0 0 0 1.5-1.5V17"/></svg>
|
||||
导出 CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<div class="field">
|
||||
<label for="flowBank">银行</label>
|
||||
<select class="select" id="flowBank"><option>全部银行</option><option>工商银行</option><option>中国银行</option><option>交通银行</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="flowAccount">账户</label>
|
||||
<select class="select" id="flowAccount"><option>全部账户</option><option>3305</option><option>9916</option><option>7710</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="flowStart">日期起</label>
|
||||
<input class="input num-input" id="flowStart" type="date" value="2026-07-01" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="flowEnd">日期止</label>
|
||||
<input class="input num-input" id="flowEnd" type="date" value="2026-08-20" />
|
||||
</div>
|
||||
<div class="field" style="min-width: 200px;">
|
||||
<label for="flowKeyword">关键词</label>
|
||||
<input class="input" id="flowKeyword" type="text" placeholder="对方单位 / 摘要 / 流水号" />
|
||||
</div>
|
||||
<button class="btn" id="resetFlowFilters">重置</button>
|
||||
<button class="btn btn-primary" id="applyFlowFilters">查询</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="ds-table" id="flowTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>银行账户</th>
|
||||
<th>方向</th>
|
||||
<th class="wrap">对方单位</th>
|
||||
<th class="wrap">摘要</th>
|
||||
<th>银行流水号</th>
|
||||
<th>归集状态</th>
|
||||
<th class="num-col">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="flowEmpty" hidden>
|
||||
<div class="e-title">没有符合条件的流水</div>
|
||||
调整筛选条件后重新查询。
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span id="flowCount">共 0 笔</span>
|
||||
<span class="num" id="flowSum"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="reconcile">
|
||||
<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="panel reconcile-panel"><div class="panel-heading"><div><h2>待确认事项</h2><p>每次确认都会记录操作人、时间和依据</p></div></div><div class="review-list" id="reviewList"><article data-review-type="match"><div class="review-main"><span class="task-level warning">匹配</span><div><h3>2026.07.18 · 转出 280.00 万元</h3><p>中信银行 5316 → B公司 · 摘要“往来款”</p></div></div><fieldset class="candidate-options"><legend>选择对方银行证据</legend><label><input type="radio" name="matchCandidate" value="B公司工商银行 9481" checked /><span><strong>B公司 · 工商银行 9481</strong><small>同金额 · 同日 · 对方账号一致 · 推荐</small></span></label><label><input type="radio" name="matchCandidate" value="B公司建设银行 2046" /><span><strong>B公司 · 建设银行 2046</strong><small>同金额 · 相差 1 日 · 摘要相近</small></span></label></fieldset><div class="review-actions"><button class="button secondary small" data-reject>无法匹配,转异常</button><button class="button primary small" data-resolve="match">确认所选匹配</button></div></article><article data-review-type="subject"><div class="review-main"><span class="task-level warning">科目</span><div><h3>2026.06.27 · 转出 600.00 万元</h3><p>建设银行 0845 → C公司 · 摘要“资金调拨”</p></div></div><div class="candidate"><label class="field compact"><span>确认科目</span><select><option>应收</option><option>其他应收</option></select></label><button class="button primary small" data-resolve="subject">确认科目</button></div></article></div><div class="review-history" id="reviewHistory" hidden><h3>本次处理记录</h3></div></section>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>往来确认</h1>
|
||||
<p class="page-sub">处理本公司流水与对方公司对账出现的单边记录,并确认往来科目归属。7 月结账日因故顺延至 08-29,逾期未确认将阻断本公司结账。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice warn" id="blocking-notice" style="margin-bottom: 16px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 18px; height: 18px; flex: none; margin-top: 1px;"><path d="M12 8v5M12 16.5v.01"/><path d="M10.3 4.1L2.8 17a2 2 0 0 0 1.7 3h15a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0z"/></svg>
|
||||
<div>
|
||||
<div class="n-title" id="notice-title">正在读取待确认单边流水…</div>
|
||||
<div class="n-body" id="notice-body">工作台与往来确认共用同一权威待确认集合。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" class="active" id="tab-match" aria-pressed="true">单边流水匹配<span class="tab-count" id="count-match">0</span></button>
|
||||
<button type="button" id="tab-subject" aria-pressed="false">科目确认<span class="tab-count" id="count-subject">2</span></button>
|
||||
</div>
|
||||
|
||||
<section id="panel-match">
|
||||
<div class="stack" id="match-stack"></div>
|
||||
|
||||
<div class="card" style="margin-top: 14px; display: none;" id="matched-summary">
|
||||
<div class="card-head">
|
||||
<span class="card-title">已匹配记录<span class="sub">本页已确认的单边流水</span></span>
|
||||
</div>
|
||||
<div id="matched-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="panel-subject" style="display: none;">
|
||||
<div class="card" style="padding: 0;">
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th><th>对方</th><th class="num-col">金额</th><th>摘要</th><th>当前建议科目</th><th>科目确认</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr data-subject-row>
|
||||
<td class="num">2026-07-18</td>
|
||||
<td class="cell-main">金牛新能源</td>
|
||||
<td class="num-col amt-in">¥680,000.00</td>
|
||||
<td>矿区光伏场地租赁费</td>
|
||||
<td><span class="tag">其他应收</span></td>
|
||||
<td>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<select class="select" style="min-height: 30px; padding: 4px 8px;"><option>应收</option><option>应付</option><option selected>其他应收</option><option>其他应付</option></select>
|
||||
<button class="btn btn-sm btn-primary subject-confirm-btn">确认</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="subject-status"><span class="pill pill-warn">待确认</span></td>
|
||||
</tr>
|
||||
<tr data-subject-row>
|
||||
<td class="num">2026-07-25</td>
|
||||
<td class="cell-main">金牛农业</td>
|
||||
<td class="num-col amt-out">¥420,000.00</td>
|
||||
<td>职工食堂食材统采分摊</td>
|
||||
<td><span class="tag">其他应付</span></td>
|
||||
<td>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<select class="select" style="min-height: 30px; padding: 4px 8px;"><option>应收</option><option>应付</option><option>其他应收</option><option selected>其他应付</option></select>
|
||||
<button class="btn btn-sm btn-primary subject-confirm-btn">确认</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="subject-status"><span class="pill pill-warn">待确认</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span>共 2 条待确认 · 确认后可在「流水管理」中继续查看</span>
|
||||
<span class="num">合计 ¥1,100,000.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>银行账户</h1>
|
||||
<p class="page-sub">本公司名下已登记银行账户。仅审核通过的账户可上传流水,账号对外仅显示尾号。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="openAccountDialog">提交新账户登记</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice info" style="margin-bottom: 14px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
|
||||
<div>
|
||||
<div class="n-title">新账户需管理员审核通过后方可上传流水</div>
|
||||
<div class="n-body">提交登记后由管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本公司账户<span class="sub">共 <span id="account-count">0</span> 个 · 统计截止 2026-08-20</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>银行 / 账号</th>
|
||||
<th>账户类型</th>
|
||||
<th>启用状态</th>
|
||||
<th>流水覆盖区间</th>
|
||||
<th>审核状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="account-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty" id="account-empty" hidden>
|
||||
<div class="e-title">暂无已登记账户</div>
|
||||
点击右上角「提交新账户登记」提交开户信息。
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span id="account-foot">共 0 个账户</span>
|
||||
<span class="meta">仅审核通过的账户可上传流水并参与归集</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="notifications">
|
||||
<header class="page-heading"><div><h1>通知</h1><p>系统提醒与总账管理员通知</p></div><button class="button secondary" id="markAllRead"><svg><use href="icons.svg#circle-check"/></svg>全部标为已读</button></header>
|
||||
<section class="panel notification-page"><div class="notification-list" id="companyNotifications"><article class="is-unread"><span class="notification-icon danger"><svg><use href="icons.svg#circle-alert"/></svg></span><span><strong>7 月工商银行流水尚未上传</strong><small>系统自动提醒 · 今天 08:00 · 截止 08.05</small><p>账户尾号 9481 缺少 07.01—07.21 流水。</p></span><em class="status danger">未读</em></article><article class="is-unread"><span class="notification-icon warning"><svg><use href="icons.svg#bell"/></svg></span><span><strong>请完成 7 月银行流水确认</strong><small>系统管理员 · 今天 09:30 · 截止 08.08</small><p>请先处理断档和两项待确认往来,再提交公司确认。</p></span><em class="status warning">未读</em></article><article><span class="notification-icon success"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>6 月账期已完成集团结账</strong><small>系统通知 · 07.05 18:10</small></span><em class="status neutral">已读</em></article></div></section>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>通知</h1>
|
||||
<p class="page-sub">系统与管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-ghost" id="mark-all-read">全部标为已读</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="tabs" id="notice-tabs">
|
||||
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">9</span></button>
|
||||
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">3</span></button>
|
||||
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">2</span></button>
|
||||
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">4</span></button>
|
||||
</div>
|
||||
|
||||
<div id="notice-list">
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">7 月流水存在断档风险:交行 尾号 7710 未上传</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-20 14:32</span> · 交行 7710 账户(待审核)7 月流水尚未上传,距 7 月结账日(顺延至 08-29)仅剩 9 天,请尽快补传。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">您有 2 笔单边流水待选择对方证据</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-19 09:15</span> · 与金牛置业的煤炭采购款 ¥3,200,000.00 等 2 笔流水仅有本方记录,需选择对方银行流水佐证。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">管理员提醒:请于 08-29 前完成待确认事项</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="doing">
|
||||
<span class="pill pill-warn" style="flex: none;">处理中</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待管理员复核。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="doing">
|
||||
<span class="pill pill-warn" style="flex: none;">处理中</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="reconcile">去处理</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">流水导入成功:中行 尾号 9916 · 42 笔</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-06 15:52</span> · 7 月中行一般户流水已导入,42 笔全部校验通过,无重复记录。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">流水导入成功:工行 尾号 3305 · 86 笔</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-06 15:48</span> · 7 月工行基本户流水已导入,86 笔全部校验通过,无重复记录。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
|
||||
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">2026 年 6 月账期已结账</div>
|
||||
<div class="lr-sub"><span class="meta">2026-07-05 09:00</span> · 6 月账期已按每月 5 日结账规则完成结账,期末数据已锁定,不可再修改。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty" id="notice-empty" style="display: none;">
|
||||
<div class="e-title">暂无该状态的通知</div>
|
||||
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="uploadDialog" class="dialog upload-dialog">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dialog>
|
||||
<dialog id="accountDialog" class="dialog">
|
||||
<form id="accountForm">
|
||||
<header><div><h2>登记银行账户</h2><p>账户提交后由总账管理员复核</p></div><button type="button" class="icon-button" data-close-account aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><div class="form-grid"><label class="field"><span>开户银行</span><input name="bank" placeholder="例如:郑州银行" required /></label><label class="field"><span>账户类型</span><select name="type"><option>基本户</option><option>一般户</option><option>专用户</option></select></label></div><label class="field"><span>银行账号</span><input name="accountNumber" inputmode="numeric" required /></label><label class="field"><span>启用日期</span><input name="startDate" type="date" value="2026-08-06" required /></label></div>
|
||||
<footer><button type="button" class="button secondary" data-close-account>取消</button><button class="button primary" type="submit">提交登记</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
<!-- 导入批次详情弹窗 -->
|
||||
<div class="modal-backdrop" id="modal-batch">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">导入批次详情</span>
|
||||
<button type="button" class="modal-close" data-close="modal-batch" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub" id="mb-sub">—</p>
|
||||
<div class="detail-box">
|
||||
<dl class="kv">
|
||||
<dt>批次号</dt><dd id="mb-id">—</dd>
|
||||
<dt>账户</dt><dd id="mb-bank">—</dd>
|
||||
<dt>流水期间</dt><dd id="mb-period">—</dd>
|
||||
<dt>明细数量</dt><dd id="mb-count">—</dd>
|
||||
<dt>覆盖状态</dt><dd id="mb-cover">—</dd>
|
||||
<dt>解析状态</dt><dd id="mb-parse">—</dd>
|
||||
<dt>导入人</dt><dd id="mb-user">本公司出纳</dd>
|
||||
<dt>导入时间</dt><dd id="mb-time">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close="modal-batch">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 撤回手工记录弹窗 -->
|
||||
<div class="modal-backdrop" id="withdraw-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">撤回手工记录</span>
|
||||
<button type="button" class="modal-close" data-close="withdraw-modal" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">撤回后该记录将从审核队列中移除,且不可恢复,需重新登记提交。</p>
|
||||
<div class="detail-box">
|
||||
<dl class="kv">
|
||||
<dt>业务日期</dt><dd id="wd-date">—</dd>
|
||||
<dt>对方公司</dt><dd id="wd-peer">—</dd>
|
||||
<dt>金额</dt><dd id="wd-amount">—</dd>
|
||||
<dt>摘要</dt><dd id="wd-summary" style="font-family: var(--font-body);">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close="withdraw-modal">取消</button>
|
||||
<button type="button" class="btn btn-danger" id="wd-confirm">确认撤回</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流水详情弹窗 -->
|
||||
<div class="modal-backdrop" id="tx-modal">
|
||||
<div class="modal wide">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">流水详情</span>
|
||||
<button type="button" class="modal-close" id="tx-modal-close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub" id="tx-modal-sub"></p>
|
||||
<div class="detail-box">
|
||||
<div class="card-title" style="margin-bottom: 10px;">原始流水</div>
|
||||
<dl class="kv">
|
||||
<dt>银行流水号</dt><dd id="d-serial"></dd>
|
||||
<dt>银行</dt><dd id="d-bank"></dd>
|
||||
<dt>账户</dt><dd id="d-account"></dd>
|
||||
<dt>交易时间</dt><dd id="d-time"></dd>
|
||||
<dt>对方户名</dt><dd id="d-peer"></dd>
|
||||
<dt>对方账号</dt><dd id="d-peer-acct"></dd>
|
||||
<dt>金额</dt><dd id="d-amount"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-box" style="margin-top: 12px;">
|
||||
<div class="card-title" style="margin-bottom: 10px;">归集链路</div>
|
||||
<dl class="kv">
|
||||
<dt>归集状态</dt><dd id="d-status"></dd>
|
||||
<dt>归集往来</dt><dd id="d-pair"></dd>
|
||||
<dt>本方科目</dt><dd id="d-subject"></dd>
|
||||
<dt>归集批次</dt><dd id="d-batch"></dd>
|
||||
<dt>说明</dt><dd id="d-note" style="font-family: var(--font-body);"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" id="tx-modal-ok">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户明细弹窗 -->
|
||||
<div class="modal-backdrop" id="modal-account-detail">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="ad-title">账户明细</span>
|
||||
<button type="button" class="modal-close" data-close="modal-account-detail" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub" id="ad-sub">本公司 · 登记账户明细</p>
|
||||
<div class="detail-box" style="margin-bottom: 12px;">
|
||||
<dl class="kv">
|
||||
<dt>开户银行</dt><dd id="ad-bank">—</dd>
|
||||
<dt>账号尾号</dt><dd id="ad-tail">—</dd>
|
||||
<dt>账户类型</dt><dd id="ad-type">—</dd>
|
||||
<dt>开户行名称</dt><dd id="ad-branch">—</dd>
|
||||
<dt>登记日期</dt><dd id="ad-reg">—</dd>
|
||||
<dt>启用状态</dt><dd id="ad-status">—</dd>
|
||||
<dt>审核状态</dt><dd id="ad-audit">—</dd>
|
||||
<dt>用途说明</dt><dd id="ad-purpose" style="font-family: var(--font-body);">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-box">
|
||||
<dl class="kv">
|
||||
<dt>申请启用</dt><dd id="ad-effective">—</dd>
|
||||
<dt>流水覆盖区间</dt><dd id="ad-range">—</dd>
|
||||
<dt>退回原因</dt><dd id="ad-reason" style="font-family: var(--font-body);">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close="modal-account-detail">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登记银行账户弹窗 -->
|
||||
<div class="modal-backdrop" id="accountDialog">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">登记银行账户</span>
|
||||
<button type="button" class="modal-close" data-close="accountDialog" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">账户提交后由管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
|
||||
<form id="accountForm" novalidate>
|
||||
<div class="grid grid-2">
|
||||
<div class="field">
|
||||
<label>开户银行</label>
|
||||
<input class="input" name="bank" placeholder="例如:郑州银行" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>账户类型</label>
|
||||
<select class="select" name="type"><option>基本户</option><option>一般户</option><option>专用户</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>银行账号</label>
|
||||
<input class="input num-input" name="accountNumber" inputmode="numeric" required />
|
||||
<span class="hint">仅尾号对外显示,完整账号仅管理员可见</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>启用日期</label>
|
||||
<input class="input num-input" name="startDate" type="date" required />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close="accountDialog">取消</button>
|
||||
<button type="submit" class="btn btn-primary">提交登记</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
/* ─── 金牛实业资金往来管理系统 · 共享样式 ─────────────────────────────
|
||||
方向:tech-utility(数据密集型工具)。六枚基础 token 绑定设计方向,
|
||||
状态色仅在此 :root 块内以 oklch 派生,组件一律引用变量。 */
|
||||
|
||||
:root {
|
||||
--bg: oklch(98% 0.005 250);
|
||||
--surface: oklch(100% 0 0);
|
||||
--fg: oklch(22% 0.02 240);
|
||||
--muted: oklch(50% 0.018 240);
|
||||
--border: oklch(90% 0.008 240);
|
||||
--accent: oklch(58% 0.16 145);
|
||||
|
||||
/* 状态色 — 仅此处定义 */
|
||||
--success: oklch(55% 0.14 150);
|
||||
--warn: oklch(62% 0.14 70);
|
||||
--danger: oklch(55% 0.18 25);
|
||||
--info: oklch(52% 0.12 240);
|
||||
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
--success-soft: color-mix(in oklch, var(--success) 12%, transparent);
|
||||
--warn-soft: color-mix(in oklch, var(--warn) 14%, transparent);
|
||||
--danger-soft: color-mix(in oklch, var(--danger) 11%, transparent);
|
||||
--info-soft: color-mix(in oklch, var(--info) 10%, transparent);
|
||||
--fg-soft: color-mix(in oklch, var(--fg) 5%, transparent);
|
||||
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--side-w: 232px;
|
||||
}
|
||||
|
||||
/* ─── reset ─────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img, svg { display: block; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; cursor: pointer; }
|
||||
h1, h2, h3, h4 { margin: 0; line-height: 1.3; }
|
||||
p { margin: 0; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ─── 应用外壳 ──────────────────────────────────────────────────── */
|
||||
.shell { display: grid; grid-template-columns: var(--side-w) 1fr; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.side-brand {
|
||||
padding: 18px 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.side-brand .brand-name { font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
|
||||
.side-brand .brand-sub { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 3px; letter-spacing: 0.02em; }
|
||||
.side-role {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.side-role.company { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
.side-nav { flex: 1; overflow-y: auto; padding: 12px 10px; }
|
||||
.side-nav .nav-group { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em; color: var(--muted); padding: 14px 10px 6px; }
|
||||
.side-nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.side-nav a svg { width: 16px; height: 16px; flex: none; }
|
||||
.side-nav a:hover { color: var(--fg); background: var(--fg-soft); }
|
||||
.side-nav a.active {
|
||||
color: var(--fg);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-nav a.active svg { color: var(--accent); }
|
||||
.side-nav a .nav-badge {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-foot { border-top: 1px solid var(--border); padding: 12px 16px; }
|
||||
.side-foot .user-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.side-foot .user-row > div { min-width: 0; }
|
||||
.side-foot .avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: var(--fg); color: var(--surface);
|
||||
display: grid; place-items: center;
|
||||
font-size: 12px; font-weight: 600;
|
||||
flex: none;
|
||||
}
|
||||
.side-foot .user-name { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.side-foot .user-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side-foot .logout { margin-left: auto; color: var(--muted); font-size: 12px; padding: 4px 6px; border-radius: 6px; background: none; border: 0; flex: none; white-space: nowrap; }
|
||||
.side-foot .logout:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
.main { min-width: 0; display: flex; flex-direction: column; }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: color-mix(in oklch, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 28px;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar .crumb { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
||||
.topbar .crumb b { color: var(--fg); font-weight: 600; }
|
||||
.topbar .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.content { padding: 24px 28px 64px; max-width: 1440px; width: 100%; margin-inline: auto; }
|
||||
|
||||
/* ─── 页头 ──────────────────────────────────────────────────────── */
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.page-head h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.page-head .page-sub { color: var(--muted); font-size: 13px; margin-top: 5px; max-width: 72ch; }
|
||||
.page-head .page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ─── 按钮 ──────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
padding: 8px 14px;
|
||||
min-height: 34px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 13px; font-weight: 550;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.btn:hover { border-color: color-mix(in oklch, var(--fg) 40%, var(--border)); background: var(--fg-soft); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
|
||||
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 88%, black); border-color: color-mix(in oklch, var(--accent) 88%, black); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
|
||||
.btn-ghost:hover { color: var(--fg); background: var(--fg-soft); }
|
||||
.btn-danger { color: var(--danger); border-color: color-mix(in oklch, var(--danger) 35%, var(--border)); }
|
||||
.btn-danger:hover { background: var(--danger-soft); border-color: var(--danger); }
|
||||
.btn-sm { padding: 4px 10px; min-height: 26px; font-size: 12px; border-radius: 6px; }
|
||||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary[disabled] { opacity: 1; background: var(--fg-soft); border-color: var(--border); color: var(--muted); }
|
||||
|
||||
/* ─── 卡片 ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.card-title { font-size: 14px; font-weight: 650; letter-spacing: -0.005em; }
|
||||
.card-title .sub { display: block; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* ─── 指标卡 ────────────────────────────────────────────────────── */
|
||||
.stat-card { padding: 16px 18px; }
|
||||
.stat-card .stat-label { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
||||
.stat-card .stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 26px; font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.stat-card .stat-value .unit { font-size: 13px; color: var(--muted); font-weight: 400; margin-left: 2px; }
|
||||
.stat-card .stat-foot { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||
.stat-card.alert .stat-value { color: var(--danger); }
|
||||
.stat-card.warn .stat-value { color: var(--warn); }
|
||||
.stat-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.stat-dot.danger { background: var(--danger); }
|
||||
.stat-dot.warn { background: var(--warn); }
|
||||
.stat-dot.success { background: var(--success); }
|
||||
.stat-dot.info { background: var(--info); }
|
||||
|
||||
/* ─── 栅格与布局工具 ────────────────────────────────────────────── */
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.grid-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.grid-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; }
|
||||
.grid-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: start; }
|
||||
.grid-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
||||
/* 管理端首页主从:左栏约 480px(定稿 HEL-153),右侧明细更宽 */
|
||||
.grid-480-1 { grid-template-columns: minmax(0, 480px) minmax(0, 1fr); align-items: stretch; }
|
||||
|
||||
/* 方案 A:一体卡片 · 分栏主从(HEL-162) */
|
||||
.dash-master {
|
||||
margin-top: 14px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-head {
|
||||
padding: 12px 16px 10px;
|
||||
margin-bottom: 0;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dash-master-head .card-title .sub {
|
||||
display: inline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.dash-master-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.dash-master-head-actions .input {
|
||||
width: 132px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
flex: none;
|
||||
}
|
||||
.dash-master-split {
|
||||
gap: 0;
|
||||
/* 少数据自适应;整卡(含共享头)目标约 340px;多数据模块内滚动 */
|
||||
max-height: 300px;
|
||||
/* max-height alone 无法约束隐式行高;补可收缩行后 pane 内 overflow:auto 才会生效 */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-company-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-company-loading,
|
||||
.dash-company-empty {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.dash-company-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.dash-company-item:hover {
|
||||
background: var(--fg-soft);
|
||||
}
|
||||
.dash-company-item.is-selected {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
padding-left: 13px;
|
||||
}
|
||||
.dash-company-item.is-selected:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dash-company-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.dash-company-name {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13.5px;
|
||||
font-weight: 550;
|
||||
color: var(--fg);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.dash-company-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-company-net {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
min-width: 4.5em;
|
||||
}
|
||||
.dash-detail-toolbar {
|
||||
padding: 10px 14px 0;
|
||||
flex: none;
|
||||
}
|
||||
.dash-detail-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dash-detail-name {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin: 10px 14px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
flex: none;
|
||||
}
|
||||
.dash-summary-item {
|
||||
padding: 8px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.dash-summary-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.dash-summary-value {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dash-master-scroll {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-detail-empty {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-detail-empty .e-title {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.dash-detail-empty .e-desc {
|
||||
max-width: 360px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.dash-master-scroll[hidden],
|
||||
.dash-detail-empty[hidden],
|
||||
.dash-detail-summary[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.ds-table.ds-table--compact {
|
||||
min-width: 0;
|
||||
}
|
||||
.ds-table.ds-table--compact th,
|
||||
.ds-table.ds-table--compact td {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.ds-table.ds-table--compact th {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ds-table tbody tr.is-selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.ds-table tbody tr.is-selected:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.muted { color: var(--muted); }
|
||||
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.meta { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
||||
.mt-0 { margin-top: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid-4, .grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2, .grid-480-1 { grid-template-columns: minmax(0, 1fr); }
|
||||
.dash-master-split {
|
||||
max-height: none;
|
||||
grid-template-rows: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 280px;
|
||||
}
|
||||
.dash-master-pane--detail {
|
||||
max-height: 420px;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.dash-summary-item:nth-child(3) {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-item:nth-child(n+4) {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.content { padding: 16px 16px 48px; }
|
||||
.dash-master-head-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dash-master-head-actions .input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.dash-summary-item:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-item:nth-child(3) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-summary-item:nth-child(n+3) {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 数据表 ────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.ds-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 640px; }
|
||||
.ds-table th, .ds-table td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.ds-table th {
|
||||
color: var(--muted); font-weight: 550;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg);
|
||||
position: sticky; top: 0;
|
||||
}
|
||||
.ds-table tbody tr:hover { background: var(--fg-soft); }
|
||||
.ds-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.ds-table .num-col { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.ds-table td.wrap, .ds-table th.wrap { white-space: normal; min-width: 180px; }
|
||||
.ds-table .cell-main { font-weight: 550; }
|
||||
.ds-table .cell-sub { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.ds-table tr.clickable { cursor: pointer; }
|
||||
.ds-table .amt-in { color: var(--success); }
|
||||
.ds-table .amt-out { color: var(--danger); }
|
||||
.table-foot { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* ─── 徽章 / 状态 ───────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.pill-success { background: var(--success-soft); color: var(--success); }
|
||||
.pill-warn { background: var(--warn-soft); color: color-mix(in oklch, var(--warn) 80%, black); }
|
||||
.pill-danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.pill-info { background: var(--info-soft); color: var(--info); }
|
||||
.pill-muted { background: var(--fg-soft); color: var(--muted); }
|
||||
.pill-accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── 筛选栏与表单 ──────────────────────────────────────────────── */
|
||||
.filters {
|
||||
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.field > label { font-size: 12px; color: var(--muted); font-weight: 550; }
|
||||
.input, .select, .textarea, .field .input, .field .select, .field .textarea {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus, .field .input:focus, .field .select:focus, .field .textarea:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.input.num-input { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.textarea { min-height: 84px; resize: vertical; line-height: 1.55; }
|
||||
.field .hint { font-size: 11.5px; color: var(--muted); }
|
||||
.field .hint.error { color: var(--danger); }
|
||||
|
||||
/* ─── Tabs ──────────────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; }
|
||||
.tabs button {
|
||||
background: none; border: 0;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px; font-weight: 550;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs button:hover { color: var(--fg); }
|
||||
.tabs button.active { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
.tabs button .tab-count {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
background: var(--fg-soft); color: var(--muted);
|
||||
margin-left: 5px;
|
||||
}
|
||||
.tabs button.active .tab-count { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* ─── 时间轴(账期) ─────────────────────────────────────────────── */
|
||||
.timeline { display: flex; gap: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); padding: 0; }
|
||||
.tl-cell { flex: 1; padding: 12px 10px; border-right: 1px solid var(--border); text-align: center; min-width: 0; }
|
||||
.tl-cell:last-child { border-right: 0; }
|
||||
.tl-cell .tl-month { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: 0.04em; }
|
||||
.tl-cell .tl-state { font-size: 12px; font-weight: 600; margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||||
.tl-cell.closed { background: var(--fg-soft); }
|
||||
.tl-cell.closed .tl-state { color: var(--muted); }
|
||||
.tl-cell.current { background: var(--accent-soft); }
|
||||
.tl-cell.current .tl-state { color: var(--accent); }
|
||||
.tl-cell.open .tl-state { color: var(--fg); }
|
||||
|
||||
/* ─── 进度条 ────────────────────────────────────────────────────── */
|
||||
.progress { height: 6px; border-radius: 999px; background: var(--fg-soft); overflow: hidden; }
|
||||
.progress > span { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
|
||||
.progress.warn > span { background: var(--warn); }
|
||||
.progress.danger > span { background: var(--danger); }
|
||||
|
||||
/* ─── 列表行(快查 / 待办 / 通知) ────────────────────────────────── */
|
||||
.list-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.list-row:last-child { border-bottom: 0; }
|
||||
.list-row .lr-main { min-width: 0; flex: 1; }
|
||||
.list-row .lr-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-row .lr-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
|
||||
.list-row .lr-side { text-align: right; flex: none; }
|
||||
[data-detail] { cursor: pointer; }
|
||||
.list-row[data-detail]:hover { background: var(--fg-soft); }
|
||||
.list-row[data-detail] { margin-inline: -4px; padding-inline: 4px; border-radius: var(--radius); }
|
||||
|
||||
/* ─── 弹窗 ──────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
background: color-mix(in oklch, var(--fg) 45%, transparent);
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
width: 100%; max-width: 560px;
|
||||
max-height: 86vh; overflow-y: auto;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 18px 50px color-mix(in oklch, var(--fg) 25%, transparent);
|
||||
}
|
||||
.modal.wide { max-width: 760px; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.modal-title { font-size: 16px; font-weight: 700; }
|
||||
.modal-close { background: none; border: 0; color: var(--muted); font-size: 18px; padding: 4px 8px; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--fg-soft); color: var(--fg); }
|
||||
.modal-sub { font-size: 12.5px; color: var(--muted); margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
/* ─── 明细展开行 ────────────────────────────────────────────────── */
|
||||
.detail-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 18px; font-size: 12.5px; }
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ─── 提示条 ────────────────────────────────────────────────────── */
|
||||
.notice {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.notice.warn { background: var(--warn-soft); border-color: color-mix(in oklch, var(--warn) 30%, transparent); }
|
||||
.notice.danger { background: var(--danger-soft); border-color: color-mix(in oklch, var(--danger) 30%, transparent); }
|
||||
.notice.info { background: var(--info-soft); border-color: color-mix(in oklch, var(--info) 25%, transparent); }
|
||||
.notice .n-title { font-weight: 650; }
|
||||
.notice .n-body { color: color-mix(in oklch, var(--fg) 80%, var(--muted)); margin-top: 2px; font-size: 12.5px; }
|
||||
|
||||
/* ─── 登录页 ────────────────────────────────────────────────────── */
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.login-aside {
|
||||
background: var(--fg);
|
||||
color: var(--surface);
|
||||
padding: 48px 56px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.login-aside .brand-mark { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; opacity: 0.65; }
|
||||
.login-aside h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin-top: 18px; line-height: 1.25; }
|
||||
.login-aside .aside-sub { opacity: 0.72; font-size: 14px; margin-top: 14px; max-width: 40ch; }
|
||||
.login-aside .aside-list { margin-top: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.login-aside .aside-item { display: flex; gap: 10px; font-size: 13px; opacity: 0.85; align-items: baseline; }
|
||||
.login-aside .aside-item .tick { font-family: var(--font-mono); color: var(--accent); }
|
||||
.login-panel { display: grid; place-items: center; padding: 48px 32px; }
|
||||
.login-card { width: 100%; max-width: 400px; }
|
||||
.login-card h2 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.login-card .login-sub { color: var(--muted); font-size: 13px; margin: 6px 0 24px; }
|
||||
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 18px; }
|
||||
.role-switch button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.role-switch button .r-name { font-size: 13.5px; font-weight: 650; }
|
||||
.role-switch button .r-desc { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||
.role-switch button.active { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.role-switch button.active .r-name { color: var(--accent); }
|
||||
.login-card .field { margin-bottom: 14px; }
|
||||
.login-foot { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.login-foot a { color: var(--accent); font-weight: 550; }
|
||||
.login-foot a:hover { text-decoration: underline; }
|
||||
|
||||
/* 端标识:登录页顶部大字区分管理端 / 公司端 */
|
||||
.login-role-title {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-role-title.admin { color: var(--accent); }
|
||||
.login-role-title.company { color: var(--info); }
|
||||
.login-role-caption { color: var(--muted); font-size: 13px; margin-bottom: 22px; }
|
||||
/* 管理端青绿 / 公司端品蓝侧栏色块 */
|
||||
.login-aside.company { background: var(--info); }
|
||||
.login-aside.company .aside-item .tick { color: var(--surface); opacity: 0.85; }
|
||||
.login-back {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.login-back:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.login-back.company:hover { border-color: var(--info); color: var(--info); }
|
||||
@media (max-width: 900px) {
|
||||
.login-wrap { grid-template-columns: 1fr; }
|
||||
.login-aside { display: none; }
|
||||
}
|
||||
|
||||
/* ─── 空状态 ────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty .e-title { font-weight: 600; color: var(--fg); margin-bottom: 4px; }
|
||||
|
||||
/* ─── 账期流程条(公司端工作台) ─────────────────────────────────── */
|
||||
.flow { display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
|
||||
.flow-step { padding: 14px 16px 13px; border-right: 1px solid var(--border); min-width: 0; }
|
||||
.flow-step:last-child { border-right: 0; }
|
||||
a.flow-step { cursor: pointer; transition: background 0.12s ease; }
|
||||
a.flow-step:hover { background: var(--fg-soft); }
|
||||
.flow-step .fs-top { display: flex; align-items: center; gap: 8px; }
|
||||
.flow-step .fs-idx { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
|
||||
.flow-step .fs-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--border); flex: none; }
|
||||
.flow-step .fs-name { font-size: 13.5px; font-weight: 650; }
|
||||
.flow-step .fs-state { font-size: 12px; font-weight: 600; margin-top: 7px; color: var(--muted); }
|
||||
.flow-step .fs-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.flow-step.done .fs-dot { background: var(--success); }
|
||||
.flow-step.done .fs-state { color: var(--success); }
|
||||
.flow-step.part .fs-dot { background: var(--warn); }
|
||||
.flow-step.part .fs-state { color: color-mix(in oklch, var(--warn) 78%, black); }
|
||||
.flow-step.doing { background: var(--warn-soft); }
|
||||
.flow-step.doing .fs-dot { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn) 22%, transparent); }
|
||||
.flow-step.doing .fs-state { color: color-mix(in oklch, var(--warn) 78%, black); }
|
||||
a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, transparent); }
|
||||
@media (max-width: 860px) {
|
||||
.flow { grid-template-columns: 1fr; }
|
||||
.flow-step { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.flow-step:last-child { border-bottom: 0; }
|
||||
}
|
||||
|
||||
/* ─── 迷你指标(2×2 数字块) ────────────────────────────────────── */
|
||||
.mini-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.mini-stat { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 13px; min-width: 0; }
|
||||
.mini-stat .ms-label { font-size: 11.5px; color: var(--muted); }
|
||||
.mini-stat .ms-value { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; margin-top: 3px; }
|
||||
.mini-stat .ms-value .unit { font-size: 11px; font-weight: 400; color: var(--muted); margin-left: 2px; }
|
||||
.mini-stat .ms-value.pos { color: var(--success); }
|
||||
.mini-stat .ms-value.neg { color: var(--danger); }
|
||||
|
||||
/* ─── 上传组件(拖放区 / 文件条 / 附件选择) ────────────────────── */
|
||||
.dropzone {
|
||||
border: 1.5px dashed color-mix(in oklch, var(--fg) 28%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg);
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.dropzone:hover, .dropzone:focus-visible { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.dropzone.dragover { border-color: var(--accent); border-style: solid; background: var(--accent-soft); }
|
||||
.dropzone svg { width: 28px; height: 28px; margin: 0 auto 10px; color: var(--muted); }
|
||||
.dropzone .dz-title { font-size: 14px; font-weight: 650; }
|
||||
.dropzone .dz-sub { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
|
||||
.dropzone .dz-sub .dz-browse { color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.dropzone .dz-formats { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 12px; letter-spacing: 0.02em; }
|
||||
|
||||
.file-chip {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.file-chip svg { width: 18px; height: 18px; color: var(--muted); flex: none; }
|
||||
.file-chip .fc-info { min-width: 0; flex: 1; }
|
||||
.file-chip .fc-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-chip .fc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.file-chip .fc-remove { background: none; border: 0; color: var(--muted); font-size: 16px; line-height: 1; padding: 4px 8px; border-radius: 6px; flex: none; }
|
||||
.file-chip .fc-remove:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
.attach-pick {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 7px 12px;
|
||||
min-height: 34px;
|
||||
border: 1.5px dashed color-mix(in oklch, var(--fg) 28%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.attach-pick svg { width: 15px; height: 15px; }
|
||||
.attach-pick:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ─── 数据表合计行 ─────────────────────────────────────────────── */
|
||||
.ds-table tfoot td { background: var(--bg); font-weight: 650; border-top: 1.5px solid color-mix(in oklch, var(--fg) 30%, var(--border)); border-bottom: 0; }
|
||||
|
||||
/* ─── 入口页(index) ────────────────────────────────────────────── */
|
||||
.portal-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; }
|
||||
.portal-inner { width: 100%; max-width: 880px; }
|
||||
.portal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 28px; }
|
||||
.portal-card {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
transition: border-color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
.portal-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.portal-card .pc-role { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; color: var(--accent); }
|
||||
.portal-card h3 { font-size: 17px; font-weight: 700; margin-top: 8px; }
|
||||
.portal-card p { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
||||
.portal-card .pc-go { font-size: 12.5px; font-weight: 600; color: var(--accent); margin-top: 14px; }
|
||||
@media (max-width: 720px) { .portal-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ─── SPA 视图切换 / 无障碍结构类 ───────────────────────────────── */
|
||||
.app-view { display: none; }
|
||||
.app-view.is-active { display: block; }
|
||||
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.skip-link {
|
||||
position: fixed; top: 8px; left: 50%; z-index: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent); color: var(--surface);
|
||||
font-size: 13px; font-weight: 600;
|
||||
transform: translate(-50%, -150%);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.skip-link:focus { transform: translate(-50%, 0); }
|
||||
|
||||
/* ─── 图标按钮 / 窄屏侧栏折叠 ──────────────────────────────────── */
|
||||
.icon-button {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 34px; height: 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface); color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.icon-button:hover { color: var(--fg); border-color: color-mix(in oklch, var(--fg) 40%, var(--border)); background: var(--fg-soft); }
|
||||
.icon-button svg { width: 18px; height: 18px; }
|
||||
.menu-button { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
.menu-button { display: inline-grid; }
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
|
||||
width: var(--side-w); height: 100dvh;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.sidebar.is-open { transform: none; box-shadow: 0 0 48px color-mix(in oklch, var(--fg) 22%, transparent); }
|
||||
}
|
||||
|
||||
/* ─── Toast 轻量提示 ───────────────────────────────────────────── */
|
||||
.toast-region { position: fixed; right: 16px; bottom: 16px; z-index: 300; display: grid; gap: 8px; }
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
min-width: 260px; max-width: 380px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 34px color-mix(in oklch, var(--fg) 18%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
.toast .t-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 5px; background: var(--muted); }
|
||||
.toast .t-body { min-width: 0; }
|
||||
.toast .t-title { font-weight: 650; }
|
||||
.toast .t-detail { color: var(--muted); font-size: 12px; margin-top: 1px; }
|
||||
.toast.success { border-color: color-mix(in oklch, var(--success) 35%, var(--border)); }
|
||||
.toast.success .t-dot { background: var(--success); }
|
||||
.toast.warn { border-color: color-mix(in oklch, var(--warn) 40%, var(--border)); }
|
||||
.toast.warn .t-dot { background: var(--warn); }
|
||||
.toast.danger { border-color: color-mix(in oklch, var(--danger) 35%, var(--border)); }
|
||||
.toast.danger .t-dot { background: var(--danger); }
|
||||
.toast.info { border-color: color-mix(in oklch, var(--info) 35%, var(--border)); }
|
||||
.toast.info .t-dot { background: var(--info); }
|
||||
@media (max-width: 720px) {
|
||||
.toast-region { right: 12px; bottom: 12px; left: 12px; }
|
||||
.toast { min-width: 0; max-width: none; }
|
||||
}
|
||||
|
||||
/* ─── 侧滑详情抽屉(用 modal/card token 重画) ─────────────────── */
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; z-index: 60;
|
||||
width: min(400px, calc(100vw - 24px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -18px 0 50px color-mix(in oklch, var(--fg) 22%, transparent);
|
||||
transform: translateX(100%);
|
||||
visibility: hidden;
|
||||
transition: transform 0.22s ease, visibility 0.22s;
|
||||
}
|
||||
.drawer.is-open { transform: none; visibility: visible; }
|
||||
.drawer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 20px 12px; border-bottom: 1px solid var(--border); }
|
||||
.drawer-head .d-title { font-size: 16px; font-weight: 700; margin-top: 6px; }
|
||||
.drawer-head .d-desc { color: var(--muted); font-size: 12.5px; margin-top: 4px; }
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.drawer-body .kv { grid-template-columns: auto 1fr; gap: 8px 18px; }
|
||||
.drawer-body .kv dt { color: var(--muted); white-space: nowrap; }
|
||||
.drawer-body .kv dd { text-align: right; word-break: break-all; }
|
||||
.drawer-tip { margin: 0 20px 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--muted); font-size: 12.5px; }
|
||||
.drawer-tip strong { display: block; color: var(--fg); margin-bottom: 4px; }
|
||||
.drawer-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ─── 加载中骨架 / 表内加载行 ──────────────────────────────────── */
|
||||
.skeleton { display: block; border-radius: var(--radius); background: var(--fg-soft); position: relative; overflow: hidden; }
|
||||
.skeleton::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--surface) 60%, transparent), transparent);
|
||||
animation: skeleton-shimmer 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||
.skeleton-line { height: 12px; margin-bottom: 10px; }
|
||||
.loading-row { padding: 40px 20px; text-align: center; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; letter-spacing: 0.04em; }
|
||||
.loading-inline { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; }
|
||||
.loading-inline::before {
|
||||
content: ""; width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||
animation: loading-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes loading-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
|
||||
.notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); }
|
||||
.notice.success .n-title { color: color-mix(in oklch, var(--success) 82%, black); }
|
||||
+30
-46
@@ -1,49 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="集团内部银行流水与往来账务管理" />
|
||||
<title>登录 · 往来台账</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body class="entry-page">
|
||||
<!--
|
||||
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
|
||||
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
|
||||
STORY: 用户确认工作身份后进入拥有独立导航、功能侧重和数据边界的对应端口。
|
||||
FIRST VIEWPORT: 左侧用大标题和三个系统事实建立产品身份,右侧玻璃表单首先选择角色再登录。
|
||||
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<main class="entry-shell">
|
||||
<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-statement">
|
||||
<h1>一笔往来,追溯到双方银行凭证。</h1>
|
||||
<p>当前演示环境 · 数据均为模拟</p>
|
||||
</div>
|
||||
<dl class="entry-facts">
|
||||
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
|
||||
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
|
||||
<div><dt>银行模板</dt><dd>已支持 6 家</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="entry-form-wrap" aria-labelledby="login-title">
|
||||
<form class="entry-form" id="loginForm">
|
||||
<header><h2 id="login-title">登录往来台账</h2><p>请选择与账号一致的工作端口</p></header>
|
||||
<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="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label>
|
||||
</div>
|
||||
<label class="field"><span>账号</span><input name="username" autocomplete="username" value="group-admin" 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="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>
|
||||
<p class="entry-note">演示账号不会验证密码,正式系统将由服务端校验账号与公司绑定关系。</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>金牛实业资金往来管理系统 · 入口</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal-wrap">
|
||||
<div class="portal-inner">
|
||||
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
|
||||
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
|
||||
|
||||
<div class="portal-grid">
|
||||
<a class="portal-card" href="login-admin.html">
|
||||
<span class="pc-role">管理端 · 7 个页面</span>
|
||||
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
|
||||
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<span class="pc-go">去管理端登录 →</span>
|
||||
</a>
|
||||
<a class="portal-card" href="login-company.html">
|
||||
<span class="pc-role">公司业务端 · 7 个页面</span>
|
||||
<h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 往来确认 / 银行账户 / 通知</h3>
|
||||
<p>面向成员公司出纳:上传银行流水、登记手工往来、确认单边匹配与科目,跟踪本月完成进度。</p>
|
||||
<span class="pc-go">去公司端登录 →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-role="admin">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
<div class="aside-list">
|
||||
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
|
||||
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
|
||||
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
|
||||
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title admin">管理端</div>
|
||||
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录管理端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="account">账号</label>
|
||||
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
|
||||
<span class="hint error" id="account-err" hidden>请输入账号</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<span class="hint error" id="password-err" hidden>请输入密码</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="hint error" id="login-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="pwd-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
|
||||
</div>
|
||||
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="new-pwd">新密码</label>
|
||||
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-pwd2">确认新密码</label>
|
||||
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<span class="hint error" id="pwd-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-role="company">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside company">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
<div class="aside-list">
|
||||
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
|
||||
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
|
||||
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
|
||||
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back company" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title company">公司端</div>
|
||||
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="account">账号</label>
|
||||
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
|
||||
<span class="hint error" id="account-err" hidden>请输入账号</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<span class="hint error" id="password-err" hidden>请输入密码</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="hint error" id="login-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="pwd-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
|
||||
</div>
|
||||
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="new-pwd">新密码</label>
|
||||
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-pwd2">确认新密码</label>
|
||||
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<span class="hint error" id="pwd-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
const ROLE = document.body.dataset.role || "admin";
|
||||
const REDIRECT = ROLE === "admin" ? "admin.html" : "company.html";
|
||||
|
||||
const form = document.getElementById("login-form");
|
||||
const accountInput = document.getElementById("account");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const accountErr = document.getElementById("account-err");
|
||||
const passwordErr = document.getElementById("password-err");
|
||||
const loginErr = document.getElementById("login-err");
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
|
||||
const modal = document.getElementById("pwd-modal");
|
||||
const newPwd = document.getElementById("new-pwd");
|
||||
const newPwd2 = document.getElementById("new-pwd2");
|
||||
const newPwdErr = document.getElementById("new-pwd-err");
|
||||
const newPwd2Err = document.getElementById("new-pwd2-err");
|
||||
const pwdErr = document.getElementById("pwd-err");
|
||||
const pwdConfirm = document.getElementById("pwd-confirm");
|
||||
|
||||
const submitLabel = "登 录";
|
||||
|
||||
function setLoading(loading) {
|
||||
submitButton.disabled = loading;
|
||||
submitButton.textContent = loading ? "登录中…" : submitLabel;
|
||||
accountInput.disabled = loading;
|
||||
passwordInput.disabled = loading;
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
loginErr.textContent = message || "";
|
||||
loginErr.hidden = !message;
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
newPwd.value = "";
|
||||
newPwd2.value = "";
|
||||
newPwdErr.hidden = true;
|
||||
newPwd2Err.hidden = true;
|
||||
pwdErr.hidden = true;
|
||||
modal.classList.add("open");
|
||||
newPwd.focus();
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
showLoginError("");
|
||||
const account = accountInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
accountErr.hidden = Boolean(account);
|
||||
passwordErr.hidden = Boolean(password);
|
||||
if (!account || !password) return;
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: account, password, portal: ROLE }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showLoginError(result?.message || "登录服务暂时不可用,请稍后重试。");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (result.must_change_password) {
|
||||
setLoading(false);
|
||||
openPasswordModal();
|
||||
return;
|
||||
}
|
||||
window.location.href = REDIRECT;
|
||||
});
|
||||
|
||||
pwdConfirm.addEventListener("click", async () => {
|
||||
const p1 = newPwd.value;
|
||||
const p2 = newPwd2.value;
|
||||
const valid = p1.length >= 8 && /[a-zA-Z]/.test(p1) && /\d/.test(p1);
|
||||
newPwdErr.hidden = valid;
|
||||
newPwd2Err.hidden = p1 === p2;
|
||||
pwdErr.hidden = true;
|
||||
if (!valid || p1 !== p2) return;
|
||||
|
||||
pwdConfirm.disabled = true;
|
||||
const response = await fetch("/api/password/change", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old_password: passwordInput.value, new_password: p1 }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
pwdErr.textContent = result?.message || "修改密码失败,请稍后重试。";
|
||||
pwdErr.hidden = false;
|
||||
pwdConfirm.disabled = false;
|
||||
return;
|
||||
}
|
||||
window.location.href = REDIRECT;
|
||||
});
|
||||
-625
@@ -1,625 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--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;
|
||||
--color-bg: #050706;
|
||||
--color-bg-soft: #090d0b;
|
||||
--color-nav: #090c0b;
|
||||
--color-surface: rgba(20, 26, 23, 0.82);
|
||||
--color-surface-solid: #131916;
|
||||
--color-surface-raised: #18201c;
|
||||
--color-surface-muted: rgba(255, 255, 255, 0.035);
|
||||
--color-line: rgba(255, 255, 255, 0.09);
|
||||
--color-line-strong: rgba(255, 255, 255, 0.16);
|
||||
--color-ink: #f2f7f4;
|
||||
--color-ink-soft: #a6b0aa;
|
||||
--color-ink-muted: #89938d;
|
||||
--color-primary: #37eb89;
|
||||
--color-primary-strong: #70ffae;
|
||||
--color-primary-dark: #08160e;
|
||||
--color-primary-wash: rgba(55, 235, 137, 0.11);
|
||||
--color-positive: #37eb89;
|
||||
--color-positive-wash: rgba(55, 235, 137, 0.1);
|
||||
--color-warning: #ffbc52;
|
||||
--color-warning-wash: rgba(255, 188, 82, 0.11);
|
||||
--color-danger: #ff626d;
|
||||
--color-danger-wash: rgba(255, 98, 109, 0.11);
|
||||
--color-info: #66a8ff;
|
||||
--color-info-wash: rgba(102, 168, 255, 0.11);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 22px;
|
||||
--radius-xl: 28px;
|
||||
--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-glow: 0 0 28px rgba(55, 235, 137, 0.12);
|
||||
--duration-fast: 150ms;
|
||||
--duration-standard: 260ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
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; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, a, summary { -webkit-tap-highlight-color: transparent; }
|
||||
button { color: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1, h2, h3, p, dl, dd { margin: 0; }
|
||||
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
::selection { background: rgba(55, 235, 137, 0.26); color: #fff; }
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 10px; background: rgba(255, 255, 255, 0.15); background-clip: padding-box; }
|
||||
|
||||
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.skip-link { position: fixed; top: 8px; left: 50%; z-index: 500; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-primary); color: var(--color-primary-dark); transform: translate(-50%, -150%); }
|
||||
.skip-link:focus { transform: translate(-50%, 0); }
|
||||
:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* Application frame */
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 252px 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%); }
|
||||
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 15px; 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-copy { display: flex; flex-direction: column; min-width: 0; }
|
||||
.brand-copy strong { font-size: 15px; }
|
||||
.brand-copy small, .user-block small, .company-context small { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.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 div { display: flex; flex-direction: column; }
|
||||
.nav-list { display: grid; gap: 5px; 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: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 svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); }
|
||||
.nav-item svg { width: 17px; flex: 0 0 auto; }
|
||||
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-item b { min-width: 19px; height: 19px; display: grid; place-items: center; margin-left: auto; border-radius: 7px; background: var(--color-primary-wash); color: var(--color-primary); font-size: 10px; }
|
||||
.sidebar-footer { display: grid; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-line); }
|
||||
.user-block { min-height: 51px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; }
|
||||
.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; }
|
||||
.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); }
|
||||
.workspace-name { display: flex; flex-direction: column; min-width: 150px; }
|
||||
.workspace-name span { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.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; }
|
||||
.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: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::placeholder, input::placeholder, textarea::placeholder { color: #626b66; }
|
||||
.period-button, .icon-button, .inside-icon, .swap-button { display: inline-grid; place-items: center; border: 1px solid var(--color-line); background: var(--color-surface-muted); color: var(--color-ink-soft); cursor: pointer; transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
|
||||
.period-button { min-height: 40px; grid-auto-flow: column; gap: 8px; padding: 0 13px; border-radius: 14px; }
|
||||
.icon-button { position: relative; width: 40px; height: 40px; border-radius: 13px; }
|
||||
.inside-icon { width: 36px; height: 36px; border: 0; background: transparent; }
|
||||
.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); }
|
||||
.menu-button { display: none; }
|
||||
main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.app-view { display: none; }
|
||||
.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 h1 { font-size: clamp(26px, 3vw, 35px); line-height: 1.15; letter-spacing: 0; }
|
||||
.page-heading p { margin-top: 7px; color: var(--color-ink-muted); }
|
||||
|
||||
/* 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:hover { transform: translateY(-1px); }
|
||||
.button:active { transform: translateY(0) scale(0.985); }
|
||||
.button.primary { background: var(--color-primary); color: var(--color-primary-dark); box-shadow: 0 8px 24px rgba(55, 235, 137, 0.18); }
|
||||
.button.primary:hover { background: var(--color-primary-strong); box-shadow: 0 10px 30px rgba(55, 235, 137, 0.25); }
|
||||
.button.secondary { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink); }
|
||||
.button.secondary:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.08); }
|
||||
.button.small { min-height: 34px; padding-inline: 11px; border-radius: 11px; font-size: 11px; }
|
||||
.button.wide { width: 100%; }
|
||||
.button:disabled { opacity: 0.42; cursor: not-allowed; transform: none; }
|
||||
.text-button { padding: 3px 0; border: 0; background: none; color: var(--color-primary); cursor: pointer; }
|
||||
.text-button:hover { color: var(--color-primary-strong); }
|
||||
.full-text-button { width: 100%; padding: 13px; border-top: 1px solid var(--color-line); }
|
||||
.field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--color-ink-soft); }
|
||||
.field > span { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.field input, .field select, .field textarea { width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--color-line); border-radius: 11px; outline: 0; background: rgba(2, 5, 3, 0.48); color: var(--color-ink); transition: border-color var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast); }
|
||||
.field textarea { resize: vertical; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { border-color: rgba(55, 235, 137, 0.5); background: rgba(2, 5, 3, 0.7); box-shadow: 0 0 0 3px rgba(55, 235, 137, 0.08); }
|
||||
.field select { color-scheme: dark; }
|
||||
.field.compact { min-width: 150px; }
|
||||
.password-field { display: grid; grid-template-columns: 1fr 38px; border: 1px solid var(--color-line); border-radius: 12px; background: rgba(2, 5, 3, 0.48); }
|
||||
.password-field input { border: 0; background: transparent; }
|
||||
.check-field { display: flex; align-items: center; gap: 8px; color: var(--color-ink-soft); }
|
||||
.check-field input { accent-color: var(--color-primary); }
|
||||
.swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; }
|
||||
|
||||
/* 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::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 { min-width: 0; overflow: hidden; }
|
||||
.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 h2 { font-size: 15px; }
|
||||
.panel-heading p { margin-top: 3px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.status { display: inline-flex; align-items: center; justify-content: center; gap: 5px; width: max-content; min-height: 23px; padding: 2px 8px; border: 1px solid transparent; border-radius: 8px; font-size: 10px; font-style: normal; white-space: nowrap; }
|
||||
.status.success { border-color: rgba(55, 235, 137, 0.18); background: var(--color-positive-wash); color: var(--color-positive); }
|
||||
.status.warning { border-color: rgba(255, 188, 82, 0.18); background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.status.danger { border-color: rgba(255, 98, 109, 0.18); background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.status.neutral { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); }
|
||||
.task-level { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; font-size: 10px; font-style: normal; font-weight: 800; }
|
||||
.task-level.danger { background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.task-level.warning { background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
|
||||
|
||||
/* Dashboard */
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; 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::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::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; }
|
||||
.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-copy { align-self: start; }
|
||||
.metric-label { display: block; color: var(--color-ink-soft); font-size: 12px; }
|
||||
.metric-value { display: block; margin-top: 7px; font: 700 31px/1 var(--font-data); letter-spacing: 0; }
|
||||
.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::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.12; pointer-events: none; }
|
||||
.metric-foot { grid-column: 1 / -1; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.metric-foot strong { color: var(--color-positive); }
|
||||
.metric-card.warning { color: var(--color-warning); }
|
||||
.metric-card.danger { color: var(--color-danger); }
|
||||
.metric-card.success { color: var(--color-positive); }
|
||||
.metric-card .metric-copy { color: var(--color-ink); }
|
||||
.metric-card .metric-icon { color: var(--color-ink-soft); }
|
||||
.metric-card .metric-foot { color: var(--color-ink-muted); }
|
||||
.metric-card[data-metric-link], .metric-card[data-metric-action] { cursor: pointer; }
|
||||
.metric-card.warning .metric-icon, .metric-card.warning .metric-foot strong { color: var(--color-warning); }
|
||||
.metric-card.warning .metric-icon::after { opacity: 0.28; }
|
||||
.metric-card.danger .metric-icon, .metric-card.danger .metric-foot strong { color: var(--color-danger); }
|
||||
.metric-card.danger .metric-icon::after { opacity: 0.32; }
|
||||
.metric-card.success .metric-icon { color: var(--color-positive); }
|
||||
.metric-card.success .metric-icon::after { opacity: 0.22; border-color: var(--color-positive); }
|
||||
.period-ribbon { display: grid; grid-template-columns: 1fr 1fr 1fr minmax(310px, 1.4fr); margin-bottom: 18px; overflow: hidden; }
|
||||
.period-ribbon > div { min-height: 72px; display: flex; flex-direction: column; justify-content: center; padding: 13px 17px; border-left: 1px solid var(--color-line); }
|
||||
.period-ribbon > div:first-child { border-left: 0; }
|
||||
.period-ribbon span, .period-ribbon small { color: var(--color-ink-muted); }
|
||||
.period-ribbon strong { margin-top: 3px; }
|
||||
.period-ribbon > .period-warning { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
.period-warning > svg { color: var(--color-warning); }
|
||||
.period-warning > span { display: flex; flex-direction: column; }
|
||||
.period-warning > span strong { color: var(--color-ink); }
|
||||
.admin-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr); gap: 18px; margin-bottom: 18px; }
|
||||
.task-list button { width: 100%; min-height: 73px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 18px; align-items: center; gap: 11px; padding: 12px 16px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; transition: background var(--duration-fast); }
|
||||
.task-list button:first-child { border-top: 0; }
|
||||
.task-list button:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
.task-list button > span:nth-child(2) { display: flex; flex-direction: column; }
|
||||
.task-list small, .task-list button > b { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.task-list button > svg { color: var(--color-ink-muted); }
|
||||
.quick-pair-form { display: grid; grid-template-columns: 1fr 38px 1fr; gap: 10px; padding: 16px; }
|
||||
.quick-pair-form .button { grid-column: 1 / -1; }
|
||||
.quick-result { margin: 0 16px 16px; padding: 14px; border: 1px solid rgba(55, 235, 137, 0.16); border-radius: var(--radius-md); background: var(--color-primary-wash); }
|
||||
.quick-result span, .quick-result p { color: var(--color-ink-muted); }
|
||||
.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); }
|
||||
|
||||
/* Stack-deck: signature 3D card-stack visual */
|
||||
.stack-deck { position: relative; height: 188px; perspective: 1400px; perspective-origin: 50% 60%; }
|
||||
.deck-stage { position: absolute; inset: 0; display: grid; place-items: center; }
|
||||
.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; }
|
||||
.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; }
|
||||
.deck-card > * { position: relative; }
|
||||
.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; }
|
||||
.deck-card .deck-name { display: flex; flex-direction: column; }
|
||||
.deck-card .deck-name strong { font-size: 14px; }
|
||||
.deck-card .deck-name small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.deck-card .deck-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.deck-card .deck-meta strong { font: 600 16px var(--font-data); color: var(--color-ink); }
|
||||
.deck-card .deck-meta small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.deck-card .deck-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 10px; color: var(--color-ink-muted); }
|
||||
.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); }
|
||||
.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 { 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::before { content: ""; position: absolute; }
|
||||
.timeline-legend { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
|
||||
.timeline-legend strong { font-size: 13px; }
|
||||
.timeline-legend span { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.timeline-body { position: relative; min-height: 88px; padding-top: 6px; }
|
||||
.timeline-axis { position: relative; height: 14px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); border-bottom: 1px solid var(--color-line); }
|
||||
.timeline-axis span { font: 600 10px var(--font-data); color: var(--color-ink-muted); align-self: end; padding-bottom: 4px; text-align: left; }
|
||||
.timeline-axis span + span { border-left: 1px solid var(--color-line); padding-left: 4px; }
|
||||
.timeline-rows { display: grid; gap: 8px; margin-top: 12px; }
|
||||
.timeline-row { position: relative; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px; font-size: 11px; }
|
||||
.timeline-row > span { color: var(--color-ink-soft); }
|
||||
.timeline-track { position: relative; height: 14px; border-radius: 7px; background: rgba(255, 255, 255, 0.04); overflow: hidden; }
|
||||
.timeline-track i { position: absolute; top: 0; bottom: 0; border-radius: inherit; }
|
||||
.timeline-track i.coverage { background: linear-gradient(90deg, rgba(55, 235, 137, 0.35), rgba(55, 235, 137, 0.55)); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.32); }
|
||||
.timeline-track i.warning { background: linear-gradient(90deg, rgba(255, 188, 82, 0.32), rgba(255, 188, 82, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 188, 82, 0.32); }
|
||||
.timeline-track i.danger { background: linear-gradient(90deg, rgba(255, 98, 109, 0.32), rgba(255, 98, 109, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 98, 109, 0.32); }
|
||||
.timeline-today { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 12px rgba(112, 255, 174, 0.6); border-radius: 1px; }
|
||||
.timeline-today::after { content: ""; position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: var(--color-primary-strong); box-shadow: 0 0 0 4px rgba(112, 255, 174, 0.18); }
|
||||
.timeline-strip-panel { position: relative; }
|
||||
|
||||
/* Ledger and queries */
|
||||
.inline-search { width: min(240px, 38vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 11px; background: rgba(0, 0, 0, 0.18); color: var(--color-ink-muted); }
|
||||
.inline-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
|
||||
.ledger-head, .company-ledger summary { display: grid; grid-template-columns: minmax(210px, 1.4fr) repeat(2, minmax(100px, 0.75fr)) minmax(150px, 0.9fr) 86px 24px; align-items: center; gap: 10px; }
|
||||
.ledger-head { min-height: 42px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 10px; }
|
||||
.company-ledger { border-top: 1px solid var(--color-line); }
|
||||
.company-ledger:first-child { border-top: 0; }
|
||||
.company-ledger summary { min-height: 72px; padding: 10px 16px; list-style: none; cursor: pointer; transition: background var(--duration-fast); }
|
||||
.company-ledger summary::-webkit-details-marker { display: none; }
|
||||
.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[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 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 small { color: var(--color-ink-muted); }
|
||||
.amount { font: 600 13px var(--font-data); }
|
||||
.amount.debit { color: var(--color-positive); }
|
||||
.amount.credit { color: var(--color-warning); }
|
||||
.ledger-breakdown { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--color-line); background: rgba(0, 0, 0, 0.13); }
|
||||
.ledger-breakdown section { padding: 14px 16px; }
|
||||
.ledger-breakdown section + section { border-left: 1px solid var(--color-line); }
|
||||
.ledger-breakdown header { display: flex; justify-content: space-between; margin-bottom: 8px; color: var(--color-ink-soft); }
|
||||
.subject-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; }
|
||||
.subject-row:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
.subject-row span { display: flex; flex-direction: column; }
|
||||
.subject-row small { color: var(--color-ink-muted); }
|
||||
.query-band, .filter-grid { padding: 17px; margin-bottom: 18px; }
|
||||
.pair-query-form { display: grid; grid-template-columns: minmax(150px, 1fr) 38px minmax(150px, 1fr) minmax(150px, 0.8fr) auto; align-items: end; gap: 11px; }
|
||||
.pair-report { overflow: hidden; }
|
||||
.pair-report-heading { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
|
||||
.pair-report-heading p { color: var(--color-ink-muted); }
|
||||
.pair-result { text-align: right; }
|
||||
.pair-result strong { display: block; color: var(--color-primary); font: 700 22px var(--font-data); }
|
||||
.pair-balance-line { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
|
||||
.pair-balance-line div { min-height: 76px; display: flex; flex-direction: column; justify-content: center; padding: 12px 16px; border-left: 1px solid var(--color-line); }
|
||||
.pair-balance-line div:first-child { border-left: 0; }
|
||||
.pair-balance-line span, .pair-balance-line small { color: var(--color-ink-muted); }
|
||||
.pair-balance-line strong { margin-top: 3px; font: 600 15px var(--font-data); }
|
||||
.pair-final strong { color: var(--color-primary); font-size: 19px; }
|
||||
.subject-strip { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
|
||||
.subject-strip button { min-height: 62px; display: flex; flex-direction: column; justify-content: center; padding: 9px 14px; border: 0; border-left: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.018); text-align: left; cursor: pointer; }
|
||||
.subject-strip button:first-child { border-left: 0; }
|
||||
.subject-strip button.is-active { background: var(--color-primary-wash); color: var(--color-primary); box-shadow: inset 0 -2px var(--color-primary); }
|
||||
.subject-strip span { color: var(--color-ink-muted); font-size: 10px; }
|
||||
|
||||
/* Filters and tables */
|
||||
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px; margin-bottom: 18px; }
|
||||
.segmented { display: flex; gap: 4px; padding: 4px; overflow-x: auto; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
|
||||
.segmented button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); white-space: nowrap; cursor: pointer; }
|
||||
.segmented button.is-active { background: rgba(255, 255, 255, 0.09); color: var(--color-ink); }
|
||||
.flow-filters { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)) minmax(200px, 1.4fr) auto; align-items: end; gap: 11px; }
|
||||
.flow-filters .grow { min-width: 0; }
|
||||
.table-summary { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 11px; }
|
||||
.table-scroll { max-width: 100%; overflow: auto; }
|
||||
.data-table { width: 100%; min-width: 860px; border-collapse: collapse; }
|
||||
.data-table th { height: 42px; padding: 8px 13px; border-bottom: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.025); color: var(--color-ink-muted); font-size: 10px; font-weight: 500; text-align: left; white-space: nowrap; }
|
||||
.data-table td { height: 55px; padding: 9px 13px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); vertical-align: middle; }
|
||||
.data-table tbody tr { transition: background var(--duration-fast); }
|
||||
.data-table tbody tr:hover { background: rgba(55, 235, 137, 0.035); }
|
||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.data-table td strong, .data-table td small { display: block; }
|
||||
.data-table td strong { color: var(--color-ink); }
|
||||
.data-table td small { color: var(--color-ink-muted); }
|
||||
.data-table .number { color: var(--color-ink); text-align: right; font-family: var(--font-data); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Settings, reminders and company work */
|
||||
.settings-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; }
|
||||
.form-body { display: grid; gap: 15px; padding: 17px; }
|
||||
.panel form footer, form.panel footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 17px; border-top: 1px solid var(--color-line); }
|
||||
.closing-panel { grid-column: 1 / -1; }
|
||||
.closing-checks { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.closing-checks article { min-height: 75px; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-left: 1px solid var(--color-line); }
|
||||
.closing-checks article:first-child { border-left: 0; }
|
||||
.closing-checks svg { color: var(--color-positive); }
|
||||
.closing-checks .is-blocked svg { color: var(--color-danger); }
|
||||
.closing-checks span { display: flex; flex-direction: column; }
|
||||
.closing-checks small { color: var(--color-ink-muted); }
|
||||
.closing-footer { min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
|
||||
.closing-footer > span { margin-right: auto; color: var(--color-ink-muted); }
|
||||
.reminder-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; }
|
||||
.notification-list article { min-height: 76px; display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--color-line); }
|
||||
.notification-list article:first-child { border-top: 0; }
|
||||
.notification-list article.is-unread { background: rgba(102, 168, 255, 0.045); }
|
||||
.notification-list article > span:nth-child(2) { display: flex; flex-direction: column; }
|
||||
.notification-list small, .notification-list p { color: var(--color-ink-muted); }
|
||||
.notification-list p { margin-top: 4px; }
|
||||
.notification-icon { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 12px; background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
|
||||
.notification-icon.danger { background: var(--color-danger-wash); color: var(--color-danger); }
|
||||
.notification-icon.warning { background: var(--color-warning-wash); color: var(--color-warning); }
|
||||
.notification-icon.success { background: var(--color-positive-wash); color: var(--color-positive); }
|
||||
.company-alert { min-height: 78px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 18px; padding: 14px 16px; border: 1px solid rgba(255, 98, 109, 0.22); border-radius: var(--radius-lg); background: var(--color-danger-wash); box-shadow: var(--shadow-low); }
|
||||
.company-alert > svg { color: var(--color-danger); }
|
||||
.company-alert p { color: #c18e92; }
|
||||
.company-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); gap: 18px; }
|
||||
.cashier-tasks article { min-height: 92px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 16px; border-top: 1px solid var(--color-line); }
|
||||
.cashier-tasks article:first-child { border-top: 0; }
|
||||
.cashier-tasks p, .cashier-tasks small { color: var(--color-ink-muted); }
|
||||
.coverage-list article { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
|
||||
.coverage-list article:first-child { border-top: 0; }
|
||||
.coverage-list article > span { display: flex; flex-direction: column; }
|
||||
.coverage-list small { color: var(--color-ink-muted); }
|
||||
.work-progress { display: grid; grid-template-columns: minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr); align-items: center; margin-top: 18px; padding: 18px; }
|
||||
.work-progress div { display: flex; align-items: center; gap: 10px; }
|
||||
.work-progress div > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 11px; background: var(--color-positive); color: var(--color-primary-dark); font-weight: 800; }
|
||||
.work-progress p { display: flex; flex-direction: column; }
|
||||
.work-progress small { color: var(--color-ink-muted); }
|
||||
.work-progress i { height: 1px; background: var(--color-line-strong); }
|
||||
.work-progress .pending > span { background: rgba(255, 255, 255, 0.065); color: var(--color-ink-muted); }
|
||||
.reconcile-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; overflow: hidden; }
|
||||
.reconcile-summary div { min-height: 86px; display: flex; flex-direction: column; justify-content: center; padding: 14px 18px; border-left: 1px solid var(--color-line); }
|
||||
.reconcile-summary div:first-child { border-left: 0; }
|
||||
.reconcile-summary span, .reconcile-summary small { color: var(--color-ink-muted); }
|
||||
.reconcile-summary strong { font: 600 19px var(--font-data); }
|
||||
.review-list > article { padding: 16px; border-top: 1px solid var(--color-line); }
|
||||
.review-list > article:first-child { border-top: 0; }
|
||||
.review-main { display: grid; grid-template-columns: 38px 1fr; gap: 10px; }
|
||||
.review-main p { color: var(--color-ink-muted); }
|
||||
.candidate { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); }
|
||||
.candidate-options { display: grid; gap: 8px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
|
||||
.candidate-options legend { padding: 0 5px; color: var(--color-ink-muted); }
|
||||
.candidate-options label { display: flex; align-items: center; gap: 9px; padding: 9px; border-radius: 11px; background: rgba(255, 255, 255, 0.035); }
|
||||
.candidate-options input { accent-color: var(--color-primary); }
|
||||
.candidate-options label span { display: flex; flex-direction: column; }
|
||||
.candidate-options small { color: var(--color-ink-muted); }
|
||||
.review-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 10px; }
|
||||
.review-history { padding: 14px 16px; border-top: 1px solid var(--color-line); background: var(--color-positive-wash); }
|
||||
.evidence-block { padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.16); }
|
||||
.evidence-block strong, .evidence-block small { display: block; }
|
||||
.evidence-block small { color: var(--color-ink-muted); }
|
||||
.account-directory { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 16px; }
|
||||
.account-directory article { overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); }
|
||||
.account-directory header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid var(--color-line); }
|
||||
.account-directory header div { display: flex; flex-direction: column; }
|
||||
.account-directory header small { color: var(--color-ink-muted); }
|
||||
.bank-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; }
|
||||
.account-directory dl { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.account-directory dl div { padding: 12px; border-left: 1px solid var(--color-line); }
|
||||
.account-directory dl div:first-child { border-left: 0; }
|
||||
.account-directory dt { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.account-directory dd { margin-top: 3px; font-size: 11px; }
|
||||
.manual-layout { display: grid; grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); align-items: start; gap: 18px; }
|
||||
.manual-records-table { min-width: 900px; }
|
||||
|
||||
/* 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::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 header p { color: var(--color-ink-muted); }
|
||||
.dialog-body { display: grid; gap: 15px; padding: 18px; }
|
||||
.dialog > form > footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 18px; border-top: 1px solid var(--color-line); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-callout { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid rgba(102, 168, 255, 0.22); border-radius: 12px; background: var(--color-info-wash); color: #9ac6ff; }
|
||||
.dropzone { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 1px dashed rgba(255, 255, 255, 0.24); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); color: var(--color-ink-soft); text-align: center; transition: border-color var(--duration-fast), background var(--duration-fast); }
|
||||
.dropzone:hover, .dropzone.is-dragging { border-color: var(--color-primary); background: var(--color-primary-wash); }
|
||||
.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.dropzone > svg { width: 30px; height: 30px; color: var(--color-primary); }
|
||||
.file-preview { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
|
||||
.file-preview > span:nth-child(2) { display: flex; flex-direction: column; min-width: 0; }
|
||||
.file-preview strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-preview small { color: var(--color-ink-muted); }
|
||||
.file-type { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); }
|
||||
.parse-result { display: grid; grid-template-columns: 38px 1fr; align-items: center; gap: 10px; padding: 11px; border: 1px solid rgba(55, 235, 137, 0.22); border-radius: var(--radius-md); background: var(--color-positive-wash); }
|
||||
.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 p { color: #e7bd78; }
|
||||
.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 strong, .toast small { display: block; }
|
||||
.toast small { color: var(--color-ink-muted); }
|
||||
|
||||
/* Login */
|
||||
.entry-page { overflow-x: hidden; }
|
||||
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
|
||||
.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::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; }
|
||||
.entry-brand > span:last-child { display: flex; flex-direction: column; }
|
||||
.entry-brand small { color: var(--color-ink-muted); }
|
||||
.entry-statement { max-width: 680px; margin: 80px 0; }
|
||||
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; }
|
||||
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); }
|
||||
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; }
|
||||
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
|
||||
.entry-facts dt { color: var(--color-ink-muted); }
|
||||
.entry-facts dd { margin-top: 4px; color: var(--color-primary); }
|
||||
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); }
|
||||
.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-form header h2 { font-size: 25px; }
|
||||
.entry-form header p, .entry-note { color: var(--color-ink-muted); }
|
||||
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
.role-switch label { cursor: pointer; }
|
||||
.role-switch input { position: absolute; opacity: 0; }
|
||||
.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 svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); }
|
||||
.role-switch small { color: var(--color-ink-muted); }
|
||||
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); }
|
||||
.role-switch input:checked + span svg { color: var(--color-primary); }
|
||||
.entry-note { font-size: 10px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell { grid-template-columns: 88px minmax(0, 1fr); }
|
||||
.sidebar { width: 70px; padding-inline: 9px; }
|
||||
.workspace { grid-column: 2; }
|
||||
.brand { justify-content: center; padding-inline: 0; }
|
||||
.brand-copy, .nav-item span, .nav-item b, .user-block > span:last-child, .company-context div { display: none; }
|
||||
.nav-item { justify-content: center; padding: 0; }
|
||||
.user-block, .company-context { justify-content: center; padding-inline: 0; }
|
||||
.metric-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.period-ribbon { grid-template-columns: repeat(3, 1fr); }
|
||||
.period-ribbon > .period-warning { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.admin-dashboard-grid, .company-dashboard-grid { grid-template-columns: 1fr; }
|
||||
.flow-filters { grid-template-columns: repeat(3, 1fr); }
|
||||
.flow-filters .button { min-height: 40px; }
|
||||
.manual-layout { grid-template-columns: 1fr; }
|
||||
.account-directory { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
main { padding-inline: 20px; }
|
||||
.search-box { display: none; }
|
||||
.settings-layout, .reminder-layout { grid-template-columns: 1fr; }
|
||||
.pair-query-form { grid-template-columns: 1fr 40px 1fr; }
|
||||
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
|
||||
.pair-query-form .button { grid-column: 3; }
|
||||
.ledger-head { display: none; }
|
||||
.company-ledger 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; }
|
||||
.ledger-breakdown { grid-template-columns: 1fr; }
|
||||
.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 .pair-final { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.subject-strip { grid-template-columns: repeat(3, 1fr); }
|
||||
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
|
||||
.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) {
|
||||
.app-shell { display: block; }
|
||||
.sidebar { inset: 10px auto 10px 10px; width: 242px; transform: translateX(calc(-100% - 20px)); transition: transform var(--duration-standard) var(--ease-out); }
|
||||
.sidebar.is-open { transform: none; }
|
||||
.sidebar.is-open .brand-copy, .sidebar.is-open .nav-item span, .sidebar.is-open .nav-item b, .sidebar.is-open .user-block > span:last-child, .sidebar.is-open .company-context div { display: flex; }
|
||||
.sidebar.is-open .nav-item { justify-content: flex-start; padding: 0 12px; }
|
||||
.sidebar.is-open .company-context { justify-content: flex-start; padding-inline: 10px; }
|
||||
.workspace { display: block; }
|
||||
.topbar { height: 64px; padding: 0 13px; gap: 9px; }
|
||||
.menu-button { display: inline-grid; }
|
||||
.workspace-name { min-width: 0; }
|
||||
.workspace-name span { display: none; }
|
||||
.demo-badge { padding: 2px 6px; }
|
||||
.period-button { display: none; }
|
||||
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
|
||||
main { padding: 22px 13px 42px; }
|
||||
.page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; }
|
||||
.page-heading h1 { font-size: 27px; }
|
||||
.page-heading > .button { align-self: stretch; }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.metric-card { min-height: 128px; grid-template-columns: minmax(0, 1fr) 38px; padding: 15px; }
|
||||
.metric-icon { width: 38px; height: 38px; border-radius: 12px; }
|
||||
.metric-value { font-size: 25px; }
|
||||
.period-ribbon { grid-template-columns: 1fr 1fr; }
|
||||
.period-ribbon > div { min-height: 64px; }
|
||||
.period-ribbon > div:nth-child(3) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.period-warning { grid-column: 1 / -1; }
|
||||
.panel-heading { min-height: 60px; padding: 11px 13px; }
|
||||
.inline-search { display: none; }
|
||||
.task-list button { grid-template-columns: 30px minmax(0, 1fr) 18px; padding-inline: 12px; }
|
||||
.task-list button > b { display: none; }
|
||||
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
|
||||
.quick-result { margin-inline: 12px; }
|
||||
.company-ledger 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-name { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.company-name i { width: 28px; height: 28px; }
|
||||
.filter-bar { align-items: stretch; flex-direction: column; }
|
||||
.segmented { width: 100%; }
|
||||
.flow-filters { grid-template-columns: 1fr 1fr; }
|
||||
.flow-filters .grow, .flow-filters .button { grid-column: 1 / -1; }
|
||||
.pair-query-form { grid-template-columns: 1fr 38px 1fr; }
|
||||
.pair-query-form .field:nth-of-type(3), .pair-query-form .button { grid-column: 1 / -1; }
|
||||
.pair-report-heading { align-items: flex-start; flex-direction: column; }
|
||||
.pair-balance-line { grid-template-columns: 1fr 1fr; }
|
||||
.pair-balance-line .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
|
||||
.subject-strip { grid-template-columns: 1fr 1fr; }
|
||||
.company-alert { grid-template-columns: 28px 1fr; }
|
||||
.company-alert .button { grid-column: 1 / -1; }
|
||||
.cashier-tasks article { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.cashier-tasks .button { grid-column: 2; justify-self: start; }
|
||||
.work-progress { grid-template-columns: 1fr; }
|
||||
.work-progress i { display: none; }
|
||||
.reconcile-summary { grid-template-columns: 1fr; }
|
||||
.reconcile-summary div { min-height: 70px; border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.reconcile-summary div:first-child { border-top: 0; }
|
||||
.candidate, .candidate-options { align-items: stretch; flex-direction: column; margin-left: 0; }
|
||||
.account-directory { grid-template-columns: 1fr; }
|
||||
.account-directory dl { grid-template-columns: 1fr 1fr; }
|
||||
.closing-checks { grid-template-columns: 1fr; }
|
||||
.closing-checks article { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.closing-footer { align-items: stretch; flex-direction: column; }
|
||||
.notification-list article { grid-template-columns: 34px minmax(0, 1fr); }
|
||||
.notification-list article > em { grid-column: 2; justify-self: start; }
|
||||
.table-summary { align-items: flex-start; flex-direction: column; justify-content: center; padding-block: 9px; }
|
||||
.dialog { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; }
|
||||
.dialog > form { min-height: 100%; display: flex; flex-direction: column; }
|
||||
.dialog-body { flex: 1; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.toast-region { right: 12px; bottom: 12px; left: 12px; }
|
||||
.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) {
|
||||
.metric-card { min-height: 150px; padding: 13px; }
|
||||
.metric-copy { min-height: 52px; }
|
||||
.metric-label { font-size: 11px; }
|
||||
.metric-value { font-size: 23px; }
|
||||
.role-switch { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@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-legend { padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stack-deck { height: auto; padding: 0 4px 8px; }
|
||||
.deck-stage { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.deck-stage .deck-card { position: relative; transform: none !important; opacity: 1 !important; width: auto; }
|
||||
.deck-card .deck-pos { right: 10px; }
|
||||
}
|
||||
|
||||
@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-rows { gap: 6px; }
|
||||
.timeline-row { grid-template-columns: 80px minmax(0, 1fr); }
|
||||
.timeline-axis span { font-size: 9px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
Reference in New Issue
Block a user