Compare commits

...
26 Commits
Author SHA1 Message Date
b27016d71d HEL-177: 公司端按方案 A 实现转账往来四层页面
工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 13:45:19 +00:00
总工 96309eb2cc HEL-177: merge HEL-174 confirm-status green into transfers page base 2026-08-27 13:17:28 +00:00
40f7a91a0f HEL-176: 公司端转账往来明细筛选与导出
新增 events 组合筛选 + keyset 分页,以及仅导出已确认明细的 CSV(含审计);
与 B-44 ledger /events 按查询参数分发,不改库表与确认语义。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 13:14:02 +00:00
cad12b3d28 HEL-175: 公司端转账往来汇总接口
新增 GET /api/company/intercompany/summary:会话 company_id 强制隔离,
已确认走 eligible_intercompany_events,待确认单列,Decimal 字符串金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:58:52 +00:00
7e7354a809 HEL-174: 公司端往来确认完成态改为 success 绿
确认全部完成后,账期流程第 3 步从 doing(warn 黄)切到 done(success 绿),
本月待办 pill 与阻断提示同步用 success;待确认仍保留 warn。补前端契约与 DOM 色值测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:56:20 +00:00
总工 642604f688 HEL-171 部署合并:公司端工作台待办同步(bf5754e) + 登录限流运维开关(551909a) 2026-08-27 12:28:04 +00:00
bf5754ee09 HEL-168: 公司端工作台待办按权威单边集合同步
确认成功后重拉 /api/company/workspace,去确认数字与本月待办共用同一口径;
新增公司端确认接口与 3→2→1→0 集成测试,失败/重复确认不误减。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:10:38 +00:00
总工andmultica-agent 551909ad0c HEL-166: 增加 APP_LOGIN_RATE_LIMIT_DISABLED 运维开关(默认保持限流)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 11:48:00 +00:00
总工andmultica-agent eefdc92ab6 HEL-165 部署:admin 端 app.js 缓存版本升至 v=9
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 09:59:36 +00:00
fdc5616115 HEL-154: 结账日联动、公司越权 POST 测试与 docs 收尾
流水页范围文案改为读取 settings 的 closing_day/start_date,去掉写死顺延日;
补公司端对设置/提醒/审核写接口的 403 覆盖;从 main 恢复手册 docs 并保留 006。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 09:44:46 +00:00
c4f7573653 HEL-162: 修复宽屏主从列表无法内部滚动
为 .dash-master-split 增加 grid-template-rows: minmax(0,1fr),使 max-height 真正约束行高,左侧 7 家公司可滚可选;顺手删除无调用的 statusPill。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 16:14:37 +00:00
5b3ebad757 HEL-162: 按方案A重做首页一体卡片主从区域
合并公司往来合计/明细为一张共享头卡片;左侧改列表行选中态,右侧加汇总条与克制空态;取消固定大高度,模块内滚动。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 15:40:07 +00:00
总工andmultica-agent b42b6481f5 HEL-158 部署:app.js 缓存版本升至 v=6
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:59:20 +00:00
e04f47b9d2 HEL-157: 审核中心接入三类待办完整队列
将待复核账户、待审手工单、匹配异常统一渲染到审核中心列表,
处置走既有 decision API,并补三类种子一致性与处置减一集成测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:45:03 +00:00
7fb97119ba HEL-157: 删除首页图表、校正主从模块并统一待审核口径
移除柱状图/折线图死代码;主从改为 480px+明细宽栏;待审核首页卡、侧栏角标与后端口径同源。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:09:01 +00:00
815b68c1fc HEL-156 返工:起算日读 settings、动态状态列头、明细默认收起
首页 from 取自 /api/admin/settings 的 start_date 并省略 cutoff;
状态列头按 period_month 显示;renderDashDetail 二级默认全部收起。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 03:37:44 +00:00
施工员andmultica-agent 609ebffdc9 HEL-155: 合计列表状态列头与首页 7 月账期对齐
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 03:32:47 +00:00
77fc625ded HEL-155: 按第三轮效果图改造管理端首页
替换借贷轧差为待审核统计卡;公司往来合计/明细合成主从模块(折叠明细);
新增柱状图与折线图位;接入 /api/admin/dashboard 真实归集与审核队列数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 03:32:47 +00:00
leeferandmultica-agent ece3e53472 HEL-144 返工A:四件事(改名/登录端标识/结账设置真保存/提醒回改)+ 迁移升为 7 + 移除废弃 B-44 前端样式测试
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 18:41:01 +08:00
leeferandmultica-agent 5816e8aa71 baseline: 测试环境现行树(b44 后端 + main web)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 18:35:41 +08:00
腾讯WorkBuddyandCursor 4add17f2b8 B-44: 返修——768px 超大期末金额与未决状态避让
在 376–900px 五列目录把方向 chip 与期末金额分行,并加 Chromium 盒模型回归,避免 15 位金额盖住未决状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 03:22:13 +08:00
腾讯WorkBuddyandCursor 95ad9c939a B-44: 返修——375px 超大金额降级与审核弹窗必展字段
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:54:32 +08:00
腾讯WorkBuddyandCursor 1fe073066e B-44: 返修——余额目录视觉、八列事件表、抽屉键盘与审核三项决定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 00:37:27 +08:00
腾讯WorkBuddy f16e8aad34 B-44: 返修——多币种对方汇总、镜像科目筛选、冲销独立生效日、创建来源撤销边界与并发幂等 2026-08-19 13:28:28 +08:00
腾讯WorkBuddy d59576739f B-44: fix empty-state retry selector in balance directory 2026-08-19 12:00:59 +08:00
腾讯WorkBuddy 85293b79df B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current
  projections, source claims, subject suggestions, eligible_position_events
- ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen,
  append-only revision chain and rebuildable current projection
- subjects.py: fixed subject mirror, draft suggestion dictionary, explicit
  administrator subject confirmation with expected_revision + idempotency
- manual_records.py: submit, approve new/link, return/exception/reverse,
  candidate hints, idempotent replay and concurrency-safe claims
- positions.py: Decimal aggregation, both-perspective conservation asserts,
  cutoff window, unresolved gross buckets, keyset pagination, evidence
  visibility (visible/masked/missing)
- server.py: admin + company intercompany APIs with tenant isolation (404 on
  cross-tenant reads, 403 on company writes) and auto reconcile wiring
- admin/company portals: balance directory, pair drill-down drawer, evidence
  drawer, subject/manual audit queue, company balance summary
- tests: ledger events, subjects, manual records, positions, HTTP API and
  migration persistence (233 total, all green)
2026-08-19 11:58:25 +08:00
46 changed files with 17446 additions and 1618 deletions
+7
View File
@@ -19,3 +19,10 @@ nul
.agent_context/ .agent_context/
.kimi/ .kimi/
vendor_pkgs/
node_modules/
package-lock.json
# local vendor for agent test env (not shipped)
.vendor/
vendor_wheels/
+2
View File
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次, 公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。 首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。 - 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
(默认未设置 = 保持限流,正式环境不得开启该变量)。
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖): 访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
+21
View File
@@ -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 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
+3
View File
@@ -26,6 +26,9 @@
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429, - 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
且在窗口内不再记录新尝试,行为确定、可测试。 且在窗口内不再记录新尝试,行为确定、可测试。
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。 - 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
「5 次失败锁 10 分钟」的生产安全策略不变。
## 角色与公司绑定 ## 角色与公司绑定
@@ -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`.
+29
View File
@@ -0,0 +1,29 @@
# 任务清单
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
## 正在做
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
## 已做完
- 六类银行流水模板识别与解析。
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
- 动态公司、用户、银行账户、别名与账户审核流程。
- 导入接口加固、逐工作表确认、失败诊断和导出。
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
- 登录页视觉融合改版。
## 还没安排
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
- 月结、重开、调整/冲销审批与审计报告。
- 服务端流水查询、筛选、分页和可追溯导出。
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`
+24
View File
@@ -0,0 +1,24 @@
# 最新进度
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
## 已经真实完成
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
## 仍未完成或不能当成已完成
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
## 最近验证
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
+26
View File
@@ -0,0 +1,26 @@
# 项目需求
## 这个系统做什么
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
系统有两个独立入口:
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
## 必须做到的业务规则
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
## 当前技术边界
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls``.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""HEL-174: 360 / 820 / 1440 截图——待确认黄 vs 已完成绿。"""
from __future__ import annotations
import re
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = Path(__file__).resolve().parent.parent / "hel174-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [
(1440, 900, "1440"),
(820, 900, "820"),
(360, 800, "360"),
]
def extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise RuntimeError(name)
def main() -> None:
app_js = (WEB / "app.js").read_text(encoding="utf-8")
inject = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
extract_fn(app_js, "formatWorkspaceAmount"),
extract_fn(app_js, "applyCompanyWorkspace"),
]
)
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
html = f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="{base}/design-system.css"/>
<title>HEL-174 往来确认状态色</title>
<style>
body {{ margin: 0; background: var(--bg); font-family: var(--font-body); color: var(--fg); }}
.shot-wrap {{ padding: 16px; max-width: 1100px; margin: 0 auto; }}
.shot-label {{ font-size: 13px; color: var(--muted); margin: 0 0 10px; }}
</style>
</head>
<body data-portal="company">
<div class="shot-wrap">
<p class="shot-label" id="shotLabel">状态预览</p>
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<div class="card" id="workspaceTodos" style="margin-top:12px;">
<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" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<a class="flow-step part" 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>
</a>
<a class="flow-step done" 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>
</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>
</div>
</div>
<div class="notice warn" id="blocking-notice" style="margin-top:12px;">
<div>
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
</div>
<div style="margin-top:12px;">
<span class="pill pill-warn">待确认</span>
<span class="pill pill-success" style="margin-left:8px;">已确认</span>
</div>
<span class="tab-count" id="count-match" hidden>0</span>
<nav class="side-nav" hidden><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</div>
</body></html>"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page()
for width, height, tag in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.set_content(html, wait_until="domcontentloaded")
page.add_script_tag(content=inject)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '待确认(应黄)';
applyCompanyWorkspace({
pending_unilateral: 3, pending_total: 3,
unilateral_events: [
{event_id:1, amount:'100000.00', currency:'CNY',
counterparty_company_name:'金牛贸易', effective_at:'2026-07-03'},
{event_id:2, amount:'200000.00', currency:'CNY',
counterparty_company_name:'金牛物流', effective_at:'2026-07-11'},
{event_id:3, amount:'300000.00', currency:'CNY',
counterparty_company_name:'金牛置业', effective_at:'2026-07-24'}
]
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-pending-{tag}.png"), full_page=True)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '已完成(应绿)';
applyCompanyWorkspace({
pending_unilateral: 0, pending_total: 0, unilateral_events: []
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-done-{tag}.png"), full_page=True)
colors = page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: getComputedStyle(state).color,
pillClass: pill.className,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
}"""
)
assert "done" in colors["stepClass"], colors
assert "pill-success" in colors["pillClass"], colors
assert colors["stateText"] == "已完成", colors
assert not colors["overflowX"], colors
print(tag, colors)
browser.close()
httpd.shutdown()
print("shots:", sorted(p.name for p in OUT.glob("*.png")))
if __name__ == "__main__":
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""HEL-177: 转账往来页 360/820/1440 冒烟截图(mock 接口,不依赖登录库)。"""
from __future__ import annotations
import json
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = ROOT / "hel177-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [(1440, 900, "1440"), (820, 900, "820"), (360, 800, "360")]
SUMMARY = {
"status": "ok",
"own_company": {"id": 3, "name": "河南金牛煤业有限公司"},
"window": {
"start": "2026-01-01",
"end": "2026-08-20",
"has_opening": False,
"opening": None,
"ending": None,
},
"confirmed": {
"outflow_total": "123860000.00",
"outflow_count": 48,
"inflow_total": "184205000.00",
"inflow_count": 77,
"net_change": "-60345000.00",
"net_direction": "payable",
},
"pending": {"count": 3, "amount_total": "3200000.00"},
"counterparties": [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "52000000.00",
"confirmed_inflow": "86000000.00",
"net": "-34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "10000000.00",
"confirmed_inflow": "12605000.00",
"net": "-2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "61860000.00",
"confirmed_inflow": "85600000.00",
"net": "-23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
],
}
# 修正 mock:按 HEL-169 净变动=转出-转入,正数应收
SUMMARY["confirmed"] = {
"outflow_total": "184205000.00",
"outflow_count": 80,
"inflow_total": "123860000.00",
"inflow_count": 48,
"net_change": "60345000.00",
"net_direction": "receivable",
}
SUMMARY["counterparties"] = [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "86000000.00",
"confirmed_inflow": "52000000.00",
"net": "34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "12605000.00",
"confirmed_inflow": "10000000.00",
"net": "2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "85600000.00",
"confirmed_inflow": "61860000.00",
"net": "23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
]
EVENTS = {
"status": "ok",
"window": {"start": "2026-01-01", "end": "2026-08-20"},
"events": [
{
"event_id": 101,
"direction": "in",
"state": "confirmed",
"pairing": "paired",
"locked": False,
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"summary": "周转资金调拨",
"status": "matched",
},
{
"event_id": 102,
"direction": "out",
"state": "pending",
"pairing": "unilateral",
"locked": False,
"amount": "1500000.00",
"currency": "CNY",
"effective_at": "2026-08-12",
"summary": "工程款结算(待确认)",
"status": "unresolved",
},
],
"next_cursor": None,
"has_more": False,
}
DETAIL = {
"status": "ok",
"event": {
"event_id": 101,
"classification": "intercompany",
"pairing": "paired",
"status": "matched",
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"mode": "auto",
"reason": "双边匹配",
"counterparty": {
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"account_number_masked": "****8821",
},
"observations": [
{
"source_row_id": 1,
"role": "payee",
"source_row": 148,
"sheet_name": "流水",
"transaction_at": "2026-08-18 10:22:00",
"income": "12000000.00",
"expense": None,
"import_batch_id": 19,
"original_filename": "工行3305_202608.xls",
"own_account_masked": "****3305",
"counterparty_account_masked": "****8821",
"counterparty_name": "河南金牛置业有限公司",
"summary": "周转资金调拨",
"reference": "JS20260818-00317",
}
],
},
}
def main() -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
mock_js = f"""
window.__HEL177_MOCK__ = true;
const SUMMARY = {json.dumps(SUMMARY, ensure_ascii=False)};
const EVENTS = {json.dumps(EVENTS, ensure_ascii=False)};
const DETAIL = {json.dumps(DETAIL, ensure_ascii=False)};
const _fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {{
const url = String(input);
if (url.includes('/api/me') || url.includes('/api/session')) {{
return new Response(JSON.stringify({{role:'company', company_id:3, username:'牛女士', company_name:'河南金牛煤业有限公司'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/workspace')) {{
return new Response(JSON.stringify({{status:'ok', pending_unilateral:0, pending_total:0, unilateral_events:[]}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/summary')) {{
return new Response(JSON.stringify(SUMMARY), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/events')) {{
return new Response(JSON.stringify(EVENTS), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/transfer-events/')) {{
return new Response(JSON.stringify(DETAIL), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/')) {{
return new Response(JSON.stringify({{status:'ok'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
return _fetch(input, init);
}};
"""
console_errors: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("pageerror", lambda exc: console_errors.append(f"pageerror:{exc}"))
page.on("console", lambda msg: console_errors.append(f"console:{msg.type}:{msg.text}") if msg.type == "error" else None)
for width, height, label in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.add_init_script(mock_js)
# bypass auth guard
page.add_init_script(
"""
const orig = window.fetch;
// initAuthGuard 读 /api/me;已在 mock 中处理
"""
)
page.goto(f"{base}/company.html", wait_until="domcontentloaded")
page.evaluate(
"""() => {
// 若鉴权把页面踢走,强制停留
if (!document.body || document.body.dataset.portal !== 'company') return;
}"""
)
# 等 summary 渲染
page.wait_for_timeout(600)
# 切到转账往来
page.evaluate("() => { if (typeof showView === 'function') showView('transfers'); }")
page.wait_for_selector("#transfersData:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow, f"{label} overview overflow"
# 文案:期间净变动,无期末余额误用
net_title = page.inner_text("#tfStatNetTitle")
assert "期间净变动" in net_title, net_title
page.screenshot(path=str(OUT / f"overview-{label}.png"), full_page=True)
# 下钻明细
page.click("#transfersCpBody tr.clickable")
page.wait_for_selector("#transfersDetailLayer:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow2 = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow2, f"{label} detail overflow"
page.screenshot(path=str(OUT / f"detail-{label}.png"), full_page=True)
# 打开抽屉
page.click("[data-transfer-evidence]")
page.wait_for_selector("#transferEvidenceDrawer.is-open", timeout=5000)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"drawer-{label}.png"), full_page=True)
page.click("[data-close-transfer-evidence]")
browser.close()
hard = [e for e in console_errors if "Failed to load resource" not in e and "favicon" not in e]
print("shots:", list(OUT.glob("*.png")))
print("console_errors:", hard)
if hard:
raise SystemExit(1)
print("OK")
if __name__ == "__main__":
main()
+1377 -11
View File
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import hmac import hmac
import os
import secrets import secrets
import sqlite3 import sqlite3
import string import string
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
INITIAL_PASSWORD_LENGTH = 12 INITIAL_PASSWORD_LENGTH = 12
def _env_flag(value: str | None) -> bool:
"""Parse a yes/no style environment flag; blank/absent means False."""
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
# Ops switch for internal test environments: APP_LOGIN_RATE_LIMIT_DISABLED=1
# turns off the login-failure lockout entirely. The default (unset) keeps the
# production policy — 5 failures within 10 minutes lock the (账号, IP) pair.
RATE_LIMIT_DISABLED = _env_flag(os.environ.get("APP_LOGIN_RATE_LIMIT_DISABLED"))
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``.""" """Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
salt = secrets.token_bytes(16) salt = secrets.token_bytes(16)
@@ -146,18 +158,19 @@ def authenticate(
Every non-rate-limited attempt is recorded in ``login_attempts`` and Every non-rate-limited attempt is recorded in ``login_attempts`` and
``audit_log``; the password itself is never stored anywhere. ``audit_log``; the password itself is never stored anywhere.
""" """
window_start = ( if not RATE_LIMIT_DISABLED:
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES) window_start = (
).isoformat() datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
failures = connection.execute( ).isoformat()
""" failures = connection.execute(
SELECT COUNT(*) AS n FROM login_attempts """
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ? SELECT COUNT(*) AS n FROM login_attempts
""", WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
(username, ip, window_start), """,
).fetchone() (username, ip, window_start),
if failures["n"] >= RATE_LIMIT_MAX_FAILURES: ).fetchone()
return None, "rate_limited" if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
return None, "rate_limited"
user = connection.execute( user = connection.execute(
"SELECT * FROM users WHERE username = ?", (username,) "SELECT * FROM users WHERE username = ?", (username,)
+740
View File
@@ -0,0 +1,740 @@
"""Company-portal intercompany transfer summary / detail / export (HEL-175/176).
Confirmed totals reuse the authoritative ``eligible_intercompany_events``
view (intercompany + paired or locked). Pending counts/amounts are listed
separately and never enter outflow, inflow or net. All money math uses
``Decimal`` on stored TEXT amounts — never float or SQLite SUM.
Detail listing (HEL-176) applies counterparty / date / direction / state
filters inside SQL before LIMIT, and uses keyset pagination on
(effective_at, event_id) descending. Export only ships confirmed rows.
"""
from __future__ import annotations
import base64
import csv
import io
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import re
import sqlite3
from . import matching, settings
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ZERO = Decimal("0.00")
_DEFAULT_PAGE = 50
_MAX_PAGE = 200
_MAX_EXPORT_ROWS = 20000
class TransferSummaryInputError(ValueError):
"""Invalid query parameters (mapped to HTTP 400)."""
def today_shanghai() -> str:
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
def _validate_date(value: object, label: str) -> str:
text = str(value or "").strip()
if not text or not _DATE_RE.fullmatch(text):
raise TransferSummaryInputError(f"{label}必须是 YYYY-MM-DD 格式。")
try:
datetime.strptime(text, "%Y-%m-%d")
except ValueError as exc:
raise TransferSummaryInputError(f"{label}不是有效日期。") from exc
return text
def _money(value: Decimal) -> str:
return format(value.quantize(Decimal("0.01")), "f")
def _as_decimal(raw: object) -> Decimal:
try:
return Decimal(str(raw))
except (InvalidOperation, TypeError) as exc:
raise TransferSummaryInputError("金额数据无效,无法汇总。") from exc
def _company_row(connection: sqlite3.Connection, company_id: int) -> dict[str, object]:
row = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if row is None:
raise TransferSummaryInputError("本公司不存在。")
return {"id": int(row["id"]), "name": row["name"]}
def _window_bounds(
connection: sqlite3.Connection, as_of: str | None
) -> tuple[str, str]:
end = _validate_date(as_of, "as_of") if as_of else today_shanghai()
start = settings.get_settings(connection).get("start_date") or "2026-01-01"
start = _validate_date(start, "start_date")
if start > end:
# Opening / start-date plumbing may lag; clamp rather than 500.
start = end
return start, end
def _load_confirmed(
connection: sqlite3.Connection, company_id: int, start: str, end: 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_company_name,
cpayee.name AS payee_company_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 e.effective_at >= ?
AND e.effective_at <= ?
ORDER BY e.event_id
""",
(company_id, company_id, start, end + "T23:59:59"),
).fetchall()
def _load_pending(
connection: sqlite3.Connection, company_id: int, start: str, end: str
) -> list[sqlite3.Row]:
"""Pending = not eligible confirmed, still company-visible for tip only.
Includes unresolved / needs_review / internal_single, plus any
intercompany decision that is neither paired nor locked.
"""
return connection.execute(
"""
SELECT c.event_id, d.id AS decision_id, d.effective_at, d.amount,
d.currency, d.classification, d.pairing, d.locked,
payer.company_id AS payer_company_id,
payee.company_id AS payee_company_id,
cpayer.name AS payer_company_name,
cpayee.name AS payee_company_name
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
LEFT JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
LEFT JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
WHERE e.lifecycle = 'active'
AND (payer.company_id = ? OR payee.company_id = ?)
AND d.effective_at >= ?
AND d.effective_at <= ?
AND (
d.classification IN ('unresolved', 'needs_review', 'internal_single')
OR (
d.classification = 'intercompany'
AND d.pairing != 'paired'
AND d.locked = 0
)
)
ORDER BY c.event_id
""",
(company_id, company_id, start, end + "T23:59:59"),
).fetchall()
def _counterparty_of(row: sqlite3.Row, company_id: int) -> tuple[int | None, str | None]:
payer = row["payer_company_id"]
payee = row["payee_company_id"]
if payer is not None and int(payer) == int(company_id):
if payee is None:
return None, None
return int(payee), row["payee_company_name"]
if payee is not None and int(payee) == int(company_id):
if payer is None:
return None, None
return int(payer), row["payer_company_name"]
return None, None
def _is_outflow(row: sqlite3.Row, company_id: int) -> bool:
return row["payer_company_id"] is not None and int(row["payer_company_id"]) == int(
company_id
)
def company_intercompany_summary(
connection: sqlite3.Connection,
*,
company_id: int,
as_of: str | None = None,
) -> dict[str, object]:
"""Build the HEL-169 summary payload for one company session."""
own = _company_row(connection, company_id)
start, end = _window_bounds(connection, as_of)
confirmed_rows = _load_confirmed(connection, company_id, start, end)
pending_rows = _load_pending(connection, company_id, start, end)
seen_confirmed: set[int] = set()
outflow = _ZERO
inflow = _ZERO
outflow_count = 0
inflow_count = 0
# counterparty_id -> bucket
buckets: dict[int, dict[str, object]] = {}
def bucket_for(cp_id: int, cp_name: str | None) -> dict[str, object]:
bucket = buckets.get(cp_id)
if bucket is None:
bucket = {
"company_id": cp_id,
"company_name": cp_name or "",
"confirmed_outflow": _ZERO,
"confirmed_inflow": _ZERO,
"pending_count": 0,
"last_effective_at": None,
}
buckets[cp_id] = bucket
elif cp_name and not bucket["company_name"]:
bucket["company_name"] = cp_name
return bucket
def touch_last(bucket: dict[str, object], effective_at: str | None) -> None:
if not effective_at:
return
previous = bucket["last_effective_at"]
if previous is None or str(effective_at) > str(previous):
bucket["last_effective_at"] = effective_at
for row in confirmed_rows:
event_id = int(row["event_id"])
if event_id in seen_confirmed:
continue
seen_confirmed.add(event_id)
amount = _as_decimal(row["amount"])
cp_id, cp_name = _counterparty_of(row, company_id)
if _is_outflow(row, company_id):
outflow += amount
outflow_count += 1
if cp_id is not None:
bucket = bucket_for(cp_id, cp_name)
bucket["confirmed_outflow"] += amount
touch_last(bucket, row["effective_at"])
else:
inflow += amount
inflow_count += 1
if cp_id is not None:
bucket = bucket_for(cp_id, cp_name)
bucket["confirmed_inflow"] += amount
touch_last(bucket, row["effective_at"])
pending_total = _ZERO
seen_pending: set[int] = set()
for row in pending_rows:
event_id = int(row["event_id"])
if event_id in seen_pending or event_id in seen_confirmed:
continue
seen_pending.add(event_id)
amount = _as_decimal(row["amount"])
pending_total += amount
cp_id, cp_name = _counterparty_of(row, company_id)
if cp_id is not None:
bucket = bucket_for(cp_id, cp_name)
bucket["pending_count"] = int(bucket["pending_count"]) + 1
touch_last(bucket, row["effective_at"])
net = outflow - inflow
if net > 0:
net_direction: str | None = "receivable"
elif net < 0:
net_direction = "payable"
else:
net_direction = None
counterparties = []
for cp_id in sorted(
buckets.keys(),
key=lambda i: (
-(
buckets[i]["confirmed_outflow"] # type: ignore[operator]
+ buckets[i]["confirmed_inflow"]
),
buckets[i]["company_name"] or "",
i,
),
):
bucket = buckets[cp_id]
conf_out = bucket["confirmed_outflow"]
conf_in = bucket["confirmed_inflow"]
assert isinstance(conf_out, Decimal) and isinstance(conf_in, Decimal)
counterparties.append(
{
"company_id": cp_id,
"company_name": bucket["company_name"],
"confirmed_outflow": _money(conf_out),
"confirmed_inflow": _money(conf_in),
"net": _money(conf_out - conf_in),
"pending_count": int(bucket["pending_count"]),
"last_effective_at": bucket["last_effective_at"],
}
)
return {
"own_company": own,
"window": {
"start": start,
"end": end,
"has_opening": False,
# Reserved for opening-balance rollout; callers must not invent balances.
"opening": None,
"ending": None,
},
"confirmed": {
"outflow_total": _money(outflow),
"outflow_count": outflow_count,
"inflow_total": _money(inflow),
"inflow_count": inflow_count,
"net_change": _money(net),
"net_direction": net_direction,
},
"pending": {
"count": len(seen_pending),
"amount_total": _money(pending_total),
},
"counterparties": counterparties,
}
# ---------------------------------------------------------------------------
# Detail list + CSV export (HEL-176)
# ---------------------------------------------------------------------------
def encode_cursor(effective_at: str, event_id: int) -> str:
raw = f"{effective_at}|{event_id}"
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii")
def decode_cursor(cursor: str | None) -> tuple[str, int] | None:
if not cursor:
return None
try:
raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
effective_at, event_id_text = raw.split("|", 1)
return effective_at, int(event_id_text)
except Exception as exc:
raise TransferSummaryInputError("分页游标无效。") from exc
def _parse_limit(raw: object | None, default: int = _DEFAULT_PAGE) -> int:
if raw is None or str(raw).strip() == "":
return default
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise TransferSummaryInputError("limit 必须是正整数。") from exc
if value < 1:
raise TransferSummaryInputError("limit 必须是正整数。")
return min(value, _MAX_PAGE)
def _parse_optional_date(raw: object | None, label: str) -> str | None:
if raw is None or str(raw).strip() == "":
return None
return _validate_date(raw, label)
def _parse_direction(raw: object | None) -> str | None:
if raw is None or str(raw).strip() == "":
return None
value = str(raw).strip().lower()
if value not in ("out", "in"):
raise TransferSummaryInputError("direction 只能是 out 或 in。")
return value
def _parse_state(raw: object | None) -> str | None:
if raw is None or str(raw).strip() == "":
return None
value = str(raw).strip().lower()
if value not in ("confirmed", "pending"):
raise TransferSummaryInputError("state 只能是 confirmed 或 pending。")
return value
def _parse_counterparty_id(raw: object | None) -> int | None:
if raw is None or str(raw).strip() == "":
return None
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise TransferSummaryInputError("counterparty_id 参数无效。") from exc
if value < 1:
raise TransferSummaryInputError("counterparty_id 参数无效。")
return value
def _event_window(
connection: sqlite3.Connection,
*,
from_: str | None,
to: str | None,
) -> tuple[str, str]:
end = _parse_optional_date(to, "to") or today_shanghai()
start_default = settings.get_settings(connection).get("start_date") or "2026-01-01"
start = _parse_optional_date(from_, "from") or _validate_date(
start_default, "start_date"
)
if start > end:
raise TransferSummaryInputError("from 不能晚于 to。")
return start, end
# Confirmed = eligible_intercompany_events. Pending matches summary tip set.
_CONFIRMED_PREDICATE = """
d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1)
"""
_PENDING_PREDICATE = """
(
d.classification IN ('unresolved', 'needs_review', 'internal_single')
OR (
d.classification = 'intercompany'
AND d.pairing != 'paired'
AND d.locked = 0
)
)
"""
_BOTH_PREDICATE = f"""
(
({_CONFIRMED_PREDICATE})
OR ({_PENDING_PREDICATE})
)
"""
def _state_predicate(state: str | None) -> str:
if state == "confirmed":
return f"({_CONFIRMED_PREDICATE})"
if state == "pending":
return f"({_PENDING_PREDICATE})"
return _BOTH_PREDICATE
def _list_select_sql() -> str:
return """
SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
d.pairing, d.amount, d.currency, d.effective_at, d.mode,
d.locked, d.rule_version, d.created_at, d.reason,
payer.company_id AS payer_company_id,
payee.company_id AS payee_company_id,
payer.bank_account_id AS payer_account_id,
payee.bank_account_id AS payee_account_id,
cpayer.name AS payer_company_name,
cpayee.name AS payee_company_name,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count,
(SELECT r.summary
FROM transfer_decision_observations o
JOIN source_rows r ON r.id = o.source_row_id
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_batch_id
WHERE o.decision_id = d.id AND b.company_id = ?
ORDER BY o.id
LIMIT 1) AS summary
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
LEFT JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
LEFT JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
"""
def _build_event_filters(
*,
company_id: int,
start: str,
end: str,
counterparty_id: int | None,
direction: str | None,
state: str | None,
cursor: tuple[str, int] | None,
) -> tuple[str, list[object]]:
conditions = [
"e.lifecycle = 'active'",
"(payer.company_id = ? OR payee.company_id = ?)",
"d.effective_at >= ?",
"d.effective_at <= ?",
_state_predicate(state),
]
params: list[object] = [
company_id,
company_id,
start,
end + "T23:59:59",
]
if counterparty_id is not None:
# Counterparty is the other participant; own company stays forced above.
conditions.append(
"""
(
(payer.company_id = ? AND payee.company_id = ?)
OR (payee.company_id = ? AND payer.company_id = ?)
)
"""
)
params.extend([company_id, counterparty_id, company_id, counterparty_id])
if direction == "out":
conditions.append("payer.company_id = ?")
params.append(company_id)
elif direction == "in":
conditions.append("payee.company_id = ?")
params.append(company_id)
if cursor is not None:
cursor_at, cursor_id = cursor
conditions.append(
"""
(
d.effective_at < ?
OR (d.effective_at = ? AND c.event_id < ?)
)
"""
)
params.extend([cursor_at, cursor_at, cursor_id])
where = " WHERE " + " AND ".join(conditions)
return where, params
def _row_state(row: sqlite3.Row) -> str:
classification = row["classification"]
pairing = row["pairing"]
locked = bool(row["locked"])
if classification == "intercompany" and (pairing == "paired" or locked):
return "confirmed"
return "pending"
def _row_direction(row: sqlite3.Row, company_id: int) -> str | None:
if row["payer_company_id"] is not None and int(row["payer_company_id"]) == int(
company_id
):
return "out"
if row["payee_company_id"] is not None and int(row["payee_company_id"]) == int(
company_id
):
return "in"
return None
def _event_list_item(row: sqlite3.Row, company_id: int) -> dict[str, object]:
direction = _row_direction(row, company_id)
state = _row_state(row)
if direction == "out":
counterparty_company_id = row["payee_company_id"]
counterparty_company_name = row["payee_company_name"]
else:
counterparty_company_id = row["payer_company_id"]
counterparty_company_name = row["payer_company_name"]
summary = row["summary"] or row["reason"] or ""
return {
"event_id": int(row["event_id"]),
"decision_id": int(row["decision_id"]),
"revision": row["revision"],
"classification": row["classification"],
"pairing": row["pairing"],
"status": matching.exposed_status(row),
"state": state,
"direction": direction,
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"mode": row["mode"],
"locked": bool(row["locked"]),
"rule_version": row["rule_version"],
"summary": summary,
"own_company_id": company_id,
"counterparty_company_id": (
int(counterparty_company_id) if counterparty_company_id is not None else None
),
"counterparty_company_name": counterparty_company_name,
"evidence_count": row["evidence_count"],
}
def company_intercompany_events(
connection: sqlite3.Connection,
*,
company_id: int,
from_: str | None = None,
to: str | None = None,
counterparty_id: int | None | object = None,
direction: str | None | object = None,
state: str | None | object = None,
limit: object | None = None,
cursor: str | None = None,
) -> dict[str, object]:
"""Filtered keyset page of company-visible transfer events (HEL-176)."""
start, end = _event_window(connection, from_=from_, to=to)
cp_id = _parse_counterparty_id(counterparty_id)
direction_value = _parse_direction(direction)
state_value = _parse_state(state)
page_size = _parse_limit(limit)
cursor_tuple = decode_cursor(cursor)
where, params = _build_event_filters(
company_id=company_id,
start=start,
end=end,
counterparty_id=cp_id,
direction=direction_value,
state=state_value,
cursor=cursor_tuple,
)
# summary subquery binds own company_id first.
sql = (
_list_select_sql()
+ where
+ " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?"
)
rows = connection.execute(
sql, (company_id, *params, page_size + 1)
).fetchall()
has_more = len(rows) > page_size
page = rows[:page_size]
items = [_event_list_item(row, company_id) for row in page]
next_cursor = None
if has_more and page:
last = page[-1]
next_cursor = encode_cursor(str(last["effective_at"]), int(last["event_id"]))
return {
"window": {"start": start, "end": end},
"events": items,
"next_cursor": next_cursor,
"has_more": has_more,
}
def company_intercompany_export_rows(
connection: sqlite3.Connection,
*,
company_id: int,
from_: str | None = None,
to: str | None = None,
counterparty_id: int | None | object = None,
direction: str | None | object = None,
) -> tuple[list[dict[str, object]], dict[str, object]]:
"""Confirmed-only rows for CSV export; pending never included."""
start, end = _event_window(connection, from_=from_, to=to)
cp_id = _parse_counterparty_id(counterparty_id)
direction_value = _parse_direction(direction)
where, params = _build_event_filters(
company_id=company_id,
start=start,
end=end,
counterparty_id=cp_id,
direction=direction_value,
state="confirmed",
cursor=None,
)
sql = (
_list_select_sql()
+ where
+ " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?"
)
rows = connection.execute(
sql, (company_id, *params, _MAX_EXPORT_ROWS + 1)
).fetchall()
if len(rows) > _MAX_EXPORT_ROWS:
raise TransferSummaryInputError(
f"导出行数超过上限 {_MAX_EXPORT_ROWS},请缩小筛选范围。"
)
items = [_event_list_item(row, company_id) for row in rows]
meta = {
"start": start,
"end": end,
"counterparty_id": cp_id,
"direction": direction_value,
"state": "confirmed",
"row_count": len(items),
}
return items, meta
def render_intercompany_export_csv(items: list[dict[str, object]]) -> bytes:
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"日期",
"方向",
"对方公司",
"金额",
"币种",
"摘要",
"状态",
"配对",
"事件ID",
"决策ID",
]
)
direction_label = {"out": "转出", "in": "转入"}
for item in items:
writer.writerow(
[
item.get("effective_at") or "",
direction_label.get(str(item.get("direction") or ""), ""),
item.get("counterparty_company_name") or "",
item.get("amount") or "",
item.get("currency") or "",
item.get("summary") or "",
"已确认",
item.get("pairing") or "",
item.get("event_id") or "",
item.get("decision_id") or "",
]
)
# UTF-8 BOM so Excel opens the CSV with the right encoding.
return (chr(0xFEFF) + buffer.getvalue()).encode("utf-8")
def is_transfer_summary_events_query(query: dict[str, list[str]]) -> bool:
"""Discriminate HEL-176 transfer list from B-44 ledger ``/events``.
B-44 uses ``cutoff`` / subject / posting_kind / source_kind / pending_subject.
HEL-176 uses ``to`` / direction / counterparty_id / state=pending|confirmed
(without ledger-only knobs).
"""
if (query.get("direction") or [None])[0] is not None:
return True
if (query.get("counterparty_id") or [None])[0] is not None:
return True
if (query.get("to") or [None])[0] is not None:
return True
state = (query.get("state") or [None])[0]
if state in ("pending", "confirmed") and (query.get("cutoff") or [None])[0] is None:
# Bare state=confirmed without cutoff is the transfer-summary list;
# B-44 confirmed always pairs with cutoff in existing callers/tests.
if (query.get("subject") or [None])[0] is not None:
return False
if (query.get("posting_kind") or [None])[0] is not None:
return False
if (query.get("source_kind") or [None])[0] is not None:
return False
return True
return False
+356
View File
@@ -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",
}
+277
View File
@@ -569,6 +569,283 @@ MIGRATIONS: tuple[Migration, ...] = (
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id; 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;
""",
),
) )
+2
View File
@@ -25,6 +25,7 @@ import sqlite3
import tempfile import tempfile
from . import auth from . import auth
from . import ledger_events
from . import matching from . import matching
from .db import utc_now from .db import utc_now
from .models import SheetResult, StatementBatch from .models import SheetResult, StatementBatch
@@ -648,6 +649,7 @@ def review_sheets(
[item["id"] for item in confirmed_rows], [item["id"] for item in confirmed_rows],
actor=actor, actor=actor,
) )
ledger_events.reconcile_bank_events(connection, actor=actor)
if began: if began:
connection.commit() connection.commit()
except Exception: except Exception:
+684
View File
@@ -0,0 +1,684 @@
"""Canonical intercompany ledger events and the revision chain (B-44).
Bank source rows and approved manual records are immutable evidence. This
module turns them into one canonical ledger event each through ``reconcile_*``
functions and an append-only revision chain. Corrections are never in-place
edits: a reversal or adjustment is a new ledger event with its own effective
date, and the original event keeps its history so no earlier cutoff is
rewritten. Current revisions and source claims are rebuildable projections.
Only B-43 ``eligible_intercompany_events`` feeds bank facts here; same-company
transfers, external transactions, unresolved rows and unlocked single
observations never reach the confirmed balance.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .subjects import MIRROR, SUBJECTS, mirror_subject
class LedgerConflictError(ValueError):
"""A revision/claim/idempotency conflict (mapped to HTTP 409)."""
class LedgerInputError(ValueError):
"""Invalid input for a ledger operation (mapped to HTTP 400/422)."""
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
def amount_scale(amount: object) -> int:
"""Decimal places of a decimal-string amount, never negative."""
try:
exponent = Decimal(str(amount)).as_tuple().exponent
except InvalidOperation:
return 0
return max(0, -int(exponent))
def parse_amount(amount: object) -> Decimal:
"""Parse a positive, valid decimal-string amount."""
try:
value = Decimal(str(amount))
except InvalidOperation:
raise LedgerInputError("金额不是有效的十进制数。") from None
if not value.is_finite() or value <= 0:
raise LedgerInputError("金额必须大于零。")
return value
def _company_exists(connection: sqlite3.Connection, company_id: int, label: str) -> None:
row = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if row is None:
raise LedgerInputError(f"{label}指向的公司不存在。")
# ---------------------------------------------------------------------------
# Revision helpers
# ---------------------------------------------------------------------------
def _ensure_transaction(connection: sqlite3.Connection) -> bool:
"""Begin an immediate transaction unless one is already open.
Write helpers may run standalone (they own the transaction) or nested
inside a caller's transaction (e.g. the sheet-confirm flow); nested calls
never start their own commit.
"""
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
return began
def current_revision(connection: sqlite3.Connection, ledger_event_id: int) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT r.* FROM current_ledger_event_revisions c
JOIN ledger_event_revisions r ON r.id = c.revision_id
WHERE c.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
def _event_lifecycle(connection: sqlite3.Connection, ledger_event_id: int) -> str | None:
row = connection.execute(
"SELECT lifecycle FROM ledger_events WHERE id = ?", (ledger_event_id,)
).fetchone()
return row["lifecycle"] if row is not None else None
def _next_revision_number(connection: sqlite3.Connection, ledger_event_id: int) -> int:
row = connection.execute(
"SELECT COALESCE(MAX(revision), 0) AS m FROM ledger_event_revisions WHERE ledger_event_id = ?",
(ledger_event_id,),
).fetchone()
return int(row["m"]) + 1
def create_event(
connection: sqlite3.Connection,
*,
state: str,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int | None,
subject_code: str | None,
source_kind: str,
source_revision_token: str | None,
posting_kind: str,
reverses_ledger_event_id: int | None = None,
adjusts_ledger_event_id: int | None = None,
rule_version: str | None = None,
evidence_json: str | None = None,
idempotency_key: str | None = None,
actor: sqlite3.Row | None = None,
reason: str | None = None,
supersedes_revision_id: int | None = None,
) -> tuple[int, int]:
"""Insert a new ledger event with one revision. Returns ``(event_id, revision_id)``."""
if state == "confirmed":
if perspective_company_id is None or subject_code is None:
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
if subject_code not in SUBJECTS:
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if int(payer_company_id) == int(payee_company_id):
raise LedgerInputError("付款公司与收款公司不能相同。")
if perspective_company_id is not None and perspective_company_id not in (
int(payer_company_id), int(payee_company_id),
):
raise LedgerInputError("视角公司必须是事件参与方。")
began = _ensure_transaction(connection)
try:
now = utc_now()
cursor = connection.execute(
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
)
event_id = int(cursor.lastrowid)
revision_id = append_revision(
connection,
event_id,
state=state,
effective_at=effective_at,
amount=amount,
currency=currency,
payer_company_id=payer_company_id,
payee_company_id=payee_company_id,
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind=source_kind,
source_revision_token=source_revision_token,
posting_kind=posting_kind,
reverses_ledger_event_id=reverses_ledger_event_id,
adjusts_ledger_event_id=adjusts_ledger_event_id,
rule_version=rule_version,
evidence_json=evidence_json,
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
supersedes_revision_id=supersedes_revision_id,
)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return event_id, revision_id
def append_revision(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
state: str,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int | None,
subject_code: str | None,
source_kind: str,
source_revision_token: str | None,
posting_kind: str,
reverses_ledger_event_id: int | None = None,
adjusts_ledger_event_id: int | None = None,
rule_version: str | None = None,
evidence_json: str | None = None,
idempotency_key: str | None = None,
actor: sqlite3.Row | None = None,
reason: str | None = None,
supersedes_revision_id: int | None = None,
) -> int:
"""Append one immutable revision and repoint the current projection."""
if _event_lifecycle(connection, ledger_event_id) != "active":
raise LedgerConflictError("该事件已停用,不能追加修订。")
if state == "confirmed":
if perspective_company_id is None or subject_code is None:
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
if subject_code not in SUBJECTS:
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if perspective_company_id not in (int(payer_company_id), int(payee_company_id)):
raise LedgerInputError("视角公司必须是事件参与方。")
elif state != "pending_subject":
raise LedgerInputError("事件状态必须是 pending_subject 或 confirmed。")
revision = _next_revision_number(connection, ledger_event_id)
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO ledger_event_revisions (
ledger_event_id, revision, state, effective_at, amount,
amount_scale, currency, payer_company_id, payee_company_id,
perspective_company_id, subject_code, source_kind,
source_revision_token, posting_kind, reverses_ledger_event_id,
adjusts_ledger_event_id, rule_version, evidence_json,
idempotency_key, actor_user_id, actor_username, reason,
supersedes_revision_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ledger_event_id, revision, state, effective_at, amount,
amount_scale(amount), currency, payer_company_id, payee_company_id,
perspective_company_id, subject_code, source_kind,
source_revision_token, posting_kind, reverses_ledger_event_id,
adjusts_ledger_event_id, rule_version, evidence_json,
idempotency_key,
actor["id"] if actor is not None else None,
actor["username"] if actor is not None else None,
reason, supersedes_revision_id, now,
),
)
connection.execute(
"""
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
VALUES (?, ?)
""",
(ledger_event_id, cursor.lastrowid),
)
return int(cursor.lastrowid)
# ---------------------------------------------------------------------------
# Source claims
# ---------------------------------------------------------------------------
def bank_source_claim(connection: sqlite3.Connection, bank_event_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM ledger_event_bank_sources WHERE bank_event_id = ?",
(bank_event_id,),
).fetchone()
def manual_source_claim(connection: sqlite3.Connection, manual_record_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(manual_record_id,),
).fetchone()
def _facts_of(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object]:
revision = current_revision(connection, ledger_event_id)
if revision is None:
return {}
return {
"amount": revision["amount"],
"currency": revision["currency"],
"effective_at": revision["effective_at"],
"payer_company_id": revision["payer_company_id"],
"payee_company_id": revision["payee_company_id"],
}
def _eligible_facts(event: sqlite3.Row) -> dict[str, object]:
return {
"amount": event["amount"],
"currency": event["currency"],
"effective_at": event["effective_at"],
"payer_company_id": event["payer_company_id"],
"payee_company_id": event["payee_company_id"],
}
def _has_reversal(connection: sqlite3.Connection, original_event_id: int) -> bool:
row = connection.execute(
"""
SELECT 1 FROM ledger_event_revisions r
JOIN current_ledger_event_revisions c ON c.revision_id = r.id
WHERE r.reverses_ledger_event_id = ? AND r.posting_kind = 'reversal'
LIMIT 1
""",
(original_event_id,),
).fetchone()
return row is not None
def create_reversal(
connection: sqlite3.Connection,
original_event_id: int,
*,
source_kind: str,
source_revision_token: str | None = None,
effective_at: str | None = None,
reason: str,
actor: sqlite3.Row | None,
idempotency_key: str | None = None,
rule_version: str | None = None,
) -> tuple[int, int]:
"""Create an equal-amount, opposite-direction reversal as a new ledger event.
The subject mirrors the original (应收<->应付, 其他应收<->其他应付). ``effective_at``
defaults to the original event's effective date so an earlier cutoff keeps
the original impact and later cutoffs see the net zero. The original event
is never modified or deleted.
"""
original = current_revision(connection, original_event_id)
if original is None:
raise LedgerConflictError("原事件不存在或没有当前修订。")
if original["state"] != "confirmed":
raise LedgerInputError("只有已确认事件才能生成冲销。")
perspective = mirror_perspective(original)
if effective_at is None:
effective_at = original["effective_at"]
return create_event(
connection,
state="confirmed",
effective_at=effective_at,
amount=original["amount"],
currency=original["currency"],
payer_company_id=original["payee_company_id"],
payee_company_id=original["payer_company_id"],
perspective_company_id=perspective,
subject_code=mirror_subject(original["subject_code"]),
source_kind=source_kind,
source_revision_token=source_revision_token,
posting_kind="reversal",
reverses_ledger_event_id=original_event_id,
rule_version=rule_version or original["rule_version"],
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
)
def mirror_perspective(revision: sqlite3.Row) -> int:
"""The counterparty company from ``revision``'s perspective."""
perspective = int(revision["perspective_company_id"])
if perspective == int(revision["payer_company_id"]):
return int(revision["payee_company_id"])
return int(revision["payer_company_id"])
def create_adjustment(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int,
subject_code: str,
reason: str,
actor: sqlite3.Row,
idempotency_key: str | None = None,
rule_version: str | None = None,
) -> tuple[int, int]:
"""Create an audit adjustment event; the original event stays unchanged."""
return create_event(
connection,
state="confirmed",
effective_at=effective_at,
amount=str(parse_amount(amount)),
currency=currency,
payer_company_id=payer_company_id,
payee_company_id=payee_company_id,
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind="adjustment",
source_revision_token=None,
posting_kind="adjustment",
adjusts_ledger_event_id=ledger_event_id,
rule_version=rule_version or SUBJECT_RULE_VERSION,
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
)
def reopen_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
reason: str,
actor: sqlite3.Row,
idempotency_key: str | None = None,
) -> tuple[int, int]:
"""Reverse a confirmed event and re-open it for subject re-review.
Creates an equal-amount reversal plus a fresh ``pending_subject`` event
that re-claims the original bank source, so the administrator can confirm
a corrected subject. The original event and its reversal keep history.
"""
current = current_revision(connection, ledger_event_id)
if current is None or current["state"] != "confirmed":
raise LedgerConflictError("只有已确认事件可以重新进入科目审核。")
bank_claim = connection.execute(
"SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ?",
(ledger_event_id,),
).fetchone()
if bank_claim is None:
raise LedgerInputError(
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
)
if not _has_reversal(connection, ledger_event_id):
create_reversal(
connection, ledger_event_id,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
reason="科目复核:原确认事件冲销",
actor=actor,
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
rule_version=current["rule_version"],
)
ev = connection.execute(
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
(bank_claim["bank_event_id"],),
).fetchone()
if ev is None:
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
event_id, revision_id = _create_bank_event(
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
replacing_claim=bank_claim,
)
return event_id, revision_id
# ---------------------------------------------------------------------------
# Bank event reconciliation
# ---------------------------------------------------------------------------
def reconcile_bank_events(
connection: sqlite3.Connection, actor: sqlite3.Row | None = None
) -> dict[str, object]:
"""Reconcile the current eligible intercompany events into ledger events.
Idempotent: first sight creates a ``pending_subject`` event; a changed B-43
decision updates a still-pending event's revision, or (for a confirmed
event) creates a reversal plus a fresh pending event. A source that left
the eligible set with a confirmed impact gets one reversal. Runs inside the
caller's transaction when one is open, otherwise in its own transaction.
"""
began = _ensure_transaction(connection)
try:
eligible = {
row["event_id"]: row
for row in connection.execute(
"SELECT * FROM eligible_intercompany_events"
).fetchall()
}
claims = {
row["bank_event_id"]: row
for row in connection.execute(
"SELECT * FROM ledger_event_bank_sources"
).fetchall()
}
stats = {
"created": 0, "updated_pending": 0, "reversal": 0,
"reopened": 0, "unchanged": 0, "sources": len(eligible),
}
for bank_event_id, event in sorted(eligible.items()):
claim = claims.get(bank_event_id)
if claim is None:
_create_bank_event(
connection, event, actor, reason="B-43 事件首次入账,待确认科目"
)
stats["created"] += 1
continue
current = current_revision(connection, claim["ledger_event_id"])
if current is None or _facts_of(connection, claim["ledger_event_id"]) != _eligible_facts(event):
if current is not None and current["state"] == "confirmed":
if not _has_reversal(connection, claim["ledger_event_id"]):
create_reversal(
connection, claim["ledger_event_id"],
source_kind="bank",
source_revision_token=event["decision_id"],
reason="B-43 事件事实变更,原确认事件冲销",
actor=actor,
)
stats["reversal"] += 1
_create_bank_event(
connection, event, actor,
reason="B-43 事件事实变更后重新入账,待确认科目",
replacing_claim=claim,
)
stats["reopened"] += 1
elif current is None or current["state"] == "pending_subject":
_append_bank_pending_revision(
connection, claim["ledger_event_id"], event, actor
)
stats["updated_pending"] += 1
else:
stats["unchanged"] += 1
else:
stats["unchanged"] += 1
for bank_event_id, claim in sorted(claims.items()):
if bank_event_id in eligible:
continue
current = current_revision(connection, claim["ledger_event_id"])
if current is not None and current["state"] == "confirmed":
if not _has_reversal(connection, claim["ledger_event_id"]):
create_reversal(
connection, claim["ledger_event_id"],
source_kind="bank",
source_revision_token=None,
reason="B-43 事件不再纳入往来,原确认事件冲销",
actor=actor,
)
stats["reversal"] += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return stats
def _create_bank_event(
connection: sqlite3.Connection,
event: sqlite3.Row,
actor: sqlite3.Row | None,
*,
reason: str,
replacing_claim: sqlite3.Row | None = None,
) -> tuple[int, int]:
event_id, revision_id = create_event(
connection,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason=reason,
)
if replacing_claim is not None:
connection.execute(
"""
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
WHERE bank_event_id = ?
""",
(event_id, event["event_id"]),
)
else:
connection.execute(
"""
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
VALUES (?, ?)
""",
(event["event_id"], event_id),
)
from .subjects import store_suggestions
store_suggestions(connection, event_id)
return event_id, revision_id
def _append_bank_pending_revision(
connection: sqlite3.Connection,
ledger_event_id: int,
event: sqlite3.Row,
actor: sqlite3.Row | None,
) -> int:
current = current_revision(connection, ledger_event_id)
revision_id = append_revision(
connection,
ledger_event_id,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason="B-43 事件事实更新,追加待审修订",
supersedes_revision_id=current["id"] if current is not None else None,
)
from .subjects import store_suggestions
store_suggestions(connection, ledger_event_id)
return revision_id
# ---------------------------------------------------------------------------
# Projection rebuild
# ---------------------------------------------------------------------------
def rebuild_current_ledger_projection(connection: sqlite3.Connection) -> int:
"""Rebuild current ledger revisions from the append-only log."""
began = _ensure_transaction(connection)
try:
connection.execute("DELETE FROM current_ledger_event_revisions")
rows = connection.execute(
"""
SELECT e.id AS ledger_event_id,
(SELECT r2.id FROM ledger_event_revisions r2
WHERE r2.ledger_event_id = e.id
ORDER BY r2.revision DESC LIMIT 1) AS latest_id
FROM ledger_events e
WHERE e.lifecycle = 'active'
""",
).fetchall()
rebuilt = 0
for row in rows:
if row["latest_id"] is None:
continue
connection.execute(
"""
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
VALUES (?, ?)
""",
(row["ledger_event_id"], row["latest_id"]),
)
rebuilt += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return rebuilt
+818
View File
@@ -0,0 +1,818 @@
"""Manual evidence records, administrator approval and audit-safe reversal.
Manual records are immutable submitted facts. Only an approved record becomes
a canonical ledger event (``approve_new``) or joins one (``approve_link``);
returned/exception/pending records never affect a balance and never leak to
the counterparty. Approved facts change only through a ``reverse`` decision
that creates an opposite new event (or detaches a linked claim) — the original
is never edited. Idempotency keys and UNIQUE claims prevent double counting.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .ledger_events import (
LedgerConflictError,
LedgerInputError,
create_event,
create_reversal,
current_revision,
manual_source_claim,
)
from .subjects import SUBJECTS
MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed")
FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other")
DATE_KEYS = ("occurred_at",)
class ManualConflictError(ValueError):
"""A claim/idempotency/revision conflict (mapped to HTTP 409)."""
class ManualInputError(ValueError):
"""Invalid manual record input (mapped to HTTP 400/422)."""
def _parse_amount(amount: object) -> Decimal:
try:
value = Decimal(str(amount))
except InvalidOperation:
raise ManualInputError("金额不是有效的十进制数。") from None
if not value.is_finite() or value <= 0:
raise ManualInputError("金额必须大于零。")
return value
def _validate_date(value: object, field: str) -> str:
text = str(value or "").strip()
if len(text) < 10:
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。")
try:
datetime.fromisoformat(text[:10])
except ValueError:
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None
return text
def _business_today() -> str:
"""Shanghai business date (the default reversal effective date)."""
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
# ---------------------------------------------------------------------------
# Submit
# ---------------------------------------------------------------------------
def submit(
connection: sqlite3.Connection,
*,
company_id: int,
counterparty_company_id: int,
occurred_at: str,
direction: str,
amount: str,
currency: str,
funding_source: str,
requested_subject: str,
request_key: str,
actor: sqlite3.Row,
bank_account_id: object = None,
personal_transit_mapping_id: object = None,
related_source_row_id: object = None,
summary: object = None,
reason: object = None,
evidence: object = None,
supersedes_record_id: object = None,
) -> dict[str, object]:
"""Submit one manual record for review. Idempotent on ``(company_id, request_key)``."""
request_key = str(request_key or "").strip()
if not request_key:
raise ManualInputError("必须提供提交幂等键 request_key。")
if int(company_id) == int(counterparty_company_id):
raise ManualInputError("对方公司不能与本公司相同。")
if direction not in ("outgoing", "incoming"):
raise ManualInputError("方向必须是 outgoing 或 incoming。")
if funding_source not in FUNDING_SOURCES:
raise ManualInputError(f"资金来源必须是:{''.join(FUNDING_SOURCES)}")
if requested_subject not in SUBJECTS:
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
_validate_date(occurred_at, "业务日期")
currency = str(currency or "").strip()
if not currency:
raise ManualInputError("币种不能为空。")
amount = str(_parse_amount(amount))
for label, raw in (
("company_id", company_id), ("counterparty_company_id", counterparty_company_id),
):
row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone()
if row is None:
raise ManualInputError(f"{label} 指向的公司不存在。")
bank_account_id = _resolve_account_ref(
connection, bank_account_id, company_id, "银行账户"
)
mapping_id = _resolve_account_ref(
connection, personal_transit_mapping_id, company_id, "个人过账映射"
)
related_row = None
if related_source_row_id not in (None, ""):
related_row = connection.execute(
"SELECT r.id, b.company_id FROM source_rows r "
"JOIN sheet_batches s ON s.id = r.sheet_batch_id "
"JOIN import_batches b ON b.id = s.import_batch_id "
"WHERE r.id = ?",
(int(related_source_row_id),),
).fetchone()
if related_row is None:
raise ManualInputError("关联银行源行不存在。")
if funding_source == "approved_bank_account" and bank_account_id is None:
raise ManualInputError("资金来源为已批准账户时必须指定银行账户。")
if funding_source == "personal_transit" and mapping_id is None:
raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。")
supersedes_id = None
if supersedes_record_id not in (None, ""):
parent = connection.execute(
"SELECT id, company_id FROM manual_records WHERE id = ?",
(int(supersedes_record_id),),
).fetchone()
if parent is None or parent["company_id"] != int(company_id):
raise ManualInputError("supersedes_record_id 无效。")
supersedes_id = int(supersedes_record_id)
now = utc_now()
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
# Re-check inside the write transaction: concurrent identical submits
# serialize here, so a replay is found before any INSERT.
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _record_payload(connection, existing["id"], idempotent_replay=True)
try:
cursor = connection.execute(
"""
INSERT INTO manual_records (
company_id, counterparty_company_id, occurred_at, direction,
amount, amount_scale, currency, funding_source, bank_account_id,
personal_transit_mapping_id, related_source_row_id,
requested_subject, summary, reason, evidence_json, request_key,
supersedes_record_id, submitted_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(company_id), int(counterparty_company_id), occurred_at, direction,
amount, _scale_of(amount), currency, funding_source, bank_account_id,
mapping_id, related_row["id"] if related_row is not None else None,
requested_subject, str(summary or "") or None,
str(reason or "") or None,
json.dumps(evidence, ensure_ascii=False) if evidence else None,
request_key, supersedes_id,
actor["id"], now,
),
)
except sqlite3.IntegrityError:
# A concurrent identical submit won the race and committed first;
# surface the existing record idempotently instead of a UNIQUE 500.
if began:
connection.rollback()
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
return _record_payload(connection, existing["id"], idempotent_replay=True)
raise
record_id = int(cursor.lastrowid)
_append_decision(
connection, record_id, state="pending", action="submit",
reason=str(reason or "") or None, actor=actor,
)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _record_payload(connection, record_id)
def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None:
if raw in (None, ""):
return None
row = connection.execute(
"SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),)
).fetchone()
if row is None:
raise ManualInputError(f"{label}不存在。")
if row["company_id"] != int(company_id):
raise ManualInputError(f"{label}必须属于提交公司。")
return int(raw)
def _scale_of(amount: str) -> int:
exponent = Decimal(amount).as_tuple().exponent
return max(0, -int(exponent))
# ---------------------------------------------------------------------------
# Decisions
# ---------------------------------------------------------------------------
def _append_decision(
connection: sqlite3.Connection,
record_id: int,
*,
state: str,
action: str,
reason: str | None,
actor: sqlite3.Row,
idempotency_key: str | None = None,
supersedes_decision_id: int | None = None,
) -> int:
row = connection.execute(
"SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?",
(record_id,),
).fetchone()
revision = int(row["m"]) + 1
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO manual_record_decisions (
record_id, revision, state, action, reason, actor_user_id,
actor_username, idempotency_key, supersedes_decision_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
record_id, revision, state, action, reason,
actor["id"], actor["username"], idempotency_key,
supersedes_decision_id, now,
),
)
decision_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
VALUES (?, ?)
""",
(record_id, decision_id),
)
return decision_id
def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT d.* FROM current_manual_record_decisions c
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE c.record_id = ?
""",
(record_id,),
).fetchone()
def decide(
connection: sqlite3.Connection,
record_id: int,
action: str,
*,
reason: str,
expected_decision_id: int | None,
request_key: str | None,
actor: sqlite3.Row,
subject_code: object = None,
target_ledger_event_id: object = None,
effective_at: object = None,
) -> dict[str, object]:
"""Apply an administrator decision to a manual record.
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
existing ledger event without adding a second economic impact; ``return``
and ``exception`` never produce a balance; ``reverse`` creates an opposite
reversal event (or detaches a linked claim) with an independent business
effective date — explicit ``effective_at`` or the approval business day.
Replays return the earlier outcome via ``idempotency_key``.
"""
reason = (reason or "").strip()
if not reason:
raise ManualInputError("必须填写审核原因。")
if action not in ("approve_new", "approve_link", "return", "exception", "reverse"):
raise ManualInputError("未知的审核决定类型。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if record is None:
raise ManualConflictError("手工记录不存在。")
if request_key:
existing = connection.execute(
"SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?",
(record_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _decision_payload(connection, record_id, existing["id"])
current = _current_decision(connection, record_id)
if current is None:
raise ManualConflictError("该记录没有当前状态。")
if expected_decision_id is not None and int(expected_decision_id) != current["id"]:
raise ManualConflictError("记录已发生变更,请刷新后重试。")
if action == "approve_new":
outcome = _approve_new(
connection, record, current, actor, subject_code, reason, request_key
)
elif action == "approve_link":
outcome = _approve_link(
connection, record, current, actor, target_ledger_event_id, reason,
request_key,
)
elif action == "return":
if current["state"] != "pending":
raise ManualConflictError("只有待复核的记录可以退回。")
decision_id = _append_decision(
connection, record_id, state="returned", action=action,
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
outcome = {"decision_id": decision_id, "ledger_event_id": None}
elif action == "exception":
if current["state"] != "pending":
raise ManualConflictError("只有待复核的记录可以转为异常。")
decision_id = _append_decision(
connection, record_id, state="exception", action=action,
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
outcome = {"decision_id": decision_id, "ledger_event_id": None}
else: # reverse
if current["state"] != "approved":
raise ManualConflictError("只有已批准记录可以冲销。")
outcome = _reverse(
connection, record, current, actor, request_key, reason,
effective_at=effective_at,
)
_store_audit(connection, record, current, action, outcome, reason, actor)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _decision_payload(connection, record_id, outcome["decision_id"])
def _approve_new(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
subject_code: object,
reason: str,
idempotency_key: str | None,
) -> dict[str, object]:
subject = str(subject_code or record["requested_subject"] or "")
if subject not in SUBJECTS:
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if record["direction"] == "outgoing":
payer, payee = record["company_id"], record["counterparty_company_id"]
else:
payer, payee = record["counterparty_company_id"], record["company_id"]
event_id, _revision_id = create_event(
connection,
state="confirmed",
effective_at=record["occurred_at"],
amount=record["amount"],
currency=record["currency"],
payer_company_id=payer,
payee_company_id=payee,
perspective_company_id=record["company_id"],
subject_code=subject,
source_kind="manual",
source_revision_token=None,
posting_kind="normal",
rule_version="manual-record-v1",
evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False),
actor=actor,
reason=reason,
)
decision_id = _append_decision(
connection, record["id"], state="approved", action="approve_new",
reason=reason, actor=actor,
idempotency_key=idempotency_key,
supersedes_decision_id=current["id"],
)
connection.execute(
"""
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
VALUES (?, ?)
""",
(record["id"], event_id),
)
return {"decision_id": decision_id, "ledger_event_id": event_id}
def _approve_link(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
target_ledger_event_id: object,
reason: str,
idempotency_key: str | None,
) -> dict[str, object]:
if target_ledger_event_id in (None, ""):
raise ManualInputError("approve_link 必须指定目标往来事件。")
target = connection.execute(
"SELECT id, lifecycle FROM ledger_events WHERE id = ?",
(int(target_ledger_event_id),),
).fetchone()
if target is None or target["lifecycle"] != "active":
raise ManualConflictError("目标往来事件不存在。")
if manual_source_claim(connection, record["id"]) is not None:
raise ManualConflictError("该手工记录已关联往来事件。")
decision_id = _append_decision(
connection, record["id"], state="approved", action="approve_link",
reason=reason, actor=actor,
idempotency_key=idempotency_key,
supersedes_decision_id=current["id"],
)
connection.execute(
"""
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
VALUES (?, ?)
""",
(record["id"], int(target_ledger_event_id)),
)
return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)}
def _reverse(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
request_key: str | None,
reason: str,
effective_at: object = None,
) -> dict[str, object]:
claim = manual_source_claim(connection, record["id"])
if claim is None:
raise ManualConflictError("该记录尚未关联往来事件,无法冲销。")
event_id = claim["ledger_event_id"]
revision = current_revision(connection, event_id)
if revision is None:
raise ManualConflictError("关联的往来事件没有当前修订。")
if _is_manual_creation_source(revision, record["id"]):
# This record created the event (approve_new): it added the economic
# impact, so reversing it must always produce an equal-amount reversal
# event — even when other manual evidence was later linked onto the
# same event. The original impact must not survive in balances.
if effective_at is not None and str(effective_at).strip():
effective_at = _validate_date(effective_at, "冲销生效日")
else:
effective_at = _business_today()
create_reversal(
connection,
event_id,
source_kind="manual",
source_revision_token=str(record["id"]),
effective_at=effective_at,
reason=reason,
actor=actor,
idempotency_key=request_key,
rule_version="manual-record-v1",
)
else:
# The manual was linked evidence (approve_link) on an event it never
# created: it added no second impact, so reversing detaches the claim
# and the underlying economic impact stays.
connection.execute(
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(record["id"],),
)
decision_id = _append_decision(
connection, record["id"], state="reversed", action="reverse",
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
return {"decision_id": decision_id, "ledger_event_id": None}
def _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool:
"""True when ``manual_record_id`` created the event via ``approve_new``.
The creation source is recorded in the event's immutable revision
``evidence_json``; a linked evidence record is never the creation source
and carries no second economic impact.
"""
if revision["source_kind"] != "manual":
return False
try:
evidence = json.loads(revision["evidence_json"] or "{}")
except (TypeError, ValueError):
return False
return evidence.get("manual_record_id") == manual_record_id
def _store_audit(
connection, record, current, action, outcome, reason, actor
) -> None:
from .auth import audit
audit(
connection,
f"manual_{action}",
actor=actor,
target=f"manual_record:{record['id']}",
detail=(
f"decision:{outcome['decision_id']};"
f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}"
),
)
# ---------------------------------------------------------------------------
# Candidates and queries
# ---------------------------------------------------------------------------
def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]:
"""Deterministic hints shown before approval; never auto-merged."""
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if record is None:
return []
wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing"
date_prefix = str(record["occurred_at"])[:10]
candidates: list[dict[str, object]] = []
bank_rows = connection.execute(
"""
SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing,
e.payer_company_id, e.payee_company_id, e.decision_id
FROM eligible_intercompany_events e
WHERE (e.payer_company_id = ? AND e.payee_company_id = ?)
OR (e.payer_company_id = ? AND e.payee_company_id = ?)
ORDER BY e.event_id
""",
(
record["company_id"], record["counterparty_company_id"],
record["counterparty_company_id"], record["company_id"],
),
).fetchall()
for row in bank_rows:
if row["amount"] != record["amount"] or row["currency"] != record["currency"]:
continue
event_direction = (
"outgoing" if row["payer_company_id"] == record["company_id"] else "incoming"
)
if event_direction != wanted_direction:
continue
candidates.append(
{
"kind": "bank_event",
"ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]),
"bank_event_id": row["event_id"],
"decision_id": row["decision_id"],
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"pairing": row["pairing"],
"hint": "已存在匹配的银行规范事件,建议关联",
}
)
manual_rows = connection.execute(
"""
SELECT m.id, m.company_id, m.counterparty_company_id, m.direction,
m.amount, m.currency, m.occurred_at, d.state
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE m.id != ? AND m.amount = ? AND m.currency = ?
AND (
(m.company_id = ? AND m.counterparty_company_id = ?)
OR (m.company_id = ? AND m.counterparty_company_id = ?)
)
ORDER BY m.id
""",
(
record["id"], record["amount"], record["currency"],
record["company_id"], record["counterparty_company_id"],
record["counterparty_company_id"], record["company_id"],
),
).fetchall()
for row in manual_rows:
if row["direction"] != wanted_direction:
continue
if row["state"] not in ("approved", "pending"):
continue
candidates.append(
{
"kind": "manual_record",
"ledger_event_id": None,
"manual_record_id": row["id"],
"amount": row["amount"],
"currency": row["currency"],
"occurred_at": row["occurred_at"],
"state": row["state"],
"hint": "存在方向相反的同额手工记录,建议核对后关联",
}
)
return candidates
def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None:
claim = connection.execute(
"SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?",
(bank_event_id,),
).fetchone()
return claim["ledger_event_id"] if claim is not None else None
def list_records(
connection: sqlite3.Connection,
*,
company_id: int | None = None,
state: str | None = None,
limit: int = 100,
) -> list[sqlite3.Row]:
conditions: list[str] = []
params: list[object] = []
if company_id is not None:
conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)")
params.extend([company_id, company_id])
if state is not None:
if state not in MANUAL_STATES:
raise ManualInputError("无效的记录状态。")
conditions.append("d.state = ?")
params.append(state)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
return connection.execute(
f"""
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
d.action AS action, d.reason AS decision_reason,
d.actor_username AS decision_actor, d.created_at AS decision_at,
c.name AS company_name, cc.name AS counterparty_company_name,
u.username AS submitted_by_username
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
LEFT JOIN companies c ON c.id = m.company_id
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
LEFT JOIN users u ON u.id = m.submitted_by
{where}
ORDER BY m.id DESC
LIMIT ?
""",
(*params, max(1, int(limit))),
).fetchall()
def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int:
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
connection.execute("DELETE FROM current_manual_record_decisions")
rows = connection.execute(
"""
SELECT m.id AS record_id,
(SELECT d2.id FROM manual_record_decisions d2
WHERE d2.record_id = m.id
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
FROM manual_records m
"""
).fetchall()
rebuilt = 0
for row in rows:
if row["latest_id"] is None:
continue
connection.execute(
"""
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
VALUES (?, ?)
""",
(row["record_id"], row["latest_id"]),
)
rebuilt += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return rebuilt
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]:
rows = list_records(connection, limit=1000)
row = next((item for item in rows if item["id"] == record_id), None)
if row is None:
raise ManualInputError("手工记录不存在。")
payload = _row_payload(connection, row)
if idempotent_replay:
payload["idempotent_replay"] = True
return payload
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {}
return {
"id": row["id"],
"company_id": row["company_id"],
"company_name": row["company_name"],
"counterparty_company_id": row["counterparty_company_id"],
"counterparty_company_name": row["counterparty_company_name"],
"occurred_at": row["occurred_at"],
"direction": row["direction"],
"amount": row["amount"],
"currency": row["currency"],
"funding_source": row["funding_source"],
"bank_account_id": row["bank_account_id"],
"personal_transit_mapping_id": row["personal_transit_mapping_id"],
"related_source_row_id": row["related_source_row_id"],
"requested_subject": row["requested_subject"],
"summary": row["summary"],
"reason": row["reason"],
"request_key": row["request_key"],
"supersedes_record_id": row["supersedes_record_id"],
"submitted_by": row["submitted_by"],
"submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None,
"attachment_name": evidence.get("attachment_name"),
"created_at": row["created_at"],
"state": row["state"],
"decision_id": row["decision_id"],
"decision_revision": row["decision_revision"],
"decision_action": row["action"],
"decision_reason": row["decision_reason"],
"decision_actor": row["decision_actor"],
"decision_at": row["decision_at"],
"candidates": find_candidates(connection, row["id"]),
}
def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]:
row = connection.execute(
"""
SELECT d.* FROM manual_record_decisions d
WHERE d.id = ?
""",
(decision_id,),
).fetchone()
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
claim = manual_source_claim(connection, record_id)
return {
"record_id": record_id,
"decision_id": decision_id,
"revision": row["revision"],
"state": row["state"],
"action": row["action"],
"reason": row["reason"],
"actor_username": row["actor_username"],
"created_at": row["created_at"],
"ledger_event_id": claim["ledger_event_id"] if claim is not None else None,
"requested_subject": record["requested_subject"],
"amount": record["amount"],
"currency": record["currency"],
"occurred_at": record["occurred_at"],
}
+1 -1
View File
@@ -262,7 +262,7 @@ def submit_bank_account(
if existing["company_id"] == company_id: if existing["company_id"] == company_id:
raise ConflictError("该银行账号已登记,请等待现有申请处理。") raise ConflictError("该银行账号已登记,请等待现有申请处理。")
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。") raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
def review_bank_account( def review_bank_account(
+81
View File
@@ -1563,6 +1563,87 @@ def exposed_status(decision: sqlite3.Row | dict) -> str:
return "unresolved" return "unresolved"
def company_pending_unilaterals(
connection: sqlite3.Connection, company_id: int
) -> list[sqlite3.Row]:
"""Authoritative pending unilateral queue for a company workspace.
Same set as company match-exceptions: active events whose current decision
is ``unresolved`` / ``needs_review`` and the company participates.
Parentheses on the company filter avoid OR/AND precedence bugs.
"""
return connection.execute(
"""
SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
d.pairing, d.amount, d.currency, d.effective_at, d.mode,
d.locked, d.rule_version, d.created_at,
payer.company_id AS payer_company_id,
payee.company_id AS payee_company_id,
payer.bank_account_id AS payer_account_id,
payee.bank_account_id AS payee_account_id,
cpayer.name AS payer_company_name,
cpayee.name AS payee_company_name,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
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
LEFT JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
LEFT JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
WHERE e.lifecycle = 'active'
AND d.classification IN ('unresolved', 'needs_review')
AND (payer.company_id = ? OR payee.company_id = ?)
ORDER BY d.id DESC
""",
(company_id, company_id),
).fetchall()
def company_workspace_payload(
connection: sqlite3.Connection, company_id: int
) -> dict[str, object]:
"""Workspace counts + list share one authoritative unilateral queue."""
rows = company_pending_unilaterals(connection, company_id)
events = [
{
"event_id": row["event_id"],
"decision_id": row["decision_id"],
"revision": row["revision"],
"classification": row["classification"],
"pairing": row["pairing"],
"status": exposed_status(row),
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"mode": row["mode"],
"locked": bool(row["locked"]),
"own_company_id": company_id,
"counterparty_company_id": (
row["payee_company_id"]
if row["payer_company_id"] == company_id
else row["payer_company_id"]
),
"counterparty_company_name": (
row["payee_company_name"]
if row["payer_company_id"] == company_id
else row["payer_company_name"]
),
"evidence_count": row["evidence_count"],
}
for row in rows
]
count = len(events)
return {
"pending_unilateral": count,
"pending_total": count,
"unilateral_events": events,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API payload helpers # API payload helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+309
View File
@@ -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
+398
View File
@@ -0,0 +1,398 @@
"""Statutory subject suggestions, mirror mapping and confirmation (B-44).
Subjects are stored from one participating company's perspective and the
other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two
companies can never record conflicting subjects. Bank summary/purpose text
only ever produces a *suggestion*; nothing here confirms a subject
automatically. Confirmation is an explicit administrator decision carrying
``expected_revision`` and an idempotency key.
"""
from __future__ import annotations
import json
import re
import sqlite3
from . import matching
SUBJECTS = ("receivable", "payable", "other_receivable", "other_payable")
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
MIRROR = {
"receivable": "payable",
"payable": "receivable",
"other_receivable": "other_payable",
"other_payable": "other_receivable",
}
SUBJECT_LABELS = {
"receivable": "应收",
"payable": "应付",
"other_receivable": "其他应收",
"other_payable": "其他应付",
}
_FULL_WIDTH = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz0123456789",
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz0123456789",
)
# Draft v1 dictionary. Exact-keyword matching only; every hit is a suggestion
# and never an automatic posting. Trade-type keywords are deliberately absent
# until the group supplies an approved dictionary (they always go to review).
_LOAN_LIKE = ("借款", "往来款", "资金往来", "临时借款", "资金调拨", "代垫", "垫付")
_REPAY_LIKE = ("还款", "归还借款", "归还往来款")
class SubjectConflictError(ValueError):
"""A stale revision or idempotency conflict (mapped to HTTP 409)."""
class SubjectInputError(ValueError):
"""Invalid input for a subject decision (mapped to HTTP 400/422)."""
def mirror_subject(subject_code: str) -> str:
if subject_code not in MIRROR:
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
return MIRROR[subject_code]
def subject_label(subject_code: str) -> str:
return SUBJECT_LABELS.get(subject_code, subject_code)
def _normalize(text: object) -> str:
return re.sub(r"[\s\ufeff]+", "", str(text or "").translate(_FULL_WIDTH))
def _bank_evidence_texts(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, str]:
"""Purpose/summary text of the B-43 source rows behind a bank event."""
row = connection.execute(
"""
SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
if row is None:
return {"purpose": "", "summary": ""}
decision = matching._current_decision_for_event(connection, row["bank_event_id"])
if decision is None:
return {"purpose": "", "summary": ""}
observations = matching._decision_observations(connection, decision["id"])
texts: dict[str, list[str]] = {"purpose": [], "summary": []}
for observation in observations:
source = connection.execute(
"SELECT purpose, summary FROM source_rows WHERE id = ?",
(observation["source_row_id"],),
).fetchone()
if source is None:
continue
for key in ("purpose", "summary"):
value = str(source[key] or "").strip()
if value:
texts[key].append(value)
return {
"purpose": " ".join(texts["purpose"]),
"summary": " ".join(texts["summary"]),
}
def compute_suggestions(
connection: sqlite3.Connection, ledger_event_id: int
) -> list[dict[str, object]]:
""" Deterministic draft suggestions for a pending event, never confirmation.
Purpose rules take precedence over summary rules. When both loan-like and
repay-like keywords match, both candidates are returned as a conflict for
the reviewer; no priority breaks the tie.
"""
from .ledger_events import current_revision
revision = current_revision(connection, ledger_event_id)
if revision is None or revision["state"] != "pending_subject":
return []
texts = _bank_evidence_texts(connection, ledger_event_id)
purpose = _normalize(texts["purpose"])
summary = _normalize(texts["summary"])
search = purpose or summary
payer = revision["payer_company_id"]
payee = revision["payee_company_id"]
loan_hit = next((word for word in _LOAN_LIKE if word in search), None)
repay_hit = next((word for word in _REPAY_LIKE if word in search), None)
suggestions: list[dict[str, object]] = []
if loan_hit:
suggestions.append(
{
"suggested_perspective_company_id": payer,
"suggested_subject_code": "other_receivable",
"reason": f"匹配建议词典「{loan_hit}」,建议付款方其他应收",
"rule_version": SUBJECT_RULE_VERSION,
"evidence": {
"keyword": loan_hit,
"matched_text": search,
"approved": False,
},
}
)
if repay_hit:
suggestions.append(
{
"suggested_perspective_company_id": payee,
"suggested_subject_code": "other_receivable",
"reason": f"匹配建议词典「{repay_hit}」,建议收款方其他应收",
"rule_version": SUBJECT_RULE_VERSION,
"evidence": {
"keyword": repay_hit,
"matched_text": search,
"approved": False,
},
}
)
return suggestions
def store_suggestions(connection: sqlite3.Connection, ledger_event_id: int) -> int:
"""Compute and append suggestions for a pending event. Returns count stored."""
from .ledger_events import current_revision
revision = current_revision(connection, ledger_event_id)
if revision is None or revision["state"] != "pending_subject":
return 0
stored = 0
for suggestion in compute_suggestions(connection, ledger_event_id):
from .db import utc_now
connection.execute(
"""
INSERT INTO ledger_subject_suggestions (
ledger_event_id, source_revision_id,
suggested_perspective_company_id, suggested_subject_code,
rule_version, evidence_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
ledger_event_id, revision["id"],
suggestion["suggested_perspective_company_id"],
suggestion["suggested_subject_code"],
suggestion["rule_version"],
json.dumps(suggestion.get("evidence", {}), ensure_ascii=False),
utc_now(),
),
)
stored += 1
return stored
def confirm_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
perspective_company_id: int,
subject_code: str,
reason: str,
expected_revision: int | None,
request_key: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
"""Confirm a subject, turning a pending event into a confirmed revision."""
from .ledger_events import append_revision, current_revision
reason = (reason or "").strip()
if not reason:
raise SubjectInputError("必须填写科目确认依据。")
if subject_code not in SUBJECTS:
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
if request_key:
existing = connection.execute(
"""
SELECT * FROM ledger_event_revisions
WHERE ledger_event_id = ? AND idempotency_key = ?
ORDER BY id LIMIT 1
""",
(ledger_event_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _revision_payload(connection, existing)
current = current_revision(connection, ledger_event_id)
if current is None:
raise SubjectConflictError("该事件不存在或没有当前修订。")
if current["state"] != "pending_subject":
raise SubjectConflictError("只有待确认科目的事件可以确认科目。")
# ``expected_revision`` may be the revision row id (what the API/UI
# sends as ``ledger_revision_id``) or the per-event sequence number;
# both identify the exact revision the client saw.
if expected_revision is not None and int(expected_revision) not in (
current["id"], current["revision"],
):
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
participants = {current["payer_company_id"], current["payee_company_id"]}
if perspective_company_id not in participants:
raise SubjectInputError("视角公司必须是事件参与方。")
revision_id = append_revision(
connection,
ledger_event_id,
state="confirmed",
effective_at=current["effective_at"],
amount=current["amount"],
currency=current["currency"],
payer_company_id=current["payer_company_id"],
payee_company_id=current["payee_company_id"],
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
posting_kind=current["posting_kind"],
reverses_ledger_event_id=current["reverses_ledger_event_id"],
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
evidence_json=current["evidence_json"],
idempotency_key=request_key,
actor=actor,
reason=reason,
supersedes_revision_id=current["id"],
)
row = connection.execute(
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
).fetchone()
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _revision_payload(connection, row)
def park_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
disposition: str,
reason: str,
expected_revision: int | None,
request_key: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
"""Record 退回/转异常 without confirming a statutory subject.
The event stays ``pending_subject`` so it never enters confirmed balances.
``exception`` is hidden from the active review queue; ``return`` remains
visible so the company can supplement materials.
"""
from .ledger_events import append_revision, current_revision
if disposition not in ("return", "exception"):
raise SubjectInputError("科目处理只能是退回或转异常。")
reason = (reason or "").strip()
if not reason:
raise SubjectInputError("必须填写处理依据。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
if request_key:
existing = connection.execute(
"""
SELECT * FROM ledger_event_revisions
WHERE ledger_event_id = ? AND idempotency_key = ?
ORDER BY id LIMIT 1
""",
(ledger_event_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _revision_payload(connection, existing)
current = current_revision(connection, ledger_event_id)
if current is None:
raise SubjectConflictError("该事件不存在或没有当前修订。")
if current["state"] != "pending_subject":
raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。")
if expected_revision is not None and int(expected_revision) not in (
current["id"], current["revision"],
):
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {}
evidence["admin_disposition"] = disposition
revision_id = append_revision(
connection,
ledger_event_id,
state="pending_subject",
effective_at=current["effective_at"],
amount=current["amount"],
currency=current["currency"],
payer_company_id=current["payer_company_id"],
payee_company_id=current["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
posting_kind=current["posting_kind"],
reverses_ledger_event_id=current["reverses_ledger_event_id"],
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
evidence_json=json.dumps(evidence, ensure_ascii=False),
idempotency_key=request_key,
actor=actor,
reason=reason,
supersedes_revision_id=current["id"],
)
row = connection.execute(
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
).fetchone()
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _revision_payload(connection, row)
def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]:
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],)
).fetchone()
return {
"ledger_event_id": revision["ledger_event_id"],
"revision_id": revision["id"],
"revision": revision["revision"],
"state": revision["state"],
"effective_at": revision["effective_at"],
"amount": revision["amount"],
"currency": revision["currency"],
"payer_company_id": revision["payer_company_id"],
"payee_company_id": revision["payee_company_id"],
"perspective_company_id": revision["perspective_company_id"],
"perspective_company_name": company["name"] if company else None,
"subject_code": revision["subject_code"],
"subject_label": subject_label(revision["subject_code"])
if revision["subject_code"]
else None,
"posting_kind": revision["posting_kind"],
"source_kind": revision["source_kind"],
"reason": revision["reason"],
"created_at": revision["created_at"],
}
+175
View File
@@ -0,0 +1,175 @@
"""Shared fixtures for B-44 ledger / manual / position tests."""
from __future__ import annotations
from pathlib import Path
import sqlite3
import tempfile
import unittest
from bank_importer import auth, matching, master_data
from bank_importer.db import connect, migrate, utc_now
class LedgerBase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
self.admin = self._admin()
self.company_a = self._company("甲公司")
self.company_b = self._company("乙公司")
self.company_c = self._company("丙公司")
self.account_a = self._approved_account(self.company_a, "6222000000000001")
self.account_b = self._approved_account(self.company_b, "6222000000000002")
def _admin(self):
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
return self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
def _company(self, name: str) -> int:
with self.connection:
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, utc_now(), utc_now()),
)
return int(cursor.lastrowid)
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
account = master_data.submit_bank_account(
self.connection, company_id=company_id, bank_name="中信银行",
account_type="基本户", account_number=number, start_date=start,
actor=None,
)
return master_data.review_bank_account(
self.connection, account["id"], "approve", None, self.admin,
effective_from=start,
)
def add_row(
self,
company_id: int,
*,
own_account: str,
cp_account: str | None = None,
income: str = "0",
expense: str = "0",
at: str = "2026-01-05T10:00:00",
currency: str = "CNY",
reference: str | None = None,
summary: str | None = None,
purpose: str | None = None,
) -> int:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(utc_now(), utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
VALUES (?, 'parsing', ?, ?, ?)
""",
(source_file_id, company_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, own_account, own_name, period_start, period_end,
transaction_count, warnings, created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, NULL, NULL, NULL, NULL, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense, balance,
own_account, own_name, counterparty_account, counterparty_name,
counterparty_bank, summary, purpose, reference, currency, created_at
) VALUES (?, 1, ?, ?, ?, NULL, ?, '测试', ?, '对方', NULL, ?, ?, ?, ?, ?)
""",
(
sheet_batch_id, at, income, expense, own_account,
cp_account, summary, purpose, reference, currency, utc_now(),
),
)
return int(cursor.lastrowid)
def pair(
self,
payer: int,
payee: int,
amount: str,
at: str = "2026-01-05T10:00:00",
*,
currency: str = "CNY",
summary: str = "借款",
purpose: str = "往来款",
) -> tuple[int, int]:
"""Create a mirrored A/B pair and reconcile into an eligible event."""
payer_account = self._account_of(payer)
payee_account = self._account_of(payee)
row_payer = self.add_row(
payer, own_account=payer_account, cp_account=payee_account,
expense=amount, at=at, currency=currency, summary=summary, purpose=purpose,
)
row_payee = self.add_row(
payee, own_account=payee_account, cp_account=payer_account,
income=amount, at=at.replace("T10:", "T11:"), currency=currency,
summary=summary, purpose=purpose,
)
matching.reconcile_rows(self.connection, [row_payer, row_payee])
return row_payer, row_payee
def _account_of(self, company_id: int) -> str:
if company_id == self.company_a:
return self.account_a["account_number"]
if company_id == self.company_b:
return self.account_b["account_number"]
return "6222000000000005"
def eligible(self) -> list[sqlite3.Row]:
return matching.eligible_intercompany_events(self.connection)
def ledger_events(self) -> list[sqlite3.Row]:
return self.connection.execute(
"SELECT * FROM ledger_events ORDER BY id"
).fetchall()
def current(self, ledger_event_id: int) -> sqlite3.Row | None:
return self.connection.execute(
"""
SELECT r.* FROM current_ledger_event_revisions c
JOIN ledger_event_revisions r ON r.id = c.revision_id
WHERE c.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
def position_events(self) -> list[sqlite3.Row]:
return self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
+35
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import sqlite3 import sqlite3
import unittest import unittest
from unittest import mock
from bank_importer import auth from bank_importer import auth
from bank_importer.db import connect, migrate, utc_now from bank_importer.db import connect, migrate, utc_now
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
self.assertIsNotNone(user) self.assertIsNotNone(user)
self.assertIsNone(reason) self.assertIsNone(reason)
def test_env_flag_parses_truthy_and_falsy_values(self) -> None:
cases = {
"1": True,
"true": True,
"YES": True,
" on ": True,
"": False,
"0": False,
"false": False,
"off": False,
None: False,
}
for value, expected in cases.items():
with self.subTest(value=value):
self.assertEqual(expected, auth._env_flag(value))
def test_rate_limit_enabled_by_default(self) -> None:
self.assertFalse(auth.RATE_LIMIT_DISABLED)
def test_env_switch_disables_rate_limit_but_not_credential_checks(self) -> None:
self.create_company_user()
with mock.patch.object(auth, "RATE_LIMIT_DISABLED", True):
for _ in range(auth.RATE_LIMIT_MAX_FAILURES + 2):
user, reason = auth.authenticate(
self.connection, "cashier-a", "Wrong999", "10.0.0.1"
)
self.assertIsNone(user)
self.assertEqual("bad_credentials", reason)
user, reason = auth.authenticate(
self.connection, "cashier-a", "Init1234", "10.0.0.1"
)
self.assertIsNotNone(user)
self.assertIsNone(reason)
class SessionTests(AuthTestCase): class SessionTests(AuthTestCase):
def test_create_and_resolve_roundtrip(self) -> None: def test_create_and_resolve_roundtrip(self) -> None:
+221
View File
@@ -0,0 +1,221 @@
"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
sync_playwright = None
class ConfirmStatusSourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
self.assertIn('notice.classList.toggle("success", pending === 0)', js)
# 文案:完成=已完成,不把待确认一并改绿
self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("--success:", css)
self.assertIn("--warn:", css)
self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
self.assertIn(".pill-warn { background: var(--warn-soft);", css)
self.assertIn(".notice.success { background: var(--success-soft);", css)
self.assertIn(".notice.warn { background: var(--warn-soft);", css)
def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('id="workspaceConfirmState"', html)
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=12", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
def _extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError(f"未能截取 function {name}")
def _chromium_available() -> bool:
"""本机缺 libatk 等系统库时 chromium 无法启动。"""
try:
import ctypes.util
return bool(ctypes.util.find_library("atk-1.0"))
except Exception:
return False
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过浏览器色值校验")
class ConfirmStatusDomTests(unittest.TestCase):
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
app_js = (WEB / "app.js").read_text(encoding="utf-8")
cls.inject_js = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
_extract_fn(app_js, "formatWorkspaceAmount"),
_extract_fn(app_js, "applyCompanyWorkspace"),
]
)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open_fixture(self, page):
page.goto(f"{self.base}/design-system.css", wait_until="domcontentloaded")
page.set_content(
f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body data-portal="company">
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<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" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<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>
</div>
<div class="notice warn" id="blocking-notice">
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
<span class="tab-count" id="count-match">0</span>
<nav class="side-nav"><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</body></html>""",
wait_until="domcontentloaded",
)
page.add_script_tag(content=self.inject_js)
page.wait_for_function("() => typeof applyCompanyWorkspace === 'function'")
@staticmethod
def _colors(page) -> dict:
return page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
const notice = document.getElementById('blocking-notice');
const cs = (el) => getComputedStyle(el);
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: cs(state).color,
pillClass: pill.className,
pillColor: cs(pill).color,
pillText: pill.textContent,
noticeClass: notice.className,
};
}"""
)
def test_pending_stays_warn_completed_turns_success(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open_fixture(page)
page.evaluate(
"""() => applyCompanyWorkspace({
pending_unilateral: 2,
pending_total: 2,
unilateral_events: [
{event_id: 1, amount: '100.00', currency: 'CNY',
counterparty_company_name: '', effective_at: '2026-07-01'}
]
})"""
)
pending = self._colors(page)
self.assertIn("doing", pending["stepClass"])
self.assertNotIn("done", pending["stepClass"].split())
self.assertIn("pill-warn", pending["pillClass"])
self.assertNotIn("pill-success", pending["pillClass"])
self.assertIn("warn", pending["noticeClass"].split())
self.assertIn("待处理", pending["stateText"])
self.assertIn("待处理", pending["pillText"])
page.evaluate(
"""() => applyCompanyWorkspace({
pending_unilateral: 0,
pending_total: 0,
unilateral_events: []
})"""
)
done = self._colors(page)
self.assertIn("done", done["stepClass"])
self.assertNotIn("doing", done["stepClass"].split())
self.assertIn("pill-success", done["pillClass"])
self.assertNotIn("pill-warn", done["pillClass"])
self.assertIn("success", done["noticeClass"].split())
self.assertEqual("已完成", done["stateText"])
self.assertEqual("已完成", done["pillText"])
self.assertNotEqual(pending["stateColor"], done["stateColor"])
self.assertNotEqual(pending["pillColor"], done["pillColor"])
browser.close()
if __name__ == "__main__":
unittest.main()
+500
View File
@@ -0,0 +1,500 @@
"""HTTP tests for company intercompany events list + CSV export (HEL-176).
Covers combined filters, keyset pagination, empty state, lateral access,
forged company_id, ID guessing, export isolation and audit logging.
"""
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 CompanyIntercompanyEventsTests(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._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.initial_passwords: dict[str, str] = {}
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.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
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[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> 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, client: Client) -> int:
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
return account_id
def _upload_and_confirm(self, client: 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 _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _seed_mixed(self) -> None:
"""Paired A→B 100, B→A 40, locked A→B 25, pending A→B 7, B→C 200."""
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
self._lock_single("25.00", "2026-02-01 10:00:00")
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
def _events(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/events?{query}")
return status, as_json(data) if data else {}
def _export(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
return client.get(f"/api/company/intercompany/export.csv?{query}")
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._events(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id_on_events_and_export(self) -> None:
status, payload = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_b}",
)
self.assertEqual(400, status, payload)
status, _, data = self._export(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_a}",
)
self.assertEqual(400, status, data)
def test_rejects_bad_filters(self) -> None:
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=sideways"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=maybe"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-13-40&to=2026-12-31"
)
self.assertEqual(400, status, payload)
def test_empty_window(self) -> None:
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual([], payload["events"])
self.assertFalse(payload["has_more"])
self.assertIsNone(payload["next_cursor"])
# ------------------------------------------------------------------
# Combined filters + confirmed/pending separation
# ------------------------------------------------------------------
def test_combined_filters_and_state_split(self) -> None:
self._seed_mixed()
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
events = payload["events"]
# A sees: out 100, in 40, locked out 25, pending out 7 — not B↔C 200
self.assertEqual(4, len(events))
amounts = {e["amount"] for e in events}
self.assertEqual({"100.00", "40.00", "25.00", "7.00"}, amounts)
for event in events:
self.assertNotEqual(self.company_c, event["counterparty_company_id"])
self.assertIn(event["state"], ("confirmed", "pending"))
self.assertIn(event["direction"], ("out", "in"))
status, confirmed = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed"
)
self.assertEqual(200, status, confirmed)
self.assertEqual(3, len(confirmed["events"]))
self.assertTrue(all(e["state"] == "confirmed" for e in confirmed["events"]))
self.assertNotIn("7.00", {e["amount"] for e in confirmed["events"]})
status, pending = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(200, status, pending)
self.assertEqual(1, len(pending["events"]))
self.assertEqual("7.00", pending["events"][0]["amount"])
self.assertEqual("pending", pending["events"][0]["state"])
status, outs = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=out&state=confirmed"
)
self.assertEqual(200, status, outs)
self.assertEqual({"100.00", "25.00"}, {e["amount"] for e in outs["events"]})
self.assertTrue(all(e["direction"] == "out" for e in outs["events"]))
status, by_cp = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_b}&state=confirmed",
)
self.assertEqual(200, status, by_cp)
self.assertEqual(3, len(by_cp["events"]))
# Date window excludes Feb locked/pending
status, jan = self._events(
self.cashier_a, "from=2026-01-01&to=2026-01-31&state=confirmed"
)
self.assertEqual(200, status, jan)
self.assertEqual({"100.00", "40.00"}, {e["amount"] for e in jan["events"]})
def test_keyset_pagination_no_dup_no_gap(self) -> None:
# Three confirmed A→B outs on distinct days
for i, amount in enumerate(("11.00", "12.00", "13.00", "14.00", "15.00")):
day = 5 + i
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, f"2026-01-{day:02d} 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, amount, f"2026-01-{day:02d} 11:00:00")],
)
status, page1 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out&limit=2",
)
self.assertEqual(200, status, page1)
self.assertEqual(2, len(page1["events"]))
self.assertTrue(page1["has_more"])
self.assertIsNotNone(page1["next_cursor"])
status, page2 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page1['next_cursor']}",
)
self.assertEqual(200, status, page2)
self.assertEqual(2, len(page2["events"]))
self.assertTrue(page2["has_more"])
status, page3 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page2['next_cursor']}",
)
self.assertEqual(200, status, page3)
self.assertEqual(1, len(page3["events"]))
self.assertFalse(page3["has_more"])
self.assertIsNone(page3["next_cursor"])
ids = [e["event_id"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(5, len(ids))
self.assertEqual(len(ids), len(set(ids)))
# Descending by effective_at then event_id
amounts = [e["amount"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(["15.00", "14.00", "13.00", "12.00", "11.00"], amounts)
# ------------------------------------------------------------------
# Isolation / ID guess / detail reuse
# ------------------------------------------------------------------
def test_lateral_isolation_and_id_guess_404(self) -> None:
self._seed_mixed()
status, payload_a = self._events(self.cashier_a)
self.assertEqual(200, status, payload_a)
a_ids = {e["event_id"] for e in payload_a["events"]}
status, payload_c = self._events(self.cashier_c)
self.assertEqual(200, status, payload_c)
# C only participates in B↔C 200
self.assertTrue(payload_c["events"])
for event in payload_c["events"]:
self.assertEqual("200.00", event["amount"])
self.assertNotIn(event["event_id"], a_ids)
# C guessing A's event id via transfer-events detail → 404
a_event_id = next(iter(a_ids))
status, _, data = self.cashier_c.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(404, status, data)
# A can open own event; counterparty account masked; only own observations
status, _, data = self.cashier_a.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
if detail.get("counterparty") and "account_number_masked" in detail["counterparty"]:
masked = detail["counterparty"]["account_number_masked"]
self.assertTrue(str(masked).startswith("****"))
self.assertNotIn(ACCOUNT_B, masked)
for obs in detail["observations"]:
self.assertEqual(self.company_a, obs["batch_company_id"])
self.assertNotIn(ACCOUNT_B, json.dumps(obs, ensure_ascii=False))
# Filtering by counterparty C still cannot leak B↔C into A's list
status, filtered = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_c}",
)
self.assertEqual(200, status, filtered)
self.assertEqual([], filtered["events"])
def test_b44_events_path_still_works_with_cutoff(self) -> None:
# Without HEL-176 discriminators, /events stays on B-44 ledger list.
status, _, data = self.cashier_a.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertIn("items", payload)
self.assertNotIn("events", payload)
# ------------------------------------------------------------------
# Export: confirmed only + audit + isolation
# ------------------------------------------------------------------
def test_export_confirmed_only_isolated_and_audited(self) -> None:
self._seed_mixed()
status, headers, data = self._export(self.cashier_a)
self.assertEqual(200, status, data)
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.assertGreaterEqual(len(lines), 2)
body = "\n".join(lines[1:])
self.assertIn("100.00", body)
self.assertIn("40.00", body)
self.assertIn("25.00", body)
self.assertNotIn("7.00", body) # pending excluded
self.assertNotIn("200.00", body) # B↔C excluded
# Explicit pending state rejected
status, _, data = self._export(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(400, status, data)
# C export must not contain A's amounts
status, _, data = self._export(self.cashier_c)
self.assertEqual(200, status, data)
text_c = data.decode("utf-8-sig")
self.assertNotIn("100.00", text_c)
self.assertNotIn("25.00", text_c)
self.assertIn("200.00", text_c)
status, _, data = self.admin.get("/api/admin/audit-log?limit=50")
self.assertEqual(200, status, data)
actions = [row["action"] for row in as_json(data)["entries"]]
self.assertIn("export_intercompany_csv", actions)
export_rows = [
row
for row in as_json(data)["entries"]
if row["action"] == "export_intercompany_csv"
]
self.assertTrue(export_rows)
self.assertTrue(
any(f"company:{self.company_a}" == row.get("target") for row in export_rows)
)
self.assertTrue(any("state:confirmed" in (row.get("detail") or "") for row in export_rows))
if __name__ == "__main__":
unittest.main()
+362
View File
@@ -0,0 +1,362 @@
"""HTTP tests for GET /api/company/intercompany/summary (HEL-175).
Covers session-scoped company_id, forged company_id rejection, confirmed vs
pending separation, Decimal net math, dual-company isolation and empty data.
"""
from __future__ import annotations
import io
import json
import os
from decimal import Decimal
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 CompanyIntercompanySummaryTests(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._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.initial_passwords: dict[str, str] = {}
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.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
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[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> 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, client: Client) -> int:
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
return account_id
def _upload_and_confirm(self, client: 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 _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
"""A-side only upload → admin locks as intercompany single."""
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _summary(self, client: Client, query: str = "as_of=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/summary?{query}")
return status, as_json(data) if data else {}
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._summary(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id(self) -> None:
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_b}"
)
self.assertEqual(400, status, payload)
self.assertIn("company_id", payload.get("message", ""))
def test_rejects_own_company_id_param(self) -> None:
# Even matching the session company is forbidden.
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_a}"
)
self.assertEqual(400, status, payload)
def test_rejects_bad_as_of(self) -> None:
status, payload = self._summary(self.cashier_a, "as_of=2026-13-40")
self.assertEqual(400, status, payload)
def test_anonymous_is_unauthorized(self) -> None:
anon = Client("127.0.0.1", self.port)
status, _, data = anon.get("/api/company/intercompany/summary?as_of=2026-12-31")
self.assertIn(status, (401, 403))
# ------------------------------------------------------------------
# Empty / confirmed math / pending isolation
# ------------------------------------------------------------------
def test_empty_window_returns_zeros(self) -> None:
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual(self.company_a, payload["own_company"]["id"])
self.assertFalse(payload["window"]["has_opening"])
self.assertIsNone(payload["window"]["opening"])
self.assertEqual("0.00", payload["confirmed"]["outflow_total"])
self.assertEqual("0.00", payload["confirmed"]["inflow_total"])
self.assertEqual("0.00", payload["confirmed"]["net_change"])
self.assertEqual(0, payload["pending"]["count"])
self.assertEqual([], payload["counterparties"])
# Amounts must be strings, never floats.
self.assertIsInstance(payload["confirmed"]["net_change"], str)
self.assertNotIsInstance(payload["confirmed"]["net_change"], float)
def test_paired_locked_pending_math_and_isolation(self) -> None:
# Paired A→B 100 + B→A 40 → A net outflow 60
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
# Locked single A→B 25 (confirmed)
self._lock_single("25.00", "2026-02-01 10:00:00")
# Pending unilateral A→B 7 (internal_single, not confirmed)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
# B↔C paired 200 — must not appear in A's summary
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
confirmed = payload["confirmed"]
self.assertEqual("125.00", confirmed["outflow_total"]) # 100 + 25
self.assertEqual(2, confirmed["outflow_count"])
self.assertEqual("40.00", confirmed["inflow_total"])
self.assertEqual(1, confirmed["inflow_count"])
self.assertEqual("85.00", confirmed["net_change"]) # 125 - 40
self.assertEqual("receivable", confirmed["net_direction"])
# Pending tip only — never folded into confirmed totals
self.assertEqual(1, payload["pending"]["count"])
self.assertEqual("7.00", payload["pending"]["amount_total"])
# Decimal identity: net = outflow - inflow, no float drift
net = Decimal(confirmed["net_change"])
self.assertEqual(
Decimal(confirmed["outflow_total"]) - Decimal(confirmed["inflow_total"]),
net,
)
counterparties = {row["company_id"]: row for row in payload["counterparties"]}
self.assertIn(self.company_b, counterparties)
self.assertNotIn(self.company_c, counterparties)
row_b = counterparties[self.company_b]
self.assertEqual("125.00", row_b["confirmed_outflow"])
self.assertEqual("40.00", row_b["confirmed_inflow"])
self.assertEqual("85.00", row_b["net"])
self.assertEqual(1, row_b["pending_count"])
# B must not see C-only? B sees C; A must not see B's C totals via forgery
status_b, payload_b = self._summary(self.cashier_b)
self.assertEqual(200, status_b, payload_b)
cps_b = {row["company_id"] for row in payload_b["counterparties"]}
self.assertIn(self.company_c, cps_b)
# A's view still excludes C
self.assertNotIn(self.company_c, counterparties)
# Same event counted once: eligible event count equals outflow+inflow counts
self.assertEqual(
confirmed["outflow_count"] + confirmed["inflow_count"],
3,
)
def test_company_b_cannot_see_a_only_pending(self) -> None:
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "9.00", "2026-04-01 10:00:00")],
)
status_a, payload_a = self._summary(self.cashier_a)
self.assertEqual(200, status_a, payload_a)
self.assertEqual(1, payload_a["pending"]["count"])
status_c, payload_c = self._summary(self.cashier_c)
self.assertEqual(200, status_c, payload_c)
self.assertEqual(0, payload_c["pending"]["count"])
self.assertEqual("0.00", payload_c["confirmed"]["outflow_total"])
self.assertEqual([], payload_c["counterparties"])
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
"""HEL-177: 公司端转账往来页面(方案 A)结构与接口契约。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
class TransfersPageSourceContractTests(unittest.TestCase):
def test_company_html_has_nav_overview_and_layers(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('data-view="transfers"', html)
self.assertIn("转账往来", html)
self.assertIn('id="workspaceTransfersCard"', html)
self.assertIn("转账往来概览", html)
self.assertIn('data-page="transfers"', html)
self.assertIn('id="transfersOverviewLayer"', html)
self.assertIn('id="transfersDetailLayer"', html)
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=12", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
reconcile = html.index('data-view="reconcile"')
self.assertLess(flows, transfers)
self.assertLess(transfers, reconcile)
def test_app_js_wires_real_apis_no_demo_totals(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('transfers: "转账往来"', js)
self.assertIn("/api/company/intercompany/summary", js)
self.assertIn("/api/company/intercompany/events", js)
self.assertIn("/api/company/intercompany/export.csv", js)
self.assertIn("/api/company/transfer-events/", js)
self.assertIn("function loadTransfersSummary", js)
self.assertIn("function openTransfersDetail", js)
self.assertIn("function openTransferEvidence", js)
self.assertIn("function initTransfers", js)
self.assertIn("initTransfers()", js)
self.assertIn("期间净变动", js)
self.assertIn("has_opening", js)
# 不得把「期末余额」写死为无期初时的标签
self.assertNotRegex(js, r'netLabelForWindow[^{]+{[^}]*return "期末余额"')
def test_design_system_scopes_info_to_transfers_module(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn('a[data-view="transfers"].active', css)
self.assertIn("var(--info-soft)", css)
self.assertIn('[data-page="transfers"] .btn-primary', css)
self.assertIn(".xfer-split", css)
self.assertIn(".xfer-split-pane.confirmed", css)
self.assertIn(".xfer-split-pane.pending", css)
# 无新色硬编码
self.assertNotRegex(css, r"\.xfer-split[^\{]*\{[^}]*#[0-9a-fA-F]{3,8}")
class TransfersDetailObservationContractTests(unittest.TestCase):
def test_server_exposes_batch_fields_on_observations(self) -> None:
server = (ROOT / "server.py").read_text(encoding="utf-8")
self.assertIn("import_batch_id", server)
self.assertIn("original_filename", server)
self.assertIn("counterparty_account_masked", server)
def _chromium_available() -> bool:
try:
import ctypes.util
return bool(ctypes.util.find_library("atk-1.0"))
except Exception:
return False
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟")
@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟")
class TransfersPageLayoutSmokeTests(unittest.TestCase):
"""静态壳:360 / 820 / 1440 无横向溢出。"""
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def test_transfers_shell_no_horizontal_overflow(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
for width in (360, 820, 1440):
page.set_viewport_size({"width": width, "height": 900})
page.set_content(
html.replace('src="app.js?v=12"', 'src=""'),
base_url=self.base,
)
page.evaluate(
"""() => {
document.querySelectorAll('.app-view').forEach((el) => {
el.classList.toggle('is-active', el.dataset.page === 'transfers');
});
const data = document.getElementById('transfersData');
const loading = document.getElementById('transfersLoading');
if (loading) loading.hidden = true;
if (data) data.hidden = false;
}"""
)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
self.assertFalse(overflow, f"{width}px 出现横向溢出")
browser.close()
if __name__ == "__main__":
unittest.main()
+313
View File
@@ -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()
+498
View File
@@ -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()
+306
View File
@@ -0,0 +1,306 @@
"""B-44 ledger event layer tests: bank reconciliation, revision chain,
reversal/adjustment, projection rebuild and immutability boundaries."""
from __future__ import annotations
from decimal import Decimal
import sqlite3
import unittest
from bank_importer import ledger_events, matching, subjects
from ledger_helpers import LedgerBase
class BankReconcileTests(LedgerBase):
def test_paired_eligible_event_creates_pending_subject_only(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
self.assertEqual(1, len(self.eligible()))
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, stats["created"])
events = self.ledger_events()
self.assertEqual(1, len(events))
revision = self.current(events[0]["id"])
self.assertEqual("pending_subject", revision["state"])
self.assertEqual(Decimal("100.00"), Decimal(revision["amount"]))
self.assertEqual("CNY", revision["currency"])
# Nothing confirmed yet -> no position event.
self.assertEqual([], self.position_events())
# The event is unresolved as subject_review.
from bank_importer import positions
unresolved = positions.unresolved_for_company(self.connection, self.company_a, "2026-12-31")
self.assertEqual("100.00", unresolved["by_reason"]["subject_review"]["gross_amount"])
def test_unlocked_single_never_enters_ledger(self) -> None:
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [1])
self.assertEqual([], self.eligible())
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(0, stats["created"])
self.assertEqual([], self.ledger_events())
def test_same_company_transfer_never_enters_ledger(self) -> None:
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000001", expense="100.00",
)
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [1, 2])
eligible = self.eligible()
self.assertEqual(0, len([e for e in eligible if e["payer_company_id"] == e["payee_company_id"]]))
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(0, stats["created"])
def test_reconcile_is_idempotent(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
events = self.ledger_events()
self.assertEqual(1, len(events))
before_revisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
).fetchone()["n"]
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, stats["unchanged"])
after_revisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
).fetchone()["n"]
self.assertEqual(before_revisions, after_revisions)
def test_reconcile_picks_up_newly_linked_rows_as_new_pending_event(self) -> None:
# A single observation locked as intercompany -> pending ledger event.
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
matching.reconcile_rows(self.connection, [row_a])
single = self.connection.execute(
"SELECT * FROM current_transfer_decisions"
).fetchone()
event_id = single["event_id"]
decision = self.connection.execute(
"SELECT * FROM transfer_match_decisions WHERE id = ?",
(single["decision_id"],),
).fetchone()
matching.apply_manual_decision(
self.connection, event_id, "assign_participant",
reason="函证确认对方",
expected_revision=decision["revision"], request_key="assign-1",
actor=self.admin,
participant={"role": "payee", "company_id": self.company_b},
)
self.assertEqual(1, len(self.eligible()))
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, len(self.ledger_events()))
# The administrator links two fresh rows with a different amount onto a
# new canonical event; reconcile creates a second pending event.
row_a2 = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="150.00",
at="2026-02-01T10:00:00",
)
row_b2 = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="150.00",
at="2026-02-01T11:00:00",
)
current_row = self.connection.execute(
"SELECT d.* FROM current_transfer_decisions c "
"JOIN transfer_match_decisions d ON d.id = c.decision_id "
"WHERE c.event_id = ?",
(event_id,),
).fetchone()
matching.apply_manual_decision(
self.connection, event_id, "link_rows",
reason="补录双边流水",
expected_revision=current_row["revision"], request_key="link-1",
actor=self.admin, source_row_ids=[row_a2, row_b2],
)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
amounts = sorted(
row["amount"] for row in self.connection.execute(
"SELECT amount FROM ledger_event_revisions WHERE state = 'pending_subject'"
).fetchall()
)
self.assertEqual(["100.00", "150.00"], amounts)
def test_confirm_subject_enters_position_view(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款建议其他应收", expected_revision=1, request_key="k1",
actor=self.admin,
)
positions = self.position_events()
self.assertEqual(1, len(positions))
self.assertEqual("other_receivable", positions[0]["subject_code"])
self.assertEqual(self.company_a, positions[0]["perspective_company_id"])
def test_confirm_stale_revision_conflicts(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认应收", expected_revision=1, request_key="k1",
actor=self.admin,
)
with self.assertRaises(subjects.SubjectConflictError):
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="重复确认", expected_revision=1, request_key="k2",
actor=self.admin,
)
def test_reversal_mirrors_direction_and_subject(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="k1", actor=self.admin,
)
reversal_id, _ = ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
reason="冲销错误确认", actor=self.admin,
)
reversal = self.current(reversal_id)
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(self.company_b, reversal["payer_company_id"])
self.assertEqual(self.company_a, reversal["payee_company_id"])
self.assertEqual("other_payable", reversal["subject_code"])
self.assertEqual(self.company_b, reversal["perspective_company_id"])
# Original event stays confirmed and still in the position view.
self.assertEqual("confirmed", self.current(event_id)["state"])
positions = self.position_events()
self.assertEqual(2, len(positions))
signed = sum(
Decimal(position["amount"]) * (
1 if position["payer_company_id"] == self.company_a else -1
)
for position in positions
)
self.assertEqual(Decimal("0"), signed)
def test_evidence_rows_stay_immutable_under_reversal(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
)
before = self.connection.execute(
"SELECT income, expense FROM source_rows ORDER BY id"
).fetchall()
ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
reason="冲销", actor=self.admin,
)
after = self.connection.execute(
"SELECT income, expense FROM source_rows ORDER BY id"
).fetchall()
self.assertEqual(before, after)
def test_reversal_idempotent_guard(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
)
# Simulate the B-43 event leaving the eligible set.
with self.connection:
self.connection.execute(
"UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
(self.eligible()[0]["event_id"],),
)
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(1, stats["reversal"])
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(1, reversals)
# Re-run does not create a second reversal.
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
self.assertEqual(0, stats["reversal"])
class ProjectionTests(LedgerBase):
def test_rebuild_current_projection(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
)
self.connection.execute("DELETE FROM current_ledger_event_revisions")
rebuilt = ledger_events.rebuild_current_ledger_projection(self.connection)
self.assertGreaterEqual(rebuilt, 1)
revision = self.current(event_id)
self.assertEqual("confirmed", revision["state"])
self.assertEqual("receivable", revision["subject_code"])
class SubjectSuggestionTests(LedgerBase):
def test_mirror_mapping_is_symmetric(self) -> None:
for subject, mirror in (
("receivable", "payable"), ("payable", "receivable"),
("other_receivable", "other_payable"), ("other_payable", "other_receivable"),
):
self.assertEqual(mirror, subjects.mirror_subject(subject))
self.assertEqual(subject, subjects.mirror_subject(mirror))
def test_loan_keyword_suggests_payer_other_receivable(self) -> None:
self.pair(self.company_a, self.company_b, "100.00", summary="借款", purpose="往来款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
suggestions = subjects.compute_suggestions(self.connection, event_id)
self.assertEqual(1, len(suggestions))
self.assertEqual(self.company_a, suggestions[0]["suggested_perspective_company_id"])
self.assertEqual("other_receivable", suggestions[0]["suggested_subject_code"])
self.assertFalse(suggestions[0]["evidence"]["approved"])
def test_repay_keyword_suggests_payee(self) -> None:
self.pair(self.company_a, self.company_b, "100.00", summary="归还往来款", purpose="还款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
suggestions = subjects.compute_suggestions(self.connection, event_id)
self.assertEqual(1, len(suggestions))
self.assertEqual(self.company_b, suggestions[0]["suggested_perspective_company_id"])
def test_trade_keyword_never_suggests(self) -> None:
# Trade vocabulary needs group approval; no dictionary hit -> review.
self.pair(self.company_a, self.company_b, "100.00", summary="货款", purpose="采购货款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
def test_name_alone_never_confirms(self) -> None:
# A name match on the counterparty cannot decide the subject.
self.pair(self.company_a, self.company_b, "100.00", summary="某客户往来", purpose=None)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
current = self.current(event_id)
self.assertEqual("pending_subject", current["state"])
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
if __name__ == "__main__":
unittest.main()
+607
View File
@@ -0,0 +1,607 @@
"""B-44 manual record tests: submit idempotency, approve new/link, return,
exception, reverse, deduplication, idempotent replay and concurrency."""
from __future__ import annotations
from decimal import Decimal
import threading
import unittest
from bank_importer import ledger_events, manual_records, matching, subjects
from ledger_helpers import LedgerBase
class SubmitTests(LedgerBase):
def submit(self, **overrides):
params = dict(
company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00",
direction="incoming",
amount="50.00",
currency="CNY",
funding_source="other",
requested_subject="other_receivable",
request_key="mr-key",
actor=self.admin,
summary="归还往来款",
)
params.update(overrides)
return manual_records.submit(self.connection, **params)
def test_submit_creates_pending_decision(self) -> None:
payload = self.submit()
self.assertEqual("pending", payload["state"])
self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
self.assertEqual(1, payload["decision_revision"])
self.assertEqual([], payload["candidates"])
def test_submit_idempotent_on_request_key(self) -> None:
first = self.submit()
second = self.submit()
self.assertEqual(first["id"], second["id"])
self.assertTrue(second["idempotent_replay"])
decisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_record_decisions"
).fetchone()["n"]
self.assertEqual(1, decisions)
def test_submit_validations(self) -> None:
cases = (
({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
({"amount": "0"}, "必须大于零"),
({"amount": "abc"}, "十进制"),
({"funding_source": "credit_card"}, "资金来源"),
({"requested_subject": "equity"}, "科目"),
({"request_key": ""}, "request_key"),
({"counterparty_company_id": 9999}, "公司不存在"),
)
for overrides, expected in cases:
with self.subTest(overrides=overrides):
with self.assertRaises(manual_records.ManualInputError) as ctx:
self.submit(**overrides)
self.assertIn(expected, str(ctx.exception))
def test_bank_account_must_belong_to_submitting_company(self) -> None:
with self.assertRaises(manual_records.ManualInputError):
self.submit(
funding_source="approved_bank_account",
bank_account_id=self.account_b["id"],
)
def test_related_source_row_never_modified(self) -> None:
row_id = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
payload = self.submit(related_source_row_id=row_id)
self.assertEqual(row_id, payload["related_source_row_id"])
with self.assertRaises(Exception):
self.connection.execute(
"UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
)
self.connection.rollback()
class DecisionTests(LedgerBase):
def setUp(self) -> None:
super().setUp()
self.record = manual_records.submit(
self.connection,
company_id=self.company_a, counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-1",
actor=self.admin, summary="归还往来款",
)
def decide(self, action, **overrides):
params = dict(
record_id=self.record["id"],
action=action,
reason="管理员审核",
expected_decision_id=self.record["decision_id"],
request_key="dec-key",
actor=self.admin,
)
params.update(overrides)
return manual_records.decide(self.connection, **params)
def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
outcome = self.decide("approve_new")
self.assertEqual("approved", outcome["state"])
event_id = outcome["ledger_event_id"]
revision = ledger_events.current_revision(self.connection, event_id)
self.assertEqual("confirmed", revision["state"])
# incoming 50 from A's perspective: B is the payer, A the payee.
self.assertEqual(self.company_b, revision["payer_company_id"])
self.assertEqual(self.company_a, revision["payee_company_id"])
self.assertEqual(self.company_a, revision["perspective_company_id"])
self.assertEqual("other_receivable", revision["subject_code"])
# Exactly one position impact.
positions = self.connection.execute(
"SELECT * FROM eligible_position_events"
).fetchall()
self.assertEqual(1, len(positions))
def test_approve_new_replay_is_idempotent(self) -> None:
first = self.decide("approve_new")
second = self.decide("approve_new")
self.assertEqual(first["decision_id"], second["decision_id"])
events = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_events"
).fetchone()["n"]
self.assertEqual(1, events)
def test_return_and_exception_never_produce_balance(self) -> None:
for action in ("return", "exception"):
with self.subTest(action=action):
fresh = manual_records.submit(
self.connection,
company_id=self.company_a, counterparty_company_id=self.company_b,
occurred_at="2026-01-11T09:00:00", direction="incoming",
amount="10.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key=f"mr-{action}",
actor=self.admin,
)
outcome = manual_records.decide(
self.connection, fresh["id"], action,
reason="材料不足" if action == "return" else "转入异常",
expected_decision_id=fresh["decision_id"],
request_key=f"dec-{action}", actor=self.admin,
)
expected_state = "returned" if action == "return" else "exception"
self.assertEqual(expected_state, outcome["state"])
self.assertEqual(0, self.connection.execute(
"SELECT COUNT(*) AS n FROM eligible_position_events"
).fetchone()["n"])
def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
approved = self.decide("approve_new")
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录,冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
rows = self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
self.assertEqual(2, len(rows))
original, reversal = rows
self.assertEqual("normal", original["posting_kind"])
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
# Net position is zero.
signed = Decimal(original["amount"]) * (
1 if original["payer_company_id"] == self.company_a else -1
) + Decimal(reversal["amount"]) * (
1 if reversal["payer_company_id"] == self.company_a else -1
)
self.assertEqual(Decimal("0"), signed)
def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
# Build an eligible bank event and confirm its subject.
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
bank_ledger_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, bank_ledger_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="subj-1",
actor=self.admin,
)
before = len(self.position_events())
outcome = manual_records.decide(
self.connection, self.record["id"], "approve_link",
reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
request_key="dec-link", actor=self.admin,
target_ledger_event_id=bank_ledger_id,
)
self.assertEqual("approved", outcome["state"])
self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
after = len(self.position_events())
self.assertEqual(before, after)
sources = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(self.record["id"],),
).fetchall()
self.assertEqual(1, len(sources))
self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])
def test_approve_link_requires_target(self) -> None:
with self.assertRaises(manual_records.ManualInputError):
self.decide("approve_link")
def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
bank_ledger_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, bank_ledger_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="subj-1",
actor=self.admin,
)
approved = manual_records.decide(
self.connection, self.record["id"], "approve_link",
reason="同源", expected_decision_id=self.record["decision_id"],
request_key="dec-link", actor=self.admin,
target_ledger_event_id=bank_ledger_id,
)
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="撤销关联", expected_decision_id=approved["decision_id"],
request_key="dec-unlink", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
# No reversal event is created for a linked claim.
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(0, reversals)
# The bank impact is untouched.
self.assertEqual(1, len(self.position_events()))
def test_stale_expected_decision_conflicts(self) -> None:
approved = self.decide("approve_new")
with self.assertRaises(manual_records.ManualConflictError):
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="冲销", expected_decision_id=self.record["decision_id"],
request_key="dec-stale", actor=self.admin,
)
def test_returned_record_cannot_be_reversed(self) -> None:
returned = self.decide("return", reason="材料不足")
with self.assertRaises(manual_records.ManualConflictError):
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="冲销", expected_decision_id=returned["decision_id"],
request_key="dec-x", actor=self.admin,
)
def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None:
# Reversing with an independent effective date must keep the original
# impact for cutoffs before it and net it to zero only on/after it —
# never rewrite the historical period retroactively.
from bank_importer import positions
approved = self.decide("approve_new")
event_id = approved["ledger_event_id"]
self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10])
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-date", actor=self.admin,
effective_at="2026-06-15",
)
reversal = self.connection.execute(
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()
self.assertEqual("2026-06-15", reversal["effective_at"][:10])
before = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-05-31"
)
after = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in before["items"]:
if item["company_id"] == self.company_a:
self.assertEqual("-50.00", item["result"]["signed_amount"])
for item in after["items"]:
if item["company_id"] == self.company_a:
self.assertEqual("0.00", item["result"]["signed_amount"])
def test_reverse_defaults_to_approval_business_day(self) -> None:
from datetime import datetime, timedelta, timezone
from bank_importer import positions
approved = self.decide("approve_new")
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-default", actor=self.admin,
)
today = datetime.now(timezone(timedelta(hours=8))).date().isoformat()
reversal = self.connection.execute(
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()
self.assertEqual(today, reversal["effective_at"][:10])
# The day before the approval business day keeps the original impact.
before = positions.company_balances(
self.connection, from_="2026-01-01",
cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(),
)
for item in before["items"]:
if item["company_id"] == self.company_a:
self.assertEqual("-50.00", item["result"]["signed_amount"])
def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None:
# approve_new created the event; a second manual record later links
# onto it. Reversing the creation source must still create an
# equal-amount reversal — the original economic impact must not survive
# just because other evidence was attached later.
approved = self.decide("approve_new")
event_id = approved["ledger_event_id"]
linked = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-12T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-link-ev",
actor=self.admin,
)
manual_records.decide(
self.connection, linked["id"], "approve_link",
reason="同源补充证据", expected_decision_id=linked["decision_id"],
request_key="dec-link-ev", actor=self.admin,
target_ledger_event_id=event_id,
)
self.assertEqual(1, len(self.position_events()))
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-create", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
rows = self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
self.assertEqual(2, len(rows))
original, reversal = rows
self.assertEqual("normal", original["posting_kind"])
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
# The later linked evidence stays attached to the event.
links = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id"
).fetchall()
self.assertEqual(2, len(links))
def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None:
# M1 creates the event, M2 links; after M1's reversal created the
# offset, reversing the linked M2 must only detach, never add a second
# reversal event.
approved = self.decide("approve_new")
event_id = approved["ledger_event_id"]
linked = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-12T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-link-ev2",
actor=self.admin,
)
linked_approved = manual_records.decide(
self.connection, linked["id"], "approve_link",
reason="同源补充证据", expected_decision_id=linked["decision_id"],
request_key="dec-link-ev2", actor=self.admin,
target_ledger_event_id=event_id,
)
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev-create2", actor=self.admin,
)
manual_records.decide(
self.connection, linked["id"], "reverse",
reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"],
request_key="dec-rev-link2", actor=self.admin,
)
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(1, reversals)
links = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources"
).fetchall()
self.assertEqual(1, len(links))
class DedupAndConcurrencyTests(LedgerBase):
def test_duplicate_manual_record_pairing_candidate(self) -> None:
manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="outgoing",
amount="40.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-a",
actor=self.admin,
)
other = manual_records.submit(
self.connection, company_id=self.company_b,
counterparty_company_id=self.company_a,
occurred_at="2026-01-10T10:00:00", direction="incoming",
amount="40.00", currency="CNY", funding_source="other",
requested_subject="other_payable", request_key="mr-b",
actor=self.admin,
)
candidates = manual_records.find_candidates(self.connection, other["id"])
self.assertEqual(1, len(candidates))
self.assertEqual("manual_record", candidates[0]["kind"])
def test_duplicate_bank_event_candidate_hints_link(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-05T12:00:00", direction="incoming",
amount="100.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-c",
actor=self.admin,
)
candidates = manual_records.find_candidates(self.connection, record["id"])
self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))
def test_concurrent_approve_new_counts_once(self) -> None:
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-conc",
actor=self.admin,
)
errors: list[Exception] = []
def worker(thread_key: str) -> None:
import sqlite3
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
manual_records.decide(
connection, record["id"], "approve_new",
reason="并发审批", expected_decision_id=record["decision_id"],
request_key=f"dec-{thread_key}", actor=self.admin,
)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# Two connections can still double-commit, so the UNIQUE manual source
# claim must make the second write fail or be a no-op; either way only
# one ledger event may exist.
events = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_events"
).fetchone()["n"]
self.assertEqual(1, events)
def test_concurrent_submit_same_request_key_is_idempotent(self) -> None:
import sqlite3
results: list[dict] = []
errors: list[Exception] = []
def worker(thread_key: str) -> None:
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
payload = manual_records.submit(
connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable",
request_key="mr-conc-submit", actor=self.admin,
)
results.append(payload)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual([], errors)
rows = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_records"
).fetchone()["n"]
self.assertEqual(1, rows)
self.assertEqual(1, len({payload["id"] for payload in results}))
decisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_record_decisions"
).fetchone()["n"]
self.assertEqual(1, decisions)
def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None:
import sqlite3
self.pair(self.company_a, self.company_b, "100.00")
from bank_importer import ledger_events
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
outcomes: list[dict] = []
errors: list[Exception] = []
def worker(thread_key: str) -> None:
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
payload = subjects.confirm_subject(
connection, event_id,
perspective_company_id=self.company_a,
subject_code="other_receivable", reason="并发确认",
expected_revision=1, request_key="conc-subj-key",
actor=self.admin,
)
outcomes.append(payload)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual([], errors)
confirmed = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'"
).fetchone()["n"]
self.assertEqual(1, confirmed)
self.assertEqual(1, len({payload["revision_id"] for payload in outcomes}))
def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
from bank_importer import ledger_events
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
first = subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="确认", expected_revision=1, request_key="noise-key",
actor=self.admin,
)
second = subjects.confirm_subject(
self.connection, event_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="重复确认", expected_revision=1, request_key="noise-key",
actor=self.admin,
)
self.assertEqual(first["revision_id"], second["revision_id"])
revisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?",
(event_id,),
).fetchone()["n"]
# One pending_subject + one confirmed; the replay appended nothing.
self.assertEqual(2, revisions)
confirmed = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions "
"WHERE ledger_event_id = ? AND state = 'confirmed'",
(event_id,),
).fetchone()["n"]
self.assertEqual(1, confirmed)
if __name__ == "__main__":
unittest.main()
+108 -5
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase): class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None: def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection) first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5], first) self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
self.assertEqual([], migrate(self.connection)) self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection)) self.assertEqual(first, applied_versions(self.connection))
tables = { tables = {
@@ -73,19 +73,31 @@ class MigrationTests(PersistenceTestCase):
"transfer_match_candidates", "transfer_match_candidates",
"current_transfer_decisions", "current_transfer_decisions",
"transfer_observation_claims", "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", "schema_migrations",
): ):
self.assertIn(table, tables) self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None: def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0)) self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection)) self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute( remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'" "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone() ).fetchone()
self.assertIsNone(remaining) self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection)) self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5], applied_versions(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: def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample() self.import_sample()
@@ -93,7 +105,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows" "SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"] ).fetchone()["n"]
self.assertGreater(row_count, 0) self.assertGreater(row_count, 0)
self.assertEqual([5], rollback(self.connection, 4)) self.assertEqual([7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched. # The pre-migration evidence and schema are untouched.
self.assertEqual( self.assertEqual(
row_count, row_count,
@@ -109,6 +121,10 @@ class MigrationTests(PersistenceTestCase):
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'" "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'"
).fetchone() ).fetchone()
self.assertIsNone(remaining) 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: def test_event_layer_views_exist_after_migration(self) -> None:
view = self.connection.execute( view = self.connection.execute(
@@ -116,6 +132,93 @@ class MigrationTests(PersistenceTestCase):
).fetchone() ).fetchone()
self.assertIsNotNone(view) 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: def test_decision_log_immutability_triggers(self) -> None:
self.import_sample() self.import_sample()
source_row_id = self.connection.execute( source_row_id = self.connection.execute(
+430
View File
@@ -0,0 +1,430 @@
"""B-44 position aggregation tests: conservation, repayments, reversals across
cutoffs, currency isolation, Decimal precision, unresolved gross, normal
balance exceptions and keyset pagination."""
from __future__ import annotations
from decimal import Decimal, getcontext
import unittest
from bank_importer import ledger_events, manual_records, positions, subjects
from ledger_helpers import LedgerBase
def confirm(connection, event_id: int, perspective: int, subject: str, key: str, actor):
return subjects.confirm_subject(
connection, event_id,
perspective_company_id=perspective, subject_code=subject,
reason="审核确认", expected_revision=1, request_key=key, actor=actor,
)
class ConservationTests(LedgerBase):
def setUp(self) -> None:
super().setUp()
getcontext().prec = 50
def _confirm_all(self, subject: str = "other_receivable"):
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(
self.connection, event["id"],
revision["payer_company_id"], subject, f"subj-{event['id']}",
self.admin,
)
def test_both_perspectives_mirror_exactly(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_a, self.company_b, "30.00", at="2026-01-10T10:00:00")
self.pair(self.company_a, self.company_b, "70.00", at="2026-02-05T10:00:00")
self._confirm_all()
pair = positions.pair_detail(
self.connection, self.company_a, self.company_b,
from_="2026-01-01", cutoff="2026-07-31",
)
item = pair["items"][0]
self.assertTrue(item["conservation"]["opposite"])
self.assertTrue(item["conservation"]["abs_equal"])
self.assertEqual(Decimal("200.00"), Decimal(item["a"]["result"]["signed_amount"]))
self.assertEqual(Decimal("-200.00"), Decimal(item["b"]["result"]["signed_amount"]))
def test_repayment_reduces_the_original_balance(self) -> None:
# A lends B 100 (A pays) then B repays 40 (B pays).
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_b, self.company_a, "40.00", at="2026-03-01T10:00:00",
summary="还款", purpose="归还借款")
self._confirm_all()
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in balances["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(Decimal("60.00"), Decimal(item["result"]["signed_amount"]))
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["debit"]))
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["credit"]))
else:
self.assertEqual(Decimal("-60.00"), Decimal(item["result"]["signed_amount"]))
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["debit"]))
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["credit"]))
def test_over_repayment_flags_normal_balance_exception(self) -> None:
# A lends B 100, then B repays 150 while A books the repayment against
# its receivable: A's receivable and B's payable both go negative.
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_b, self.company_a, "150.00", at="2026-03-01T10:00:00",
summary="还款", purpose="归还借款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
# Book every event from A's perspective on the receivable side so
# the over-repayment produces abnormal balances.
confirm(
self.connection, event["id"], self.company_a,
"other_receivable", f"k-{event['id']}", self.admin,
)
pair = positions.pair_detail(
self.connection, self.company_a, self.company_b,
from_="2026-01-01", cutoff="2026-07-31",
)
item = pair["items"][0]
self.assertTrue(item["normal_balance_exception"])
self.assertIn("other_receivable", item["normal_balance_exception"])
self.assertIn("other_payable", item["normal_balance_exception"])
class CutoffAndReversalTests(LedgerBase):
def _loan(self) -> int:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
confirm(self.connection, event_id, self.company_a, "other_receivable", "k1", self.admin)
return event_id
def test_cutoff_before_reversal_keeps_original_impact(self) -> None:
event_id = self._loan()
# Reversal dated after the original event.
ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
effective_at="2026-06-15T00:00:00", reason="冲销", actor=self.admin,
)
before = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-05-31"
)
after = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in before["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
for item in after["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(Decimal("0"), Decimal(item["result"]["signed_amount"]))
def test_from_gt_cutoff_is_rejected(self) -> None:
with self.assertRaises(positions.PositionInputError):
positions.validate_window("2026-08-01", "2026-07-31")
def test_events_before_from_are_excluded_from_period(self) -> None:
self._loan()
balances = positions.company_balances(
self.connection, from_="2026-06-01", cutoff="2026-07-31"
)
self.assertEqual([], balances["items"])
def test_reversal_uses_approved_effective_date(self) -> None:
event_id = self._loan()
ledger_events.create_reversal(
self.connection, event_id, source_kind="adjustment",
effective_at="2026-08-01T00:00:00", reason="未来冲销", actor=self.admin,
)
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in balances["items"]:
if item["company_id"] == self.company_a:
# The future reversal does not rewrite the earlier cutoff.
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
class CurrencyAndPrecisionTests(LedgerBase):
def test_currencies_never_mix(self) -> None:
self.pair(self.company_a, self.company_b, "100.00", currency="CNY")
self.pair(self.company_a, self.company_b, "50.00", at="2026-01-20T10:00:00",
currency="USD")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
events = self.ledger_events()
for event in events:
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
by_company = {}
for item in balances["items"]:
by_company.setdefault(item["company_id"], {})[item["currency"]] = item["result"]
self.assertEqual(Decimal("100.00"), Decimal(by_company[self.company_a]["CNY"]["signed_amount"]))
self.assertEqual(Decimal("50.00"), Decimal(by_company[self.company_a]["USD"]["signed_amount"]))
def test_decimal_precision_is_preserved(self) -> None:
self.pair(self.company_a, self.company_b, "0.01")
self.pair(self.company_a, self.company_b, "12345.6789", at="2026-01-11T10:00:00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
balances = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31"
)
for item in balances["items"]:
if item["company_id"] == self.company_a:
self.assertEqual(
Decimal("12345.6889"), Decimal(item["result"]["signed_amount"])
)
# The stored string keeps every decimal place.
self.assertIn("12345.6889", item["result"]["signed_amount"])
def test_manual_record_preserves_amount_scale(self) -> None:
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-02-01T09:00:00", direction="incoming",
amount="12.3400", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-prec",
actor=self.admin,
)
manual_records.decide(
self.connection, record["id"], "approve_new",
reason="确认", expected_decision_id=record["decision_id"],
request_key="dec-prec", actor=self.admin,
)
stored = self.connection.execute(
"SELECT amount, amount_scale FROM eligible_position_events"
).fetchone()
self.assertEqual("12.3400", stored["amount"])
self.assertEqual(4, stored["amount_scale"])
class UnresolvedTests(LedgerBase):
def test_unresolved_gross_never_nets(self) -> None:
# Two pending_subject events of opposite economic signs must sum their
# absolute values, never cancel.
self.pair(self.company_a, self.company_b, "100.00")
self.pair(self.company_a, self.company_b, "40.00", at="2026-01-20T10:00:00",
summary="还款", purpose="归还借款")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
unresolved = positions.unresolved_for_company(
self.connection, self.company_a, "2026-07-31"
)
self.assertEqual(2, unresolved["count"])
self.assertEqual(
Decimal("140.00"), Decimal(unresolved["gross_amount"])
)
self.assertEqual(
Decimal("140.00"),
Decimal(unresolved["by_reason"]["subject_review"]["gross_amount"]),
)
def test_unmatched_single_reason_bucket(self) -> None:
self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="88.00",
)
from bank_importer import matching
matching.reconcile_rows(self.connection, [1])
unresolved = positions.unresolved_for_company(
self.connection, self.company_a, "2026-07-31"
)
self.assertEqual(1, unresolved["count"])
self.assertEqual(
Decimal("88.00"),
Decimal(unresolved["by_reason"]["unmatched_single"]["gross_amount"]),
)
def test_pending_manual_only_counts_for_submitting_company(self) -> None:
manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-a",
actor=self.admin,
)
a_unresolved = positions.unresolved_for_company(
self.connection, self.company_a, "2026-07-31"
)
b_unresolved = positions.unresolved_for_company(
self.connection, self.company_b, "2026-07-31"
)
self.assertEqual(1, a_unresolved["count"])
self.assertEqual(
Decimal("50.00"), Decimal(a_unresolved["by_reason"]["manual_pending"]["gross_amount"])
)
# The counterparty never sees the unapproved declaration.
self.assertEqual(0, b_unresolved["count"])
class PaginationTests(LedgerBase):
def test_subject_filter_matches_stored_and_mirror_subjects(self) -> None:
# A books "receivable" from its own perspective; B sees the mirror
# "payable". Filtering by B's mirror subject must still find the event.
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
confirm(self.connection, event_id, self.company_a, "receivable", "k-mirror",
self.admin)
stored = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
subject="receivable",
)
self.assertTrue(any(
item["ledger_event_id"] == event_id for item in stored["items"]
))
# Company B filters by its own perspective: the stored "receivable" is
# the mirror of "payable", so it must be included.
mirror = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
subject="payable", viewer_company_id=self.company_b,
)
self.assertTrue(any(
item["ledger_event_id"] == event_id for item in mirror["items"]
), mirror)
for item in mirror["items"]:
if item["ledger_event_id"] == event_id:
self.assertEqual("payable", item["own_subject"])
def test_subject_filter_without_match_returns_empty(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = self.ledger_events()[0]["id"]
confirm(self.connection, event_id, self.company_a, "receivable", "k-none",
self.admin)
other = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
subject="other_receivable",
)
self.assertEqual([], other["items"])
def test_pagination_is_stable_and_complete(self) -> None:
for index in range(7):
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
seen: list[int] = []
cursor = None
pages = 0
while True:
page = positions.list_events(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
limit=3, cursor=cursor,
)
pages += 1
seen.extend(item["ledger_event_id"] for item in page["items"])
cursor = page["next_cursor"]
if not page["has_more"]:
break
self.assertEqual(7, len(seen))
self.assertEqual(len(set(seen)), len(seen))
self.assertGreater(pages, 2)
def test_directory_pagination_complete(self) -> None:
for index in range(6):
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
for event in self.ledger_events():
revision = self.current(event["id"])
confirm(self.connection, event["id"], revision["payer_company_id"],
"other_receivable", f"k-{event['id']}", self.admin)
seen: set[tuple[int, str]] = set()
cursor = None
while True:
page = positions.company_balances(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
limit=1, cursor=cursor,
)
seen.update((item["company_id"], item["currency"]) for item in page["items"])
cursor = page["next_cursor"]
if not page["has_more"]:
break
self.assertEqual(
{(self.company_a, "CNY"), (self.company_b, "CNY")}, seen
)
def test_event_payload_account_chip_and_repayment_flag(self) -> None:
self.pair(
self.company_a, self.company_b, "40.00",
summary="归还往来款", purpose="还款",
)
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = int(self.ledger_events()[0]["id"])
detail = positions.event_detail(self.connection, event_id)
event = detail["event"]
self.assertEqual("visible", event["payer_account"]["visibility"])
self.assertEqual("测试 0001", event["payer_account"]["label"])
self.assertEqual("visible", event["payee_account"]["visibility"])
self.assertEqual("测试 0002", event["payee_account"]["label"])
self.assertEqual("归还往来款", event["summary"])
self.assertTrue(event["is_repayment"])
company_view = positions.event_payload(
self.connection,
self.connection.execute(
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
(event_id,),
).fetchone(),
viewer_company_id=self.company_a,
)
self.assertEqual("visible", company_view["payer_account"]["visibility"])
self.assertEqual("masked", company_view["payee_account"]["visibility"])
self.assertEqual("按对方授权不可见", company_view["payee_account"]["label"])
def test_park_subject_exception_hides_from_review_queue(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = int(self.ledger_events()[0]["id"])
current = self.current(event_id)
subjects.park_subject(
self.connection, event_id,
disposition="exception", reason="转异常核查",
expected_revision=current["id"], request_key="park-exc-1",
actor=self.admin,
)
queue = positions.subject_review_queue(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
)
self.assertFalse(
any(item["ledger_event_id"] == event_id for item in queue["items"])
)
revision = self.current(event_id)
self.assertEqual("pending_subject", revision["state"])
def test_park_subject_return_stays_in_review_queue(self) -> None:
self.pair(self.company_a, self.company_b, "80.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
event_id = int(self.ledger_events()[0]["id"])
current = self.current(event_id)
subjects.park_subject(
self.connection, event_id,
disposition="return", reason="退回补充摘要",
expected_revision=current["id"], request_key="park-ret-1",
actor=self.admin,
)
queue = positions.subject_review_queue(
self.connection, from_="2026-01-01", cutoff="2026-07-31",
)
self.assertTrue(
any(item["ledger_event_id"] == event_id for item in queue["items"])
)
if __name__ == "__main__":
unittest.main()
+594
View File
@@ -0,0 +1,594 @@
"""HTTP integration tests for the B-44 intercompany position APIs.
Covers the admin balances/pair/events/evidence/subject-review/manual-record
endpoints and the company-side scoped reads with masked evidence, plus
cross-tenant 404s and company-forbidden admin writes. Every test method spins
up its own server with a fresh database so subject confirmations and matched
fixtures never leak across tests.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00",
currency: str = "RMB"):
return [own, "测试公司", at, amount, "", "50000.00", currency, "对方", cp, "某银行", "借款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
currency: str = "RMB"):
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", cp, "某银行", "借款", ""]
class IntercompanyApiTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self.initial_passwords: dict[str, str] = {}
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a", self.company_a)
self.cashier_b = self._login_company("cashier-b", self.company_b)
self.cashier_c = self._login_company("cashier-c", self.company_c)
self._approve_account(self.company_a, ACCOUNT_A)
self._approve_account(self.company_b, ACCOUNT_B)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords.setdefault(username, as_json(data)["initial_password"])
return as_json(data)["company_id"]
def _login_company(self, username: str, company_id: int) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str) -> None:
client = {self.company_a: self.cashier_a, self.company_b: self.cashier_b}[company_id]
status, _, data = client.post_json(
"/api/company/accounts",
{"bank_name": "中信银行", "account_type": "基本户",
"account_number": number, "start_date": "2026-01-01"},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
)
assert status == 200, data
def _upload_and_confirm(self, client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00",
currency: str = "RMB") -> dict:
"""Upload+confirm a new A<->B pair; returns the pending review item."""
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at, currency)],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"), currency)],
)
status, _, data = self.admin.get(
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
pending = [item for item in items if item["amount"] == amount]
self.assertEqual(1, len(pending), data)
return pending[0]
def _confirm(self, ledger_event_id: int) -> dict:
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{ledger_event_id}"
)
self.assertEqual(200, status, data)
detail = as_json(data)
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{ledger_event_id}/subject-decisions",
{
"perspective_company_id": detail["event"]["payer_company_id"],
"subject_code": "other_receivable",
"reason": "借款确认其他应收",
"expected_revision": detail["event"]["ledger_revision_id"],
"request_key": f"subj-{ledger_event_id}",
},
)
self.assertEqual(200, status, data)
return as_json(data)["revision"]
def balances(self, client=None, path="/api/admin/intercompany/balances") -> dict:
client = client or self.admin
status, _, data = client.get(path + "?from=2026-01-01&cutoff=2026-12-31")
self.assertEqual(200, status, data)
return as_json(data)
def _by_company(self, payload: dict, company_id: int) -> dict:
return next(item for item in payload["items"] if item["company_id"] == company_id)
# ------------------------------------------------------------------
# Admin balances / pair / events
# ------------------------------------------------------------------
def test_admin_balances_directory(self) -> None:
self._fresh_pair("100.00")
payload = self.balances()
self.assertEqual("2026-12-31", payload["window"]["cutoff"])
companies = {item["company_id"] for item in payload["items"]}
self.assertIn(self.company_a, companies)
self.assertIn(self.company_b, companies)
for item in payload["items"]:
self.assertEqual("unavailable", item["opening"]["status"])
self.assertIsNone(item["opening"]["amount"])
self.assertEqual("period_net_change", item["result"]["kind"])
self.assertIn("gross_amount", item["unresolved"])
self.assertIn("count", item["unresolved"])
self.assertIn("by_reason", item["unresolved"])
self.assertEqual("RMB", item["currency"])
def test_admin_balances_pending_subject_shows_unresolved(self) -> None:
self._fresh_pair("100.00")
payload = self.balances()
item = self._by_company(payload, self.company_a)
self.assertEqual(1, item["unresolved"]["count"])
self.assertEqual(
"100.00", item["unresolved"]["by_reason"]["subject_review"]["gross_amount"]
)
self.assertEqual("0", item["period"]["debit"])
self.assertEqual("0", item["period"]["credit"])
def test_admin_pair_detail_conserves(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.admin.get(
f"/api/admin/intercompany/pairs/{self.company_a}/{self.company_b}"
"?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
item = payload["items"][0]
self.assertTrue(item["conservation"]["opposite"])
self.assertTrue(item["conservation"]["abs_equal"])
self.assertEqual("unavailable", item["opening"]["status"])
self.assertIn("subjects", item)
self.assertEqual(1, item["unresolved"]["count"])
def test_admin_subject_decision_flows_to_balances(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
self.assertEqual("confirmed", revision["state"])
self.assertEqual("other_receivable", revision["subject_code"])
payload = self.balances()
item = self._by_company(payload, self.company_a)
self.assertEqual(0, item["unresolved"]["count"])
self.assertEqual("100.00", item["period"]["debit"])
self.assertEqual("100.00", item["result"]["signed_amount"])
def test_admin_subject_decision_stale_revision_conflicts(self) -> None:
pending = self._fresh_pair("100.00")
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
{
"perspective_company_id": pending["payer_company_id"],
"subject_code": "other_receivable", "reason": "确认",
"expected_revision": 999, "request_key": "stale-key",
},
)
self.assertEqual(409, status, data)
def test_admin_events_list_and_evidence(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
event = next(
item for item in items
if item["ledger_event_id"] == revision["ledger_event_id"]
)
self.assertEqual("confirmed", event["state"])
self.assertEqual("other_receivable", event["subject_code"])
self.assertIn("payer_company_name", event)
self.assertIn("amount", event)
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{event['ledger_event_id']}"
)
self.assertEqual(200, status, data)
self.assertEqual("confirmed", as_json(data)["state"])
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{event['ledger_event_id']}/evidence"
)
self.assertEqual(200, status, data)
blocks = as_json(data)["blocks"]
self.assertTrue(blocks)
for block in blocks:
self.assertEqual("visible", block["visibility"])
def test_admin_events_list_includes_pending_subject(self) -> None:
pending = self._fresh_pair("100.00")
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
item = next(
item for item in items
if item["ledger_event_id"] == pending["ledger_event_id"]
)
self.assertEqual("pending_subject", item["state"])
self.assertIsNone(item["subject_code"])
def test_admin_manual_record_decision_via_api(self) -> None:
status, _, data = self.cashier_a.post_json(
"/api/company/manual-records",
{
"counterparty_company_id": self.company_b,
"occurred_at": "2026-02-01T09:00:00",
"direction": "incoming", "amount": "20.00", "currency": "CNY",
"funding_source": "other", "requested_subject": "other_receivable",
"request_key": "mr-api-1", "summary": "还款",
},
)
self.assertEqual(200, status, data)
record = as_json(data)["record"]
self.assertEqual("pending", record["state"])
status, _, data = self.admin.get("/api/admin/manual-records")
self.assertEqual(200, status, data)
records = as_json(data)["records"]
self.assertTrue(any(item["id"] == record["id"] for item in records))
status, _, data = self.admin.post_json(
f"/api/admin/manual-records/{record['id']}/decisions",
{"action": "approve_new", "reason": "银行流水中无此事实",
"expected_decision_id": record["decision_id"], "request_key": "dec-api-1"},
)
self.assertEqual(200, status, data)
self.assertEqual("approved", as_json(data)["decision"]["state"])
status, _, data = self.cashier_a.get("/api/company/manual-records")
self.assertEqual(200, status, data)
own = [item for item in as_json(data)["records"] if item["id"] == record["id"]]
self.assertEqual(1, len(own))
self.assertEqual("approved", own[0]["state"])
status, _, data = self.cashier_b.get("/api/company/manual-records")
self.assertEqual(200, status, data)
self.assertEqual([], [
item for item in as_json(data)["records"] if item["id"] == record["id"]
])
def test_admin_adjustment_reverse_via_api(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{revision['ledger_event_id']}/adjustments",
{"action": "reverse", "reason": "科目误判,冲销"},
)
self.assertEqual(200, status, data)
self.assertEqual("reverse", as_json(data)["action"])
# ------------------------------------------------------------------
# Company scope and masking
# ------------------------------------------------------------------
def test_company_balances_scoped_to_own_company(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.cashier_a.get(
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
for item in payload["items"]:
self.assertEqual(self.company_a, item["company_id"])
self.assertIn("counterparties", payload)
self.assertTrue(payload["counterparties"])
def test_company_pair_and_events_are_own_scope(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.cashier_a.get(
f"/api/company/intercompany/pairs/{self.company_b}"
"?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
self.assertEqual(self.company_a, as_json(data)["companies"]["a"]["company_id"])
status, _, data = self.cashier_a.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
for item in items:
self.assertIn("direction", item)
self.assertIn("own_subject_label", item)
self.assertIn(item["direction"], ("incoming", "outgoing"))
def test_company_evidence_masks_counterparty_side(self) -> None:
pending = self._fresh_pair("100.00")
self._confirm(pending["ledger_event_id"])
status, _, data = self.cashier_a.get(
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
)
self.assertEqual(200, status, data)
blocks = as_json(data)["blocks"]
visibilities = {block["visibility"] for block in blocks}
self.assertTrue(visibilities & {"visible", "masked"})
for block in blocks:
if block["visibility"] == "masked":
self.assertEqual("按对方授权不可见", block["fields"].get("note"))
self.assertNotIn(ACCOUNT_B, json.dumps(block["fields"], ensure_ascii=False))
def test_company_cannot_read_or_write_admin_intercompany(self) -> None:
self._fresh_pair("100.00")
status, _, data = self.cashier_a.get("/api/admin/intercompany/balances")
self.assertEqual(403, status, data)
status, _, data = self.cashier_a.get("/api/admin/manual-records")
self.assertEqual(403, status, data)
status, _, data = self.cashier_a.post_json(
"/api/admin/manual-records/1/decisions", {"action": "approve_new", "reason": "x"}
)
self.assertEqual(403, status, data)
status, _, data = self.cashier_a.post_json(
"/api/admin/intercompany/events/1/adjustments",
{"action": "reverse", "reason": "越权"},
)
self.assertEqual(403, status, data)
def test_cross_company_reads_are_404(self) -> None:
pending = self._fresh_pair("100.00")
status, _, data = self.cashier_c.get(
f"/api/company/intercompany/events/{pending['ledger_event_id']}"
)
self.assertEqual(404, status, data)
status, _, data = self.cashier_c.get(
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
)
self.assertEqual(404, status, data)
def test_company_counterparty_summary_splits_by_currency(self) -> None:
# A->B 100 CNY and A->B 50 USD must render two rows, never a mixed
# "CNY 150.00" bucket.
cny = self._fresh_pair("100.00", currency="CNY")
usd = self._fresh_pair("50.00", at="2026-01-20 10:00:00", currency="USD")
for pending in (cny, usd):
self._confirm(pending["ledger_event_id"])
status, _, data = self.cashier_a.get(
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
counterparties = as_json(data)["counterparties"]
rows = [row for row in counterparties if row["counterparty_company_id"] == self.company_b]
self.assertEqual(2, len(rows), counterparties)
by_currency = {row["currency"]: row for row in rows}
self.assertEqual({"CNY", "USD"}, set(by_currency))
self.assertEqual("100.00", by_currency["CNY"]["result"]["signed_amount"])
self.assertEqual("50.00", by_currency["USD"]["result"]["signed_amount"])
for row in rows:
self.assertNotEqual("150.00", row["result"]["signed_amount"])
self.assertEqual(1, row["event_count"])
def test_company_events_subject_filter_matches_mirror(self) -> None:
# A confirms subject "receivable" (stored from A's perspective); B must
# still find the event when filtering by the mirror subject "payable".
pending = self._fresh_pair("100.00")
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
{
"perspective_company_id": self.company_a,
"subject_code": "receivable", "reason": "确认应收",
"expected_revision": 1, "request_key": "mirror-subj",
},
)
self.assertEqual(200, status, data)
status, _, data = self.cashier_b.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31&subject=payable"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(any(
item["ledger_event_id"] == pending["ledger_event_id"] for item in items
), data)
def test_manual_reverse_via_api_uses_explicit_effective_date(self) -> None:
status, _, data = self.cashier_a.post_json(
"/api/company/manual-records",
{
"counterparty_company_id": self.company_b,
"occurred_at": "2026-02-01T09:00:00",
"direction": "incoming", "amount": "20.00", "currency": "CNY",
"funding_source": "other", "requested_subject": "other_receivable",
"request_key": "mr-rev-api", "summary": "还款",
},
)
self.assertEqual(200, status, data)
record = as_json(data)["record"]
status, _, data = self.admin.post_json(
f"/api/admin/manual-records/{record['id']}/decisions",
{"action": "approve_new", "reason": "确认入账",
"expected_decision_id": record["decision_id"], "request_key": "dec-rev-api"},
)
self.assertEqual(200, status, data)
event_id = as_json(data)["decision"]["ledger_event_id"]
status, _, data = self.admin.get(
f"/api/admin/intercompany/events/{event_id}"
)
self.assertEqual(200, status, data)
revision_id = as_json(data)["event"]["ledger_revision_id"]
status, _, data = self.admin.post_json(
f"/api/admin/manual-records/{record['id']}/decisions",
{"action": "reverse", "reason": "误录冲销", "effective_at": "2026-06-15",
"expected_decision_id": None, "request_key": "dec-rev-api-2"},
)
self.assertEqual(200, status, data)
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
reversals = [item for item in as_json(data)["items"] if item["posting_kind"] == "reversal"]
self.assertEqual(1, len(reversals))
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
def test_admin_events_include_account_chips(self) -> None:
pending = self._fresh_pair("100.00")
revision = self._confirm(pending["ledger_event_id"])
status, _, data = self.admin.get(
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
event = next(
item for item in as_json(data)["items"]
if item["ledger_event_id"] == revision["ledger_event_id"]
)
self.assertIn("payer_account", event)
self.assertIn("payee_account", event)
self.assertEqual("visible", event["payer_account"]["visibility"])
self.assertTrue(event["payer_account"]["label"])
self.assertIn("summary", event)
self.assertIn("is_repayment", event)
def test_subject_exception_drops_from_review_queue(self) -> None:
pending = self._fresh_pair("77.00")
status, _, data = self.admin.post_json(
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
{
"perspective_company_id": pending["payer_company_id"],
"action": "exception",
"reason": "转异常待核查",
"expected_revision": pending["revision_id"],
"request_key": "park-exc-api",
},
)
self.assertEqual(200, status, data)
self.assertEqual("pending_subject", as_json(data)["revision"]["state"])
status, _, data = self.admin.get(
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
ids = [item["ledger_event_id"] for item in as_json(data)["items"]]
self.assertNotIn(pending["ledger_event_id"], ids)
if __name__ == "__main__":
unittest.main()
+296
View File
@@ -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()
+900 -189
View File
File diff suppressed because it is too large Load Diff
+2538 -466
View File
File diff suppressed because it is too large Load Diff
+1001 -151
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30 -45
View File
@@ -1,48 +1,33 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="河南金牛实业集团内部资金往来与银行流水管理" /> <title>金牛实业资金往来管理系统 · 入口</title>
<title>登录 · 河南金牛实业集团</title> <link rel="stylesheet" href="design-system.css?v=5" />
<link rel="stylesheet" href="styles.css" /> </head>
</head> <body>
<body class="entry-page"> <div class="portal-wrap">
<!-- <div class="portal-inner">
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。 <p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。 <h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
STORY: 居中集团登录卡:先确认集团身份与系统名称,再选择工作端口登录。 <p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
FIRST VIEWPORT: 一张悬浮玻璃登录卡居于氛围光中央,集团名称置顶,端口选择、账号表单与系统事实依次排列。
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。 <div class="portal-grid">
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md <a class="portal-card" href="login-admin.html">
--> <span class="pc-role">管理端 · 7 个页面</span>
<main class="entry-shell"> <h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
<section class="entry-card" aria-labelledby="login-title"> <p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
<div class="entry-brand"><span class="brand-mark"></span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div> <span class="pc-go">去管理端登录 →</span>
<form class="entry-form" id="loginForm"> </a>
<header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header> <a class="portal-card" href="login-company.html">
<div class="role-switch" role="radiogroup" aria-label="工作端口"> <span class="pc-role">公司业务端 · 7 个页面</span>
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label> <h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 往来确认 / 银行账户 / 通知</h3>
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label> <p>面向成员公司出纳:上传银行流水、登记手工往来、确认单边匹配与科目,跟踪本月完成进度。</p>
</div> <span class="pc-go">去公司端登录 →</span>
<label class="field"><span>账号</span><input name="username" autocomplete="username" required /></label> </a>
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label> </div>
<div id="changePassword" hidden> </div>
<p class="entry-note">首次登录须修改密码,请设置新密码后再进入工作台。</p> </div>
<label class="field"><span>新密码</span><input name="new_password" type="password" autocomplete="new-password" /></label> </body>
<label class="field"><span>确认新密码</span><input name="confirm_password" type="password" autocomplete="new-password" /></label>
</div>
<p class="entry-note" id="loginError" role="alert" hidden></p>
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
</form>
<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>
</main>
<script src="app.js"></script>
</body>
</html> </html>
+80
View File
@@ -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>
+80
View File
@@ -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>
+96
View File
@@ -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;
});
-738
View File
@@ -1,738 +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: #050505;
--color-bg-soft: #0a0a0a;
--color-nav: #0a0a0a;
--color-surface: rgba(22, 22, 22, 0.82);
--color-surface-solid: #161616;
--color-surface-raised: #1c1c1c;
--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.06), inset 0 -1px 0 rgba(0, 0, 0, 0.22);
--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: 14px/1.6 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: 266px minmax(0, 1fr); }
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 248px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 12, 12, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 20px; border-bottom: 1px solid var(--color-line); }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; }
.brand-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: 8px; padding: 14px 2px; overflow-y: auto; }
.nav-item { position: relative; width: 100%; min-height: 46px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
.nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); }
.nav-item.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: 56px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 5, 5, 0.82); backdrop-filter: blur(22px); }
.topbar-spacer { flex: 1; }
.workspace-name { display: flex; flex-direction: column; min-width: 150px; }
.workspace-name 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(200px, 22vw, 340px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
.search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); }
.search-box 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: 12px 32px 58px; }
.app-view { display: none; }
.app-view.is-active { display: block; }
.page-heading { min-height: 56px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.page-heading h1 { font-size: clamp(22px, 2.4vw, 30px); line-height: 1.2; letter-spacing: 0; }
.page-heading p { margin-top: 5px; color: var(--color-ink-muted); }
.content-toolbar { display: contents; }
.content-toolbar > .search-box { margin-inline: auto; width: clamp(260px, 42vw, 520px); }
.content-toolbar .toolbar-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.dashboard-head { align-items: center; gap: 20px; margin-bottom: 20px; }
/* Commands and fields */
.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: linear-gradient(160deg, rgba(28, 28, 28, 0.82) 0%, rgba(16, 16, 16, 0.86) 50%, rgba(10, 10, 10, 0.88) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, rgba(255, 255, 255, 0) 70%); pointer-events: none; }
.panel:hover { box-shadow: 0 20px 48px rgba(0, 0, 0, 0.32), inset 0 1px rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
.panel { min-width: 0; overflow: hidden; }
.panel > * { 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: 18px; margin: 0 0 18px; }
.metric-card { position: relative; min-height: 164px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 24px 22px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.86) 0%, rgba(14, 14, 14, 0.88) 50%, rgba(8, 8, 8, 0.9) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transform: perspective(900px) rotateX(0.6deg) rotateY(-0.8deg); transform-origin: center bottom; transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 42%, rgba(255, 255, 255, 0) 72%); pointer-events: none; }
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; transition: opacity var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card:hover::after { opacity: 0.7; box-shadow: 0 -10px 34px currentColor; }
.metric-card:hover { transform: perspective(900px) rotateX(3.5deg) rotateY(-4.5deg) translateY(-10px); border-color: rgba(255, 255, 255, 0.32); box-shadow: 0 36px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 1px rgba(255, 255, 255, 0.14); }
.metric-copy { align-self: start; }
.metric-label { display: block; color: var(--color-ink-soft); font-size: 13px; }
.metric-value { display: block; margin-top: 7px; font: 700 34px/1.05 var(--font-data); letter-spacing: 0; }
.metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); }
.metric-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); }
/* Key-company / account-coverage flat list (replaces the floating 3D card stack) */
.company-list { display: grid; gap: 8px; padding: 10px 12px 14px; }
.company-row { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 62px; padding: 11px 13px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(22, 22, 22, 0.7) 0%, rgba(12, 12, 12, 0.75) 100%); cursor: pointer; transition: background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard); }
.company-row:hover { background: linear-gradient(160deg, rgba(32, 32, 32, 0.8) 0%, rgba(18, 18, 18, 0.85) 100%); border-color: var(--color-line-strong); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); }
.company-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
.company-row-mark { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
.company-row.is-warning .company-row-mark { background: var(--color-warning-wash); color: var(--color-warning); }
.company-row.is-danger .company-row-mark { background: var(--color-danger-wash); color: var(--color-danger); }
.company-row-body { display: flex; flex-direction: column; min-width: 0; }
.company-row-body strong { font-size: 14px; }
.company-row-body small { color: var(--color-ink-muted); font-size: 11px; }
.company-row-figure { display: flex; flex-direction: column; align-items: flex-end; text-align: right; min-width: 92px; }
.company-row-figure strong { font: 600 14px var(--font-data); color: var(--color-ink); }
.company-row-figure small { color: var(--color-ink-muted); font-size: 11px; }
.company-row .status { flex: 0 0 auto; }
/* 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(22, 22, 22, 0.86), rgba(10, 10, 10, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
.timeline-strip::before { content: ""; position: absolute; }
.timeline-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, 17, 17, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
.dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); }
.dialog > 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; }
.sheet-review { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); }
.sheet-review h3 { font-size: 14px; }
.sheet-review-hint { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-list { display: grid; gap: 8px; margin-top: 10px; max-height: 220px; overflow-y: auto; }
.sheet-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.sheet-item.is-pending { border-color: rgba(255, 188, 82, 0.45); }
.sheet-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sheet-item-head strong { font-size: 13px; }
.sheet-item-head small { color: var(--color-ink-muted); }
.sheet-item-meta { margin-top: 5px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-item-actions { display: flex; align-items: center; gap: 6px; }
.sheet-item-actions .text-button { font-size: 12px; padding: 4px 8px; }
.sheet-item-reason { margin-top: 6px; padding: 6px 8px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 12px; }
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 20, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
.toast strong, .toast small { display: block; }
.toast 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: #080808; }
.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: 12px 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) {
.timeline-strip { grid-template-columns: 1fr; }
.timeline-legend { padding-bottom: 4px; }
}
@media (max-width: 900px) {
.company-row { grid-template-columns: 34px minmax(0, 1fr) auto; }
.company-row-figure { display: none; }
}
@media (max-width: 720px) {
.timeline-strip { padding: 14px; }
.timeline-rows { gap: 6px; }
.timeline-row { grid-template-columns: 80px minmax(0, 1fr); }
.timeline-axis span { font-size: 9px; }
}
/* ===== 参考图对齐:胶囊搜索条 / 账期时间轴 / 右侧详情面板 ===== */
.content-toolbar > .search-box { border-radius: 999px; background: rgba(255, 255, 255, 0.045); backdrop-filter: blur(18px) saturate(120%); }
.timeline-panel { margin-top: 18px; padding: 18px; }
.timeline { padding: 10px 4px 0; }
.timeline-bar { position: relative; height: 14px; border: 1px solid var(--color-line); border-radius: 999px; background: rgba(255, 255, 255, 0.05); }
.timeline-seg, .timeline-gap { position: absolute; top: 2px; bottom: 2px; border-radius: 999px; }
.timeline-seg.is-closed { background: linear-gradient(90deg, rgba(55, 235, 137, 0.5), rgba(55, 235, 137, 0.28)); }
.timeline-seg.is-current { background: linear-gradient(90deg, rgba(255, 188, 82, 0.4), rgba(255, 188, 82, 0.18)); }
.timeline-gap { background: repeating-linear-gradient(45deg, rgba(255, 98, 109, 0.75) 0 6px, rgba(255, 98, 109, 0.35) 6px 12px); box-shadow: 0 0 12px rgba(255, 98, 109, 0.25); }
.timeline-today { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 10px rgba(55, 235, 137, 0.6); }
.timeline-today::after { content: "今天"; position: absolute; top: -19px; right: 5px; font-size: 10px; font-style: normal; color: var(--color-primary-strong); white-space: nowrap; }
.timeline-ticks { position: relative; height: 34px; margin-top: 8px; }
.timeline-ticks span { position: absolute; display: flex; flex-direction: column; transform: translateX(-50%); font-size: 10px; color: var(--color-ink-muted); text-align: center; }
.timeline-ticks span:first-child { transform: none; text-align: left; }
.timeline-ticks span:last-child { transform: translateX(-100%); text-align: right; }
.timeline-ticks small { color: var(--color-ink-soft); }
.timeline-panel .timeline-legend { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px 18px; margin: 10px 0 0; padding: 0; list-style: none; font-size: 11px; color: var(--color-ink-soft); }
.timeline-panel .timeline-legend li { display: flex; align-items: center; gap: 6px; }
.timeline-panel .dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.success { background: var(--color-positive); }
.dot.warning { background: var(--color-warning); }
.dot.danger { background: var(--color-danger); }
.dot.today { background: var(--color-primary-strong); box-shadow: 0 0 6px rgba(55, 235, 137, 0.7); }
.detail-drawer { position: fixed; top: 16px; right: 16px; bottom: 16px; z-index: 80; width: min(380px, calc(100vw - 32px)); display: flex; flex-direction: column; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.92), rgba(12, 12, 12, 0.94)); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); transform: translateX(calc(100% + 32px)); visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), visibility var(--duration-standard); }
.detail-drawer.is-open { transform: none; visibility: visible; }
.detail-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid var(--color-line); }
.detail-drawer h2 { margin-top: 8px; font-size: 17px; }
.detail-drawer .detail-desc { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields { display: grid; gap: 10px; margin: 0; padding: 14px 18px; overflow-y: auto; }
.detail-fields > div { display: flex; justify-content: space-between; gap: 14px; padding-bottom: 10px; border-bottom: 1px dashed var(--color-line); }
.detail-fields dt { flex-shrink: 0; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields dd { margin: 0; text-align: right; font-size: 12px; }
.detail-tip { margin: 0 18px 12px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-ink-soft); font-size: 12px; }
.detail-tip strong { display: block; margin-bottom: 4px; color: var(--color-ink); }
.detail-drawer > footer { display: flex; gap: 10px; margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--color-line); }
.detail-drawer > footer .button { flex: 1; justify-content: center; }
.detail-drawer .status { align-self: flex-start; }
[data-detail] { cursor: pointer; }
@media (max-width: 720px) {
.detail-drawer { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); }
.timeline-panel { padding: 14px; }
}
/* ===== 质感打磨:玻璃光泽 / 氛围光 / 对比与呼吸感 ===== */
:root {
--radius-lg: 24px;
--radius-xl: 30px;
}
body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(52vw 42vh at 62% 32%, rgba(55, 235, 137, 0.055), transparent 62%), radial-gradient(40vw 34vh at 90% 4%, rgba(102, 168, 255, 0.032), transparent 60%), radial-gradient(46vw 40vh at 28% 96%, rgba(55, 235, 137, 0.028), transparent 60%); }
.app-shell { position: relative; z-index: 0; }
.metric-card { border-color: rgba(255, 255, 255, 0.12); background: radial-gradient(120% 90% at 50% 108%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 42%, transparent 60%), linear-gradient(160deg, rgba(41, 41, 41, 0.86) 0%, rgba(23, 23, 23, 0.88) 50%, rgba(12, 12, 12, 0.9) 100%); }
.metric-card::before { background: radial-gradient(130% 100% at 50% 112%, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 45%, transparent 62%), linear-gradient(165deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.05) 42%, transparent 72%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.05), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, transparent 70%); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.16); background: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06)); box-shadow: inset 0 1px rgba(255, 255, 255, 0.14), 0 10px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(55, 235, 137, 0.08); }
.metric-value { font-size: clamp(28px, 2.6vw, 38px); font-weight: 800; color: #ffffff; white-space: nowrap; }
.metric-grid { gap: 20px; margin-bottom: 20px; }
.admin-dashboard-grid { gap: 20px; margin-bottom: 20px; }
.company-dashboard-grid { gap: 20px; }
.page-heading { margin-bottom: 22px; }
@media (max-width: 720px) {
.metric-grid { gap: 10px; margin-bottom: 14px; }
.page-heading { margin-bottom: 18px; }
}
/* ===== 反馈修正:侧栏质感 + 模块间距统一 ===== */
.sidebar { border-color: rgba(255, 255, 255, 0.13); background: linear-gradient(170deg, rgba(26, 26, 26, 0.92), rgba(10, 10, 10, 0.94)); box-shadow: var(--shadow-panel), 0 0 44px rgba(55, 235, 137, 0.05); }
.sidebar::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(170deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 45%, transparent 70%); pointer-events: none; }
.nav-list { gap: 10px; }
.nav-item { min-height: 48px; border-radius: 16px; }
.user-block { border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-surface-muted); }
.company-alert { margin-bottom: 20px; }
.period-ribbon { margin-bottom: 20px; }
.company-stack-panel { margin-bottom: 20px; }
.work-progress { margin-top: 20px; }
.timeline-panel { margin-top: 20px; }
/* ===== 反馈修正:整体提亮 + 列表改为玻璃行(去掉线条隔断) ===== */
:root {
--color-ink-soft: #b6c0ba;
--color-ink-muted: #99a39d;
}
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { border-color: rgba(255, 255, 255, 0.12); background: linear-gradient(160deg, rgba(37, 37, 37, 0.84) 0%, rgba(21, 21, 21, 0.87) 50%, rgba(12, 12, 12, 0.89) 100%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.13) 0%, rgba(255, 255, 255, 0.04) 40%, transparent 70%); }
.task-list, .cashier-tasks, .coverage-list, .notification-list, .review-list { display: grid; gap: 8px; padding: 12px 14px 14px; }
.task-list button, .cashier-tasks article, .coverage-list article, .notification-list article, .review-list > article, .subject-row { border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); transition: background var(--duration-fast), border-color var(--duration-fast), box-shadow var(--duration-fast), transform var(--duration-fast); }
.task-list button:hover, .cashier-tasks article:hover, .coverage-list article:hover, .notification-list article:hover, .review-list > article:hover, .subject-row:hover { border-color: rgba(255, 255, 255, 0.2); background: linear-gradient(160deg, rgba(44, 44, 44, 0.82) 0%, rgba(22, 22, 22, 0.86) 100%); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); }
.notification-list article.is-unread { border-color: rgba(102, 168, 255, 0.3); background: linear-gradient(160deg, rgba(102, 168, 255, 0.1), rgba(102, 168, 255, 0.04)); }
.company-ledger { margin: 8px 14px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); overflow: hidden; }
.company-ledger[open] { border-color: rgba(255, 255, 255, 0.18); }
.company-ledger:last-child { margin-bottom: 14px; }
.data-table { border-collapse: separate; border-spacing: 0 8px; }
.data-table th { height: 28px; padding: 0 13px; border-bottom: 0; background: transparent; }
.data-table td { border-bottom: 0; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); }
.data-table td:first-child { border-left: 1px solid var(--color-line); border-radius: 12px 0 0 12px; }
.data-table td:last-child { border-right: 1px solid var(--color-line); border-radius: 0 12px 12px 0; }
.data-table tbody tr:hover td { background: linear-gradient(160deg, rgba(44, 44, 44, 0.85) 0%, rgba(22, 22, 22, 0.88) 100%); }
.data-table tbody tr:last-child td { border-bottom: 1px solid var(--color-line); }
.table-scroll { padding: 2px 14px 8px; }
.table-summary { margin: 0 14px; }
/* ===== 登录页重做:居中集团登录卡 ===== */
.entry-shell { display: grid; grid-template-columns: 1fr; place-items: center; min-height: 100vh; padding: 48px 20px; }
.entry-card { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: var(--radius-xl); background: linear-gradient(165deg, rgba(36, 36, 36, 0.9) 0%, rgba(16, 16, 16, 0.92) 60%, rgba(10, 10, 10, 0.94) 100%); box-shadow: var(--shadow-panel), 0 0 60px rgba(55, 235, 137, 0.06); backdrop-filter: blur(28px) saturate(125%); }
.entry-card::before { content: ""; position: absolute; inset: 0; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.07), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 42%, transparent 70%); pointer-events: none; }
.entry-card::after { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 2px; background: linear-gradient(90deg, transparent, rgba(55, 235, 137, 0.65), transparent); }
.entry-brand { flex-direction: column; justify-content: center; gap: 15px; min-height: 0; padding: 36px 28px 24px; border-bottom: 1px solid var(--color-line); text-align: center; }
.entry-brand .brand-mark { width: 54px; height: 54px; border-radius: 17px; font-size: 26px; }
.entry-brand > span:last-child { align-items: center; }
.entry-brand strong { font-size: 21px; letter-spacing: 0.03em; }
.entry-brand small { margin-top: 4px; font-size: 12px; letter-spacing: 0.08em; }
.entry-form { width: 100%; gap: 16px; padding: 24px 28px 10px; border: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
.entry-form header { text-align: center; }
.entry-form header h2 { font-size: 22px; }
.entry-form header p { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.entry-facts { max-width: none; gap: 10px; padding: 16px 28px 28px; }
.entry-facts div { padding: 12px; text-align: center; }
.entry-facts dd { margin-top: 2px; font-size: 12px; }
@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; }
}