Compare commits
29
Commits
main
...
deploy/hel194
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb1f2b462 | ||
|
|
fe6b4e59ec | ||
|
|
27f2b0b69a | ||
|
|
b27016d71d | ||
|
|
96309eb2cc | ||
|
|
40f7a91a0f | ||
|
|
cad12b3d28 | ||
|
|
7e7354a809 | ||
|
|
642604f688 | ||
|
|
bf5754ee09 | ||
|
|
551909ad0c | ||
|
|
eefdc92ab6 | ||
|
|
fdc5616115 | ||
|
|
c4f7573653 | ||
|
|
5b3ebad757 | ||
|
|
b42b6481f5 | ||
|
|
e04f47b9d2 | ||
|
|
7fb97119ba | ||
|
|
815b68c1fc | ||
|
|
609ebffdc9 | ||
|
|
77fc625ded | ||
|
|
ece3e53472 | ||
|
|
5816e8aa71 | ||
|
|
4add17f2b8 | ||
|
|
95ad9c939a | ||
|
|
1fe073066e | ||
|
|
f16e8aad34 | ||
|
|
d59576739f | ||
|
|
85293b79df |
@@ -1,6 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.chrome-libs/
|
||||
.tmp-*/
|
||||
|
||||
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
||||
@@ -19,3 +20,10 @@ nul
|
||||
.agent_context/
|
||||
.kimi/
|
||||
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# local vendor for agent test env (not shipped)
|
||||
.vendor/
|
||||
vendor_wheels/
|
||||
|
||||
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
|
||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||
- 会话有效期 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 +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 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
|
||||
@@ -26,6 +26,9 @@
|
||||
- 计数来自 `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`.
|
||||
@@ -0,0 +1,29 @@
|
||||
# 任务清单
|
||||
|
||||
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
|
||||
|
||||
## 正在做
|
||||
|
||||
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
|
||||
|
||||
## 已做完
|
||||
|
||||
- 六类银行流水模板识别与解析。
|
||||
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
|
||||
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
|
||||
- 动态公司、用户、银行账户、别名与账户审核流程。
|
||||
- 导入接口加固、逐工作表确认、失败诊断和导出。
|
||||
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
|
||||
- 登录页视觉融合改版。
|
||||
|
||||
## 还没安排
|
||||
|
||||
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
|
||||
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
|
||||
- 月结、重开、调整/冲销审批与审计报告。
|
||||
- 服务端流水查询、筛选、分页和可追溯导出。
|
||||
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
|
||||
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
|
||||
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
|
||||
|
||||
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 最新进度
|
||||
|
||||
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
|
||||
|
||||
## 已经真实完成
|
||||
|
||||
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
|
||||
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
|
||||
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
|
||||
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
|
||||
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
|
||||
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
|
||||
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
|
||||
|
||||
## 仍未完成或不能当成已完成
|
||||
|
||||
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
|
||||
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
|
||||
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
|
||||
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
|
||||
|
||||
## 最近验证
|
||||
|
||||
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 项目需求
|
||||
|
||||
## 这个系统做什么
|
||||
|
||||
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
|
||||
|
||||
系统有两个独立入口:
|
||||
|
||||
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
|
||||
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
|
||||
|
||||
## 必须做到的业务规则
|
||||
|
||||
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
|
||||
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
|
||||
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
|
||||
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
|
||||
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
|
||||
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
|
||||
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
|
||||
|
||||
## 当前技术边界
|
||||
|
||||
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls` 与 `.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
|
||||
|
||||
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
+25
-12
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
|
||||
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:
|
||||
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||
salt = secrets.token_bytes(16)
|
||||
@@ -146,18 +158,19 @@ def authenticate(
|
||||
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
||||
``audit_log``; the password itself is never stored anywhere.
|
||||
"""
|
||||
window_start = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||
).isoformat()
|
||||
failures = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM login_attempts
|
||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||
""",
|
||||
(username, ip, window_start),
|
||||
).fetchone()
|
||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||
return None, "rate_limited"
|
||||
if not RATE_LIMIT_DISABLED:
|
||||
window_start = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||
).isoformat()
|
||||
failures = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM login_attempts
|
||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||
""",
|
||||
(username, ip, window_start),
|
||||
).fetchone()
|
||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||
return None, "rate_limited"
|
||||
|
||||
user = connection.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
"""Calculation window: start date, opening balances, coverage gaps and balances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from . import master_data, matching
|
||||
|
||||
|
||||
SETTING_START_DATE = "calculation_start_date"
|
||||
OPENING_STATUSES = ("draft", "confirmed", "superseded", "void")
|
||||
GAP_KINDS = ("head", "mid", "tail")
|
||||
GAP_STATUSES = ("open", "closed_attested")
|
||||
ATTESTATION_STATUSES = ("pending", "approved", "rejected")
|
||||
|
||||
|
||||
class LockedError(ValueError):
|
||||
"""Start date cannot change after a period is closed."""
|
||||
|
||||
|
||||
class ConflictError(ValueError):
|
||||
"""Revision or state conflict."""
|
||||
|
||||
|
||||
def utc_today() -> str:
|
||||
return datetime.now(timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
def _parse_decimal(value: object) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, TypeError):
|
||||
raise ValueError("金额格式无效。") from None
|
||||
|
||||
|
||||
def normalize_pair(company_a: int, company_b: int) -> tuple[int, int]:
|
||||
if company_a == company_b:
|
||||
raise ValueError("两家公司不能相同。")
|
||||
return (company_a, company_b) if company_a < company_b else (company_b, company_a)
|
||||
|
||||
|
||||
def signed_from_viewer(viewer_id: int, low_id: int, high_id: int, amount: Decimal) -> Decimal:
|
||||
return amount if viewer_id == low_id else -amount
|
||||
|
||||
|
||||
def get_setting(connection: sqlite3.Connection, key: str) -> str | None:
|
||||
row = connection.execute(
|
||||
"SELECT value FROM system_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row["value"] if row is not None else None
|
||||
|
||||
|
||||
def get_calculation_start_date(connection: sqlite3.Connection) -> str | None:
|
||||
return get_setting(connection, SETTING_START_DATE)
|
||||
|
||||
|
||||
def has_closed_periods(connection: sqlite3.Connection) -> bool:
|
||||
row = connection.execute("SELECT 1 FROM closed_periods LIMIT 1").fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def is_start_date_locked(connection: sqlite3.Connection) -> bool:
|
||||
return has_closed_periods(connection)
|
||||
|
||||
|
||||
def set_calculation_start_date(
|
||||
connection: sqlite3.Connection,
|
||||
start_date: str,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
start_date = master_data.validate_date(start_date, "起算日", required=True)
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("修改起算日必须填写原因。")
|
||||
if is_start_date_locked(connection):
|
||||
raise LockedError("已有结账月份,起算日已锁定。")
|
||||
before = get_calculation_start_date(connection)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
""",
|
||||
(SETTING_START_DATE, start_date, now, actor["id"]),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
SETTING_START_DATE,
|
||||
before,
|
||||
start_date,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"system_setting",
|
||||
0,
|
||||
"update_start_date",
|
||||
{"calculation_start_date": before},
|
||||
{"calculation_start_date": start_date},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
recalculate_coverage_gaps(connection)
|
||||
return {
|
||||
"calculation_start_date": start_date,
|
||||
"locked": False,
|
||||
"previous": before,
|
||||
}
|
||||
|
||||
|
||||
def start_date_payload(connection: sqlite3.Connection) -> dict[str, object]:
|
||||
return {
|
||||
"calculation_start_date": get_calculation_start_date(connection),
|
||||
"locked": is_start_date_locked(connection),
|
||||
}
|
||||
|
||||
|
||||
def _current_opening_revision(
|
||||
connection: sqlite3.Connection, low_id: int, high_id: int
|
||||
) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT * FROM opening_balance_revisions
|
||||
WHERE company_id_low = ? AND company_id_high = ?
|
||||
AND status IN ('draft', 'confirmed')
|
||||
ORDER BY revision DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(low_id, high_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _next_revision(connection: sqlite3.Connection, low_id: int, high_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT MAX(revision) AS max_rev FROM opening_balance_revisions
|
||||
WHERE company_id_low = ? AND company_id_high = ?
|
||||
""",
|
||||
(low_id, high_id),
|
||||
).fetchone()
|
||||
return int(row["max_rev"] or 0) + 1
|
||||
|
||||
|
||||
def create_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
company_a: int,
|
||||
company_b: int,
|
||||
amount: object,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
viewer_company_id: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
low_id, high_id = normalize_pair(company_a, company_b)
|
||||
decimal_amount = _parse_decimal(amount)
|
||||
if viewer_company_id is not None and viewer_company_id == high_id:
|
||||
decimal_amount = -decimal_amount
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("录入期初必须填写原因。")
|
||||
existing = _current_opening_revision(connection, low_id, high_id)
|
||||
if existing is not None and existing["status"] == "draft":
|
||||
raise ConflictError("该公司对已有待确认期初,请先确认或作废后再录入。")
|
||||
if existing is not None and existing["status"] == "confirmed":
|
||||
raise ConflictError("该对公司已有确认期初,请使用修订。")
|
||||
revision = _next_revision(connection, low_id, high_id)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO opening_balance_revisions (
|
||||
company_id_low, company_id_high, amount, currency, revision,
|
||||
status, reason, actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
low_id,
|
||||
high_id,
|
||||
str(decimal_amount),
|
||||
currency,
|
||||
revision,
|
||||
reason,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
row_id = int(cursor.lastrowid)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
row_id,
|
||||
"create_draft",
|
||||
None,
|
||||
{"company_id_low": low_id, "company_id_high": high_id, "amount": str(decimal_amount)},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, row_id)
|
||||
|
||||
|
||||
def confirm_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
revision_id: int,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
if row["status"] != "draft":
|
||||
raise ConflictError("只能确认待确认状态的期初。")
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("确认期初必须填写原因。")
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(reason, revision_id),
|
||||
)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
revision_id,
|
||||
"confirm",
|
||||
{"status": "draft"},
|
||||
{"status": "confirmed"},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, revision_id)
|
||||
|
||||
|
||||
def revise_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
revision_id: int,
|
||||
amount: object,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
if row["status"] != "confirmed":
|
||||
raise ConflictError("只能修订已确认期初。")
|
||||
decimal_amount = _parse_decimal(amount)
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("修订期初必须填写原因。")
|
||||
low_id = int(row["company_id_low"])
|
||||
high_id = int(row["company_id_high"])
|
||||
revision = _next_revision(connection, low_id, high_id)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
|
||||
(revision_id,),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO opening_balance_revisions (
|
||||
company_id_low, company_id_high, amount, currency, revision,
|
||||
status, reason, actor_user_id, actor_username, supersedes_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
low_id,
|
||||
high_id,
|
||||
str(decimal_amount),
|
||||
row["currency"],
|
||||
revision,
|
||||
reason,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
revision_id,
|
||||
now,
|
||||
),
|
||||
)
|
||||
new_id = int(cursor.lastrowid)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
new_id,
|
||||
"revise",
|
||||
{"amount": row["amount"], "revision_id": revision_id},
|
||||
{"amount": str(decimal_amount), "revision_id": new_id},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, new_id)
|
||||
|
||||
|
||||
def void_opening_balance(
|
||||
connection: sqlite3.Connection,
|
||||
revision_id: int,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
if row["status"] not in ("draft", "confirmed"):
|
||||
raise ConflictError("该期初已作废或已被替代。")
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 2:
|
||||
raise ValueError("作废期初必须填写原因。")
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
|
||||
(reason, revision_id),
|
||||
)
|
||||
master_data.record_change(
|
||||
connection,
|
||||
"opening_balance",
|
||||
revision_id,
|
||||
"void",
|
||||
{"status": row["status"]},
|
||||
{"status": "void"},
|
||||
reason,
|
||||
actor,
|
||||
)
|
||||
return opening_balance_payload(connection, revision_id)
|
||||
|
||||
|
||||
def opening_balance_payload(connection: sqlite3.Connection, revision_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.*, cl.name AS company_low_name, ch.name AS company_high_name
|
||||
FROM opening_balance_revisions r
|
||||
JOIN companies cl ON cl.id = r.company_id_low
|
||||
JOIN companies ch ON ch.id = r.company_id_high
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(revision_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("期初记录不存在。")
|
||||
amount = _parse_decimal(row["amount"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id_low": row["company_id_low"],
|
||||
"company_id_high": row["company_id_high"],
|
||||
"company_low_name": row["company_low_name"],
|
||||
"company_high_name": row["company_high_name"],
|
||||
"amount": str(amount),
|
||||
"currency": row["currency"],
|
||||
"revision": row["revision"],
|
||||
"status": row["status"],
|
||||
"reason": row["reason"],
|
||||
"actor_username": row["actor_username"],
|
||||
"supersedes_id": row["supersedes_id"],
|
||||
"created_at": row["created_at"],
|
||||
"direction_low": "receivable" if amount >= 0 else "payable",
|
||||
}
|
||||
|
||||
|
||||
def list_opening_balances(connection: sqlite3.Connection) -> list[dict[str, object]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.id FROM opening_balance_revisions r
|
||||
JOIN (
|
||||
SELECT company_id_low, company_id_high, MAX(revision) AS max_rev
|
||||
FROM opening_balance_revisions
|
||||
WHERE status IN ('draft', 'confirmed', 'void')
|
||||
GROUP BY company_id_low, company_id_high
|
||||
) latest ON latest.company_id_low = r.company_id_low
|
||||
AND latest.company_id_high = r.company_id_high
|
||||
AND latest.max_rev = r.revision
|
||||
WHERE r.status IN ('draft', 'confirmed', 'void')
|
||||
ORDER BY r.company_id_low, r.company_id_high
|
||||
"""
|
||||
).fetchall()
|
||||
return [opening_balance_payload(connection, row["id"]) for row in rows]
|
||||
|
||||
|
||||
def confirmed_opening_amount(
|
||||
connection: sqlite3.Connection, low_id: int, high_id: int
|
||||
) -> Decimal | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT amount FROM opening_balance_revisions
|
||||
WHERE company_id_low = ? AND company_id_high = ? AND status = 'confirmed'
|
||||
ORDER BY revision DESC LIMIT 1
|
||||
""",
|
||||
(low_id, high_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _parse_decimal(row["amount"])
|
||||
|
||||
|
||||
def _date_add(day: str, delta: int) -> str:
|
||||
return (date.fromisoformat(day) + timedelta(days=delta)).isoformat()
|
||||
|
||||
|
||||
def _merge_intervals(intervals: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
if not intervals:
|
||||
return []
|
||||
sorted_intervals = sorted(intervals, key=lambda item: item[0])
|
||||
merged = [sorted_intervals[0]]
|
||||
for start, end in sorted_intervals[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if date.fromisoformat(start) <= date.fromisoformat(_date_add(last_end, 1)):
|
||||
if date.fromisoformat(end) > date.fromisoformat(last_end):
|
||||
merged[-1] = (last_start, end)
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _account_coverage_intervals(
|
||||
connection: sqlite3.Connection, account: sqlite3.Row
|
||||
) -> list[tuple[str, str]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT MIN(substr(r.transaction_at, 1, 10)) AS interval_start,
|
||||
MAX(substr(r.transaction_at, 1, 10)) AS interval_end
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
WHERE b.upload_bank_account_id = ?
|
||||
OR r.own_account = ?
|
||||
GROUP BY s.id
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
(account["id"], account["account_number"]),
|
||||
).fetchall()
|
||||
return [
|
||||
(row["interval_start"], row["interval_end"])
|
||||
for row in rows
|
||||
if row["interval_start"] and row["interval_end"]
|
||||
]
|
||||
|
||||
|
||||
def _account_transaction_dates(
|
||||
connection: sqlite3.Connection, account: sqlite3.Row
|
||||
) -> list[str]:
|
||||
intervals = _account_coverage_intervals(connection, account)
|
||||
dates: list[str] = []
|
||||
for start, end in intervals:
|
||||
current = date.fromisoformat(start)
|
||||
end_day = date.fromisoformat(end)
|
||||
while current <= end_day:
|
||||
dates.append(current.isoformat())
|
||||
current += timedelta(days=1)
|
||||
return dates
|
||||
|
||||
|
||||
def _dates_to_intervals(dates: list[str]) -> list[tuple[str, str]]:
|
||||
if not dates:
|
||||
return []
|
||||
intervals: list[tuple[str, str]] = []
|
||||
start = dates[0]
|
||||
prev = dates[0]
|
||||
for current in dates[1:]:
|
||||
if date.fromisoformat(current) == date.fromisoformat(prev) + timedelta(days=1):
|
||||
prev = current
|
||||
continue
|
||||
intervals.append((start, prev))
|
||||
start = current
|
||||
prev = current
|
||||
intervals.append((start, prev))
|
||||
return intervals
|
||||
|
||||
|
||||
def _detect_gaps(
|
||||
required_start: str,
|
||||
required_end: str,
|
||||
covered: list[tuple[str, str]],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
if required_start > required_end:
|
||||
return []
|
||||
gaps: list[tuple[str, str, str]] = []
|
||||
if not covered:
|
||||
gaps.append((required_start, required_end, "head"))
|
||||
return gaps
|
||||
merged = _merge_intervals(covered)
|
||||
first_start, first_end = merged[0]
|
||||
if required_start < first_start:
|
||||
gaps.append((required_start, _date_add(first_start, -1), "head"))
|
||||
for index in range(len(merged) - 1):
|
||||
_, left_end = merged[index]
|
||||
right_start, _ = merged[index + 1]
|
||||
gap_start = _date_add(left_end, 1)
|
||||
gap_end = _date_add(right_start, -1)
|
||||
if gap_start <= gap_end:
|
||||
gaps.append((gap_start, gap_end, "mid"))
|
||||
last_start, last_end = merged[-1]
|
||||
tail_start = _date_add(last_end, 1)
|
||||
gap_from = max(tail_start, required_start)
|
||||
if gap_from <= required_end:
|
||||
kind = "tail" if last_end >= required_start else "head"
|
||||
gaps.append((gap_from, required_end, kind))
|
||||
return gaps
|
||||
|
||||
|
||||
def detect_account_gaps(
|
||||
connection: sqlite3.Connection,
|
||||
account: sqlite3.Row,
|
||||
*,
|
||||
start_date: str | None,
|
||||
today: str | None = None,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
if account["status"] != "active":
|
||||
return []
|
||||
today = today or utc_today()
|
||||
effective_from = account["effective_from"] or start_date or today
|
||||
effective_to = account["effective_to"] or today
|
||||
required_start = max(filter(None, [start_date, effective_from]))
|
||||
required_end = min(today, effective_to)
|
||||
if not master_data.is_usable(account, required_start):
|
||||
return []
|
||||
intervals = _account_coverage_intervals(connection, account)
|
||||
return _detect_gaps(required_start, required_end, intervals)
|
||||
|
||||
|
||||
def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
|
||||
start_date = get_calculation_start_date(connection)
|
||||
today = utc_today()
|
||||
accounts = connection.execute(
|
||||
"SELECT * FROM bank_accounts WHERE status = 'active'"
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
with connection:
|
||||
for account in accounts:
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM coverage_gaps
|
||||
WHERE bank_account_id = ? AND status = 'open'
|
||||
""",
|
||||
(account["id"],),
|
||||
)
|
||||
for gap_start, gap_end, gap_kind in detect_account_gaps(
|
||||
connection, account, start_date=start_date, today=today
|
||||
):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO coverage_gaps (
|
||||
bank_account_id, gap_start, gap_end, gap_kind,
|
||||
status, first_detected_at
|
||||
) VALUES (?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(bank_account_id, gap_start, gap_end) DO UPDATE SET
|
||||
gap_kind = excluded.gap_kind,
|
||||
status = CASE coverage_gaps.status
|
||||
WHEN 'closed_attested' THEN 'closed_attested'
|
||||
ELSE 'open'
|
||||
END
|
||||
""",
|
||||
(account["id"], gap_start, gap_end, gap_kind, utc_now()),
|
||||
)
|
||||
rebuilt += 1
|
||||
return rebuilt
|
||||
|
||||
|
||||
def coverage_gap_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
|
||||
account = master_data.get_account(connection, row["bank_account_id"])
|
||||
if account is None:
|
||||
raise ValueError("账户不存在。")
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (account["company_id"],)
|
||||
).fetchone()
|
||||
start = date.fromisoformat(row["gap_start"])
|
||||
end = date.fromisoformat(row["gap_end"])
|
||||
gap_days = (end - start).days + 1
|
||||
pending = connection.execute(
|
||||
"""
|
||||
SELECT id FROM no_business_attestations
|
||||
WHERE bank_account_id = ?
|
||||
AND gap_start = ? AND gap_end = ?
|
||||
AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
|
||||
).fetchone()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"company_id": account["company_id"],
|
||||
"company_name": company["name"] if company else None,
|
||||
"account_number_masked": master_data.mask_account_number(account["account_number"]),
|
||||
"gap_start": row["gap_start"],
|
||||
"gap_end": row["gap_end"],
|
||||
"gap_kind": row["gap_kind"],
|
||||
"gap_days": gap_days,
|
||||
"day_count": gap_days,
|
||||
"status": row["status"],
|
||||
"first_detected_at": row["first_detected_at"],
|
||||
"pending_attestation_id": pending["id"] if pending else None,
|
||||
}
|
||||
|
||||
|
||||
def list_coverage_gaps(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
clauses.append("ba.company_id = ?")
|
||||
params.append(company_id)
|
||||
if status is not None:
|
||||
clauses.append("g.status = ?")
|
||||
params.append(status)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT g.* FROM coverage_gaps g
|
||||
JOIN bank_accounts ba ON ba.id = g.bank_account_id
|
||||
{where}
|
||||
ORDER BY g.gap_start, g.bank_account_id
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [coverage_gap_payload(connection, row) for row in rows]
|
||||
|
||||
|
||||
def submit_no_business_attestation(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
bank_account_id: int,
|
||||
gap_start: str,
|
||||
gap_end: str,
|
||||
reason: str,
|
||||
evidence: str | None,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
gap_start = master_data.validate_date(gap_start, "断档起始日", required=True)
|
||||
gap_end = master_data.validate_date(gap_end, "断档结束日", required=True)
|
||||
if gap_start > gap_end:
|
||||
raise ValueError("断档起始日不能晚于结束日。")
|
||||
reason = str(reason or "").strip()
|
||||
if len(reason) < 5:
|
||||
raise ValueError("无业务说明至少 5 个字符。")
|
||||
account = master_data.get_account(connection, bank_account_id)
|
||||
if account["company_id"] != company_id:
|
||||
raise ValueError("只能为本公司账户提交说明。")
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO no_business_attestations (
|
||||
bank_account_id, gap_start, gap_end, reason, evidence,
|
||||
submitted_by, company_id, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
|
||||
""",
|
||||
(
|
||||
bank_account_id,
|
||||
gap_start,
|
||||
gap_end,
|
||||
reason,
|
||||
evidence,
|
||||
actor["id"],
|
||||
company_id,
|
||||
now,
|
||||
),
|
||||
)
|
||||
attestation_id = int(cursor.lastrowid)
|
||||
return attestation_payload(connection, attestation_id)
|
||||
|
||||
|
||||
def review_no_business_attestation(
|
||||
connection: sqlite3.Connection,
|
||||
attestation_id: int,
|
||||
decision: str,
|
||||
review_reason: str,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("无业务说明不存在。")
|
||||
if row["status"] != "pending":
|
||||
raise ConflictError("该说明已审核。")
|
||||
review_reason = str(review_reason or "").strip()
|
||||
if decision not in ("approve", "reject"):
|
||||
raise ValueError("审核决定无效。")
|
||||
if len(review_reason) < 2:
|
||||
raise ValueError("审核必须填写理由。")
|
||||
status = "approved" if decision == "approve" else "rejected"
|
||||
now = utc_now()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE no_business_attestations
|
||||
SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, actor["id"], now, review_reason, attestation_id),
|
||||
)
|
||||
if status == "approved":
|
||||
# Exact match first so the attested row survives recalculate's
|
||||
# "DELETE ... status='open'" and stays closed_attested.
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE coverage_gaps
|
||||
SET status = 'closed_attested'
|
||||
WHERE bank_account_id = ?
|
||||
AND gap_start = ? AND gap_end = ?
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
|
||||
)
|
||||
recalculate_coverage_gaps(connection)
|
||||
if status == "approved":
|
||||
# Overlap match: if recalculate shifts boundaries, still close
|
||||
# any open gap that intersects the attested interval.
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE coverage_gaps
|
||||
SET status = 'closed_attested'
|
||||
WHERE bank_account_id = ?
|
||||
AND status = 'open'
|
||||
AND gap_start <= ?
|
||||
AND gap_end >= ?
|
||||
""",
|
||||
(row["bank_account_id"], row["gap_end"], row["gap_start"]),
|
||||
)
|
||||
return attestation_payload(connection, attestation_id)
|
||||
|
||||
|
||||
def attestation_payload(connection: sqlite3.Connection, attestation_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("无业务说明不存在。")
|
||||
account = master_data.get_account(connection, row["bank_account_id"])
|
||||
if account is None:
|
||||
raise ValueError("账户不存在。")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"company_id": row["company_id"],
|
||||
"gap_start": row["gap_start"],
|
||||
"gap_end": row["gap_end"],
|
||||
"reason": row["reason"],
|
||||
"evidence": row["evidence"],
|
||||
"status": row["status"],
|
||||
"review_reason": row["review_reason"],
|
||||
"reviewed_at": row["reviewed_at"],
|
||||
"created_at": row["created_at"],
|
||||
"account_number_masked": master_data.mask_account_number(account["account_number"]),
|
||||
}
|
||||
|
||||
|
||||
def list_change_log(connection: sqlite3.Connection, limit: int = 100) -> list[dict[str, object]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT * FROM master_data_changes
|
||||
WHERE entity_type IN ('system_setting', 'opening_balance')
|
||||
ORDER BY id DESC LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
items: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
before = json.loads(row["before_json"]) if row["before_json"] else None
|
||||
after = json.loads(row["after_json"]) if row["after_json"] else None
|
||||
target = "起算日"
|
||||
if row["entity_type"] == "opening_balance":
|
||||
target = f"期初 #{row['entity_id']}"
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"actor_username": row["actor_username"],
|
||||
"action": row["action"],
|
||||
"target": target,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"reason": row["reason"],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _pair_net_change(
|
||||
connection: sqlite3.Connection,
|
||||
viewer_id: int,
|
||||
counterparty_id: int,
|
||||
*,
|
||||
cutoff: str | None,
|
||||
start_date: str | None,
|
||||
) -> Decimal:
|
||||
cutoff_where = ""
|
||||
params: list[object] = [viewer_id, counterparty_id, counterparty_id, viewer_id]
|
||||
if cutoff:
|
||||
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?"
|
||||
params.append(cutoff)
|
||||
start_where = ""
|
||||
if start_date:
|
||||
start_where = "AND substr(d.effective_at, 1, 10) >= ?"
|
||||
params.append(start_date)
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT d.amount, payer.company_id AS payer_id, payee.company_id AS payee_id
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN transfer_match_decisions d ON d.id = e.decision_id
|
||||
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE (
|
||||
(payer.company_id = ? AND payee.company_id = ?)
|
||||
OR (payer.company_id = ? AND payee.company_id = ?)
|
||||
)
|
||||
{cutoff_where}
|
||||
{start_where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
# 站在本公司视角:正数=应收方向。转出/垫付(payer)使应收增加。
|
||||
# 与 company_transfers.net = outflow − inflow 及页面文案口径一致。
|
||||
total = Decimal("0")
|
||||
for row in rows:
|
||||
amount = _parse_decimal(row["amount"])
|
||||
if row["payer_id"] == viewer_id:
|
||||
total += amount
|
||||
elif row["payee_id"] == viewer_id:
|
||||
total -= amount
|
||||
return total
|
||||
|
||||
|
||||
def pair_has_confirmed_opening(
|
||||
connection: sqlite3.Connection, company_a: int, company_b: int
|
||||
) -> bool:
|
||||
low_id, high_id = normalize_pair(company_a, company_b)
|
||||
return confirmed_opening_amount(connection, low_id, high_id) is not None
|
||||
|
||||
|
||||
def compute_pair_balance(
|
||||
connection: sqlite3.Connection,
|
||||
viewer_id: int,
|
||||
counterparty_id: int,
|
||||
*,
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff = cutoff or utc_today()
|
||||
cutoff = master_data.validate_date(cutoff, "截止日", required=True)
|
||||
start_date = get_calculation_start_date(connection)
|
||||
low_id, high_id = normalize_pair(viewer_id, counterparty_id)
|
||||
opening_amount: Decimal | None = None
|
||||
if start_date and pair_has_confirmed_opening(connection, viewer_id, counterparty_id):
|
||||
stored = confirmed_opening_amount(connection, low_id, high_id)
|
||||
assert stored is not None
|
||||
opening_amount = signed_from_viewer(viewer_id, low_id, high_id, stored)
|
||||
net_change = _pair_net_change(
|
||||
connection,
|
||||
viewer_id,
|
||||
counterparty_id,
|
||||
cutoff=cutoff,
|
||||
start_date=start_date,
|
||||
)
|
||||
unresolved = matching.unresolved_amounts(connection, viewer_id, cutoff=cutoff)
|
||||
pending_total = Decimal("0")
|
||||
for row in unresolved:
|
||||
pending_total += _parse_decimal(row["amount"] or "0")
|
||||
basis = "full" if opening_amount is not None and start_date else "net_change"
|
||||
payload: dict[str, object] = {
|
||||
"viewer_company_id": viewer_id,
|
||||
"counterparty_company_id": counterparty_id,
|
||||
"effective_at": cutoff,
|
||||
"calculation_start_date": start_date,
|
||||
"net_change": str(net_change),
|
||||
"basis": basis,
|
||||
"pending_unconfirmed": str(pending_total),
|
||||
"currency": "CNY",
|
||||
}
|
||||
if basis == "full" and opening_amount is not None:
|
||||
closing = opening_amount + net_change
|
||||
payload["opening"] = str(opening_amount)
|
||||
payload["closing"] = str(closing)
|
||||
payload["opening_effective_at"] = _date_add(start_date, -1)
|
||||
return payload
|
||||
|
||||
|
||||
def list_company_counterparties(
|
||||
connection: sqlite3.Connection, company_id: int
|
||||
) -> list[int]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT DISTINCT CASE
|
||||
WHEN payer.company_id = ? THEN payee.company_id
|
||||
ELSE payer.company_id
|
||||
END AS other_id
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN transfer_decision_participants payer ON payer.decision_id = e.decision_id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee ON payee.decision_id = e.decision_id AND payee.role = 'payee'
|
||||
WHERE payer.company_id = ? OR payee.company_id = ?
|
||||
""",
|
||||
(company_id, company_id, company_id),
|
||||
).fetchall()
|
||||
return [int(row["other_id"]) for row in rows if row["other_id"] is not None]
|
||||
|
||||
|
||||
def compute_company_balances(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
*,
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff = cutoff or utc_today()
|
||||
start_date = get_calculation_start_date(connection)
|
||||
counterparties = list_company_counterparties(connection, company_id)
|
||||
pairs = [
|
||||
compute_pair_balance(connection, company_id, other_id, cutoff=cutoff)
|
||||
for other_id in counterparties
|
||||
]
|
||||
complete = bool(start_date) and all(item["basis"] == "full" for item in pairs)
|
||||
return {
|
||||
"company_id": company_id,
|
||||
"cutoff": cutoff,
|
||||
"calculation_start_date": start_date,
|
||||
"basis": "full" if complete and pairs else "net_change",
|
||||
"pairs": pairs,
|
||||
}
|
||||
|
||||
|
||||
def opening_coverage_summary(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
companies = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM companies"
|
||||
).fetchone()["n"]
|
||||
recorded = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT company_id_low || ':' || company_id_high) AS n
|
||||
FROM opening_balance_revisions WHERE status = 'confirmed'
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
return {"company_count": companies, "confirmed_pair_count": recorded}
|
||||
@@ -0,0 +1,754 @@
|
||||
"""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 calculation, 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()
|
||||
calc_start = calculation.get_calculation_start_date(connection)
|
||||
start = calc_start or 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"],
|
||||
}
|
||||
)
|
||||
|
||||
# Enrich window with calculation-basis opening/ending when configured.
|
||||
balances = calculation.compute_company_balances(
|
||||
connection, company_id, cutoff=end
|
||||
)
|
||||
has_opening = balances.get("basis") == "full"
|
||||
opening_total = _ZERO
|
||||
ending_total = _ZERO
|
||||
if has_opening:
|
||||
for pair in balances.get("pairs") or []:
|
||||
opening_total += _as_decimal(pair.get("opening") or "0")
|
||||
ending_total += _as_decimal(pair.get("closing") or "0")
|
||||
|
||||
return {
|
||||
"own_company": own,
|
||||
"window": {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"has_opening": has_opening,
|
||||
"opening": _money(opening_total) if has_opening else None,
|
||||
"ending": _money(ending_total) if has_opening else None,
|
||||
"basis": balances.get("basis"),
|
||||
"calculation_start_date": balances.get("calculation_start_date"),
|
||||
},
|
||||
"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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -569,6 +569,448 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
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;
|
||||
""",
|
||||
),
|
||||
|
||||
Migration(
|
||||
version=8,
|
||||
name="0008_calculation_window",
|
||||
# HEL-194/202: opening balances, coverage gaps, no-business attestations.
|
||||
# Reuses system_settings / system_setting_changes from 0007; does not
|
||||
# recreate them. Extends master_data_changes CHECK and overlays the
|
||||
# calculation_start_date filter onto eligible_intercompany_events.
|
||||
up="""
|
||||
CREATE TABLE closed_periods (
|
||||
year_month TEXT PRIMARY KEY,
|
||||
closed_at TEXT NOT NULL,
|
||||
closed_by INTEGER REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE opening_balance_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id_low INTEGER NOT NULL REFERENCES companies (id),
|
||||
company_id_high INTEGER NOT NULL REFERENCES companies (id),
|
||||
amount TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
revision INTEGER NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('draft', 'confirmed', 'superseded', 'void')),
|
||||
reason TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
supersedes_id INTEGER REFERENCES opening_balance_revisions (id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (company_id_low, company_id_high, revision),
|
||||
CHECK (company_id_low < company_id_high)
|
||||
);
|
||||
|
||||
CREATE TABLE coverage_gaps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
|
||||
gap_start TEXT NOT NULL,
|
||||
gap_end TEXT NOT NULL,
|
||||
gap_kind TEXT NOT NULL CHECK (gap_kind IN ('head', 'mid', 'tail')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'closed_attested')),
|
||||
first_detected_at TEXT NOT NULL,
|
||||
UNIQUE (bank_account_id, gap_start, gap_end)
|
||||
);
|
||||
|
||||
CREATE TABLE no_business_attestations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
|
||||
gap_start TEXT NOT NULL,
|
||||
gap_end TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
evidence TEXT,
|
||||
submitted_by INTEGER REFERENCES users (id),
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
reviewed_by INTEGER REFERENCES users (id),
|
||||
reviewed_at TEXT,
|
||||
review_reason TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_opening_balance_pair ON opening_balance_revisions (company_id_low, company_id_high);
|
||||
CREATE INDEX idx_coverage_gaps_account ON coverage_gaps (bank_account_id);
|
||||
CREATE INDEX idx_attestations_company ON no_business_attestations (company_id);
|
||||
|
||||
CREATE TABLE master_data_changes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL
|
||||
CHECK (entity_type IN (
|
||||
'company', 'user', 'bank_account', 'account_alias',
|
||||
'personal_transit_mapping', 'system_setting', 'opening_balance'
|
||||
)),
|
||||
entity_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
reason TEXT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
|
||||
DROP TABLE master_data_changes;
|
||||
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
|
||||
|
||||
DROP VIEW IF EXISTS eligible_intercompany_events;
|
||||
CREATE VIEW eligible_intercompany_events AS
|
||||
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
|
||||
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
|
||||
payer.company_id AS payer_company_id,
|
||||
payer.bank_account_id AS payer_account_id,
|
||||
payee.company_id AS payee_company_id,
|
||||
payee.bank_account_id AS payee_account_id,
|
||||
d.pairing AS pairing, d.rule_version AS rule_version,
|
||||
(SELECT COUNT(*) FROM transfer_decision_observations o
|
||||
WHERE o.decision_id = d.id) AS evidence_count
|
||||
FROM current_transfer_decisions c
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants payer
|
||||
ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee
|
||||
ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
|
||||
AND (d.pairing = 'paired' OR d.locked = 1)
|
||||
AND (
|
||||
(SELECT value FROM system_settings WHERE key = 'calculation_start_date') IS NULL
|
||||
OR substr(d.effective_at, 1, 10) >= (
|
||||
SELECT value FROM system_settings WHERE key = 'calculation_start_date'
|
||||
)
|
||||
);
|
||||
""",
|
||||
down="""
|
||||
DROP VIEW IF EXISTS eligible_intercompany_events;
|
||||
CREATE VIEW eligible_intercompany_events AS
|
||||
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
|
||||
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
|
||||
payer.company_id AS payer_company_id,
|
||||
payer.bank_account_id AS payer_account_id,
|
||||
payee.company_id AS payee_company_id,
|
||||
payee.bank_account_id AS payee_account_id,
|
||||
d.pairing AS pairing, d.rule_version AS rule_version,
|
||||
(SELECT COUNT(*) FROM transfer_decision_observations o
|
||||
WHERE o.decision_id = d.id) AS evidence_count
|
||||
FROM current_transfer_decisions c
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants payer
|
||||
ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee
|
||||
ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
|
||||
AND (d.pairing = 'paired' OR d.locked = 1);
|
||||
|
||||
CREATE TABLE master_data_changes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL
|
||||
CHECK (entity_type IN (
|
||||
'company', 'user', 'bank_account', 'account_alias',
|
||||
'personal_transit_mapping'
|
||||
)),
|
||||
entity_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
reason TEXT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes
|
||||
WHERE entity_type NOT IN ('system_setting', 'opening_balance');
|
||||
DROP TABLE master_data_changes;
|
||||
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
|
||||
|
||||
DROP INDEX IF EXISTS idx_attestations_company;
|
||||
DROP INDEX IF EXISTS idx_coverage_gaps_account;
|
||||
DROP INDEX IF EXISTS idx_opening_balance_pair;
|
||||
DROP TABLE IF EXISTS no_business_attestations;
|
||||
DROP TABLE IF EXISTS coverage_gaps;
|
||||
DROP TABLE IF EXISTS opening_balance_revisions;
|
||||
DROP TABLE IF EXISTS closed_periods;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import sqlite3
|
||||
import tempfile
|
||||
|
||||
from . import auth
|
||||
from . import ledger_events
|
||||
from . import matching
|
||||
from .db import utc_now
|
||||
from .models import SheetResult, StatementBatch
|
||||
@@ -648,6 +649,7 @@ def review_sheets(
|
||||
[item["id"] for item in confirmed_rows],
|
||||
actor=actor,
|
||||
)
|
||||
ledger_events.reconcile_bank_events(connection, actor=actor)
|
||||
if began:
|
||||
connection.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Canonical intercompany ledger events and the revision chain (B-44).
|
||||
|
||||
Bank source rows and approved manual records are immutable evidence. This
|
||||
module turns them into one canonical ledger event each through ``reconcile_*``
|
||||
functions and an append-only revision chain. Corrections are never in-place
|
||||
edits: a reversal or adjustment is a new ledger event with its own effective
|
||||
date, and the original event keeps its history so no earlier cutoff is
|
||||
rewritten. Current revisions and source claims are rebuildable projections.
|
||||
|
||||
Only B-43 ``eligible_intercompany_events`` feeds bank facts here; same-company
|
||||
transfers, external transactions, unresolved rows and unlocked single
|
||||
observations never reach the confirmed balance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .subjects import MIRROR, SUBJECTS, mirror_subject
|
||||
|
||||
|
||||
class LedgerConflictError(ValueError):
|
||||
"""A revision/claim/idempotency conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class LedgerInputError(ValueError):
|
||||
"""Invalid input for a ledger operation (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
|
||||
|
||||
|
||||
def amount_scale(amount: object) -> int:
|
||||
"""Decimal places of a decimal-string amount, never negative."""
|
||||
try:
|
||||
exponent = Decimal(str(amount)).as_tuple().exponent
|
||||
except InvalidOperation:
|
||||
return 0
|
||||
return max(0, -int(exponent))
|
||||
|
||||
|
||||
def parse_amount(amount: object) -> Decimal:
|
||||
"""Parse a positive, valid decimal-string amount."""
|
||||
try:
|
||||
value = Decimal(str(amount))
|
||||
except InvalidOperation:
|
||||
raise LedgerInputError("金额不是有效的十进制数。") from None
|
||||
if not value.is_finite() or value <= 0:
|
||||
raise LedgerInputError("金额必须大于零。")
|
||||
return value
|
||||
|
||||
|
||||
def _company_exists(connection: sqlite3.Connection, company_id: int, label: str) -> None:
|
||||
row = connection.execute(
|
||||
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LedgerInputError(f"{label}指向的公司不存在。")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revision helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_transaction(connection: sqlite3.Connection) -> bool:
|
||||
"""Begin an immediate transaction unless one is already open.
|
||||
|
||||
Write helpers may run standalone (they own the transaction) or nested
|
||||
inside a caller's transaction (e.g. the sheet-confirm flow); nested calls
|
||||
never start their own commit.
|
||||
"""
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
return began
|
||||
|
||||
|
||||
def current_revision(connection: sqlite3.Connection, ledger_event_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT r.* FROM current_ledger_event_revisions c
|
||||
JOIN ledger_event_revisions r ON r.id = c.revision_id
|
||||
WHERE c.ledger_event_id = ?
|
||||
""",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _event_lifecycle(connection: sqlite3.Connection, ledger_event_id: int) -> str | None:
|
||||
row = connection.execute(
|
||||
"SELECT lifecycle FROM ledger_events WHERE id = ?", (ledger_event_id,)
|
||||
).fetchone()
|
||||
return row["lifecycle"] if row is not None else None
|
||||
|
||||
|
||||
def _next_revision_number(connection: sqlite3.Connection, ledger_event_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(revision), 0) AS m FROM ledger_event_revisions WHERE ledger_event_id = ?",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
return int(row["m"]) + 1
|
||||
|
||||
|
||||
def create_event(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
state: str,
|
||||
effective_at: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
payer_company_id: int,
|
||||
payee_company_id: int,
|
||||
perspective_company_id: int | None,
|
||||
subject_code: str | None,
|
||||
source_kind: str,
|
||||
source_revision_token: str | None,
|
||||
posting_kind: str,
|
||||
reverses_ledger_event_id: int | None = None,
|
||||
adjusts_ledger_event_id: int | None = None,
|
||||
rule_version: str | None = None,
|
||||
evidence_json: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
reason: str | None = None,
|
||||
supersedes_revision_id: int | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Insert a new ledger event with one revision. Returns ``(event_id, revision_id)``."""
|
||||
if state == "confirmed":
|
||||
if perspective_company_id is None or subject_code is None:
|
||||
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
|
||||
if subject_code not in SUBJECTS:
|
||||
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if int(payer_company_id) == int(payee_company_id):
|
||||
raise LedgerInputError("付款公司与收款公司不能相同。")
|
||||
if perspective_company_id is not None and perspective_company_id not in (
|
||||
int(payer_company_id), int(payee_company_id),
|
||||
):
|
||||
raise LedgerInputError("视角公司必须是事件参与方。")
|
||||
began = _ensure_transaction(connection)
|
||||
try:
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
)
|
||||
event_id = int(cursor.lastrowid)
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
event_id,
|
||||
state=state,
|
||||
effective_at=effective_at,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
payer_company_id=payer_company_id,
|
||||
payee_company_id=payee_company_id,
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=subject_code,
|
||||
source_kind=source_kind,
|
||||
source_revision_token=source_revision_token,
|
||||
posting_kind=posting_kind,
|
||||
reverses_ledger_event_id=reverses_ledger_event_id,
|
||||
adjusts_ledger_event_id=adjusts_ledger_event_id,
|
||||
rule_version=rule_version,
|
||||
evidence_json=evidence_json,
|
||||
idempotency_key=idempotency_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
supersedes_revision_id=supersedes_revision_id,
|
||||
)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
def append_revision(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
state: str,
|
||||
effective_at: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
payer_company_id: int,
|
||||
payee_company_id: int,
|
||||
perspective_company_id: int | None,
|
||||
subject_code: str | None,
|
||||
source_kind: str,
|
||||
source_revision_token: str | None,
|
||||
posting_kind: str,
|
||||
reverses_ledger_event_id: int | None = None,
|
||||
adjusts_ledger_event_id: int | None = None,
|
||||
rule_version: str | None = None,
|
||||
evidence_json: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
reason: str | None = None,
|
||||
supersedes_revision_id: int | None = None,
|
||||
) -> int:
|
||||
"""Append one immutable revision and repoint the current projection."""
|
||||
if _event_lifecycle(connection, ledger_event_id) != "active":
|
||||
raise LedgerConflictError("该事件已停用,不能追加修订。")
|
||||
if state == "confirmed":
|
||||
if perspective_company_id is None or subject_code is None:
|
||||
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
|
||||
if subject_code not in SUBJECTS:
|
||||
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if perspective_company_id not in (int(payer_company_id), int(payee_company_id)):
|
||||
raise LedgerInputError("视角公司必须是事件参与方。")
|
||||
elif state != "pending_subject":
|
||||
raise LedgerInputError("事件状态必须是 pending_subject 或 confirmed。")
|
||||
revision = _next_revision_number(connection, ledger_event_id)
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_revisions (
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale, currency, payer_company_id, payee_company_id,
|
||||
perspective_company_id, subject_code, source_kind,
|
||||
source_revision_token, posting_kind, reverses_ledger_event_id,
|
||||
adjusts_ledger_event_id, rule_version, evidence_json,
|
||||
idempotency_key, actor_user_id, actor_username, reason,
|
||||
supersedes_revision_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale(amount), currency, payer_company_id, payee_company_id,
|
||||
perspective_company_id, subject_code, source_kind,
|
||||
source_revision_token, posting_kind, reverses_ledger_event_id,
|
||||
adjusts_ledger_event_id, rule_version, evidence_json,
|
||||
idempotency_key,
|
||||
actor["id"] if actor is not None else None,
|
||||
actor["username"] if actor is not None else None,
|
||||
reason, supersedes_revision_id, now,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(ledger_event_id, cursor.lastrowid),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bank_source_claim(connection: sqlite3.Connection, bank_event_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"SELECT * FROM ledger_event_bank_sources WHERE bank_event_id = ?",
|
||||
(bank_event_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def manual_source_claim(connection: sqlite3.Connection, manual_record_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(manual_record_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _facts_of(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object]:
|
||||
revision = current_revision(connection, ledger_event_id)
|
||||
if revision is None:
|
||||
return {}
|
||||
return {
|
||||
"amount": revision["amount"],
|
||||
"currency": revision["currency"],
|
||||
"effective_at": revision["effective_at"],
|
||||
"payer_company_id": revision["payer_company_id"],
|
||||
"payee_company_id": revision["payee_company_id"],
|
||||
}
|
||||
|
||||
|
||||
def _eligible_facts(event: sqlite3.Row) -> dict[str, object]:
|
||||
return {
|
||||
"amount": event["amount"],
|
||||
"currency": event["currency"],
|
||||
"effective_at": event["effective_at"],
|
||||
"payer_company_id": event["payer_company_id"],
|
||||
"payee_company_id": event["payee_company_id"],
|
||||
}
|
||||
|
||||
|
||||
def _has_reversal(connection: sqlite3.Connection, original_event_id: int) -> bool:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1 FROM ledger_event_revisions r
|
||||
JOIN current_ledger_event_revisions c ON c.revision_id = r.id
|
||||
WHERE r.reverses_ledger_event_id = ? AND r.posting_kind = 'reversal'
|
||||
LIMIT 1
|
||||
""",
|
||||
(original_event_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def create_reversal(
|
||||
connection: sqlite3.Connection,
|
||||
original_event_id: int,
|
||||
*,
|
||||
source_kind: str,
|
||||
source_revision_token: str | None = None,
|
||||
effective_at: str | None = None,
|
||||
reason: str,
|
||||
actor: sqlite3.Row | None,
|
||||
idempotency_key: str | None = None,
|
||||
rule_version: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Create an equal-amount, opposite-direction reversal as a new ledger event.
|
||||
|
||||
The subject mirrors the original (应收<->应付, 其他应收<->其他应付). ``effective_at``
|
||||
defaults to the original event's effective date so an earlier cutoff keeps
|
||||
the original impact and later cutoffs see the net zero. The original event
|
||||
is never modified or deleted.
|
||||
"""
|
||||
original = current_revision(connection, original_event_id)
|
||||
if original is None:
|
||||
raise LedgerConflictError("原事件不存在或没有当前修订。")
|
||||
if original["state"] != "confirmed":
|
||||
raise LedgerInputError("只有已确认事件才能生成冲销。")
|
||||
perspective = mirror_perspective(original)
|
||||
if effective_at is None:
|
||||
effective_at = original["effective_at"]
|
||||
return create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=effective_at,
|
||||
amount=original["amount"],
|
||||
currency=original["currency"],
|
||||
payer_company_id=original["payee_company_id"],
|
||||
payee_company_id=original["payer_company_id"],
|
||||
perspective_company_id=perspective,
|
||||
subject_code=mirror_subject(original["subject_code"]),
|
||||
source_kind=source_kind,
|
||||
source_revision_token=source_revision_token,
|
||||
posting_kind="reversal",
|
||||
reverses_ledger_event_id=original_event_id,
|
||||
rule_version=rule_version or original["rule_version"],
|
||||
idempotency_key=idempotency_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def mirror_perspective(revision: sqlite3.Row) -> int:
|
||||
"""The counterparty company from ``revision``'s perspective."""
|
||||
perspective = int(revision["perspective_company_id"])
|
||||
if perspective == int(revision["payer_company_id"]):
|
||||
return int(revision["payee_company_id"])
|
||||
return int(revision["payer_company_id"])
|
||||
|
||||
|
||||
def create_adjustment(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
effective_at: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
payer_company_id: int,
|
||||
payee_company_id: int,
|
||||
perspective_company_id: int,
|
||||
subject_code: str,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
rule_version: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Create an audit adjustment event; the original event stays unchanged."""
|
||||
return create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=effective_at,
|
||||
amount=str(parse_amount(amount)),
|
||||
currency=currency,
|
||||
payer_company_id=payer_company_id,
|
||||
payee_company_id=payee_company_id,
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=subject_code,
|
||||
source_kind="adjustment",
|
||||
source_revision_token=None,
|
||||
posting_kind="adjustment",
|
||||
adjusts_ledger_event_id=ledger_event_id,
|
||||
rule_version=rule_version or SUBJECT_RULE_VERSION,
|
||||
idempotency_key=idempotency_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def reopen_subject(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Reverse a confirmed event and re-open it for subject re-review.
|
||||
|
||||
Creates an equal-amount reversal plus a fresh ``pending_subject`` event
|
||||
that re-claims the original bank source, so the administrator can confirm
|
||||
a corrected subject. The original event and its reversal keep history.
|
||||
"""
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
if current is None or current["state"] != "confirmed":
|
||||
raise LedgerConflictError("只有已确认事件可以重新进入科目审核。")
|
||||
bank_claim = connection.execute(
|
||||
"SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ?",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
if bank_claim is None:
|
||||
raise LedgerInputError(
|
||||
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
|
||||
)
|
||||
if not _has_reversal(connection, ledger_event_id):
|
||||
create_reversal(
|
||||
connection, ledger_event_id,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
reason="科目复核:原确认事件冲销",
|
||||
actor=actor,
|
||||
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
|
||||
rule_version=current["rule_version"],
|
||||
)
|
||||
ev = connection.execute(
|
||||
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
|
||||
(bank_claim["bank_event_id"],),
|
||||
).fetchone()
|
||||
if ev is None:
|
||||
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
|
||||
event_id, revision_id = _create_bank_event(
|
||||
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
|
||||
replacing_claim=bank_claim,
|
||||
)
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bank event reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def reconcile_bank_events(
|
||||
connection: sqlite3.Connection, actor: sqlite3.Row | None = None
|
||||
) -> dict[str, object]:
|
||||
"""Reconcile the current eligible intercompany events into ledger events.
|
||||
|
||||
Idempotent: first sight creates a ``pending_subject`` event; a changed B-43
|
||||
decision updates a still-pending event's revision, or (for a confirmed
|
||||
event) creates a reversal plus a fresh pending event. A source that left
|
||||
the eligible set with a confirmed impact gets one reversal. Runs inside the
|
||||
caller's transaction when one is open, otherwise in its own transaction.
|
||||
"""
|
||||
began = _ensure_transaction(connection)
|
||||
try:
|
||||
eligible = {
|
||||
row["event_id"]: row
|
||||
for row in connection.execute(
|
||||
"SELECT * FROM eligible_intercompany_events"
|
||||
).fetchall()
|
||||
}
|
||||
claims = {
|
||||
row["bank_event_id"]: row
|
||||
for row in connection.execute(
|
||||
"SELECT * FROM ledger_event_bank_sources"
|
||||
).fetchall()
|
||||
}
|
||||
stats = {
|
||||
"created": 0, "updated_pending": 0, "reversal": 0,
|
||||
"reopened": 0, "unchanged": 0, "sources": len(eligible),
|
||||
}
|
||||
for bank_event_id, event in sorted(eligible.items()):
|
||||
claim = claims.get(bank_event_id)
|
||||
if claim is None:
|
||||
_create_bank_event(
|
||||
connection, event, actor, reason="B-43 事件首次入账,待确认科目"
|
||||
)
|
||||
stats["created"] += 1
|
||||
continue
|
||||
current = current_revision(connection, claim["ledger_event_id"])
|
||||
if current is None or _facts_of(connection, claim["ledger_event_id"]) != _eligible_facts(event):
|
||||
if current is not None and current["state"] == "confirmed":
|
||||
if not _has_reversal(connection, claim["ledger_event_id"]):
|
||||
create_reversal(
|
||||
connection, claim["ledger_event_id"],
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
reason="B-43 事件事实变更,原确认事件冲销",
|
||||
actor=actor,
|
||||
)
|
||||
stats["reversal"] += 1
|
||||
_create_bank_event(
|
||||
connection, event, actor,
|
||||
reason="B-43 事件事实变更后重新入账,待确认科目",
|
||||
replacing_claim=claim,
|
||||
)
|
||||
stats["reopened"] += 1
|
||||
elif current is None or current["state"] == "pending_subject":
|
||||
_append_bank_pending_revision(
|
||||
connection, claim["ledger_event_id"], event, actor
|
||||
)
|
||||
stats["updated_pending"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
for bank_event_id, claim in sorted(claims.items()):
|
||||
if bank_event_id in eligible:
|
||||
continue
|
||||
current = current_revision(connection, claim["ledger_event_id"])
|
||||
if current is not None and current["state"] == "confirmed":
|
||||
if not _has_reversal(connection, claim["ledger_event_id"]):
|
||||
create_reversal(
|
||||
connection, claim["ledger_event_id"],
|
||||
source_kind="bank",
|
||||
source_revision_token=None,
|
||||
reason="B-43 事件不再纳入往来,原确认事件冲销",
|
||||
actor=actor,
|
||||
)
|
||||
stats["reversal"] += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def _create_bank_event(
|
||||
connection: sqlite3.Connection,
|
||||
event: sqlite3.Row,
|
||||
actor: sqlite3.Row | None,
|
||||
*,
|
||||
reason: str,
|
||||
replacing_claim: sqlite3.Row | None = None,
|
||||
) -> tuple[int, int]:
|
||||
event_id, revision_id = create_event(
|
||||
connection,
|
||||
state="pending_subject",
|
||||
effective_at=event["effective_at"],
|
||||
amount=event["amount"],
|
||||
currency=event["currency"],
|
||||
payer_company_id=event["payer_company_id"],
|
||||
payee_company_id=event["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
posting_kind="normal",
|
||||
rule_version=SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(
|
||||
{
|
||||
"bank_event_id": event["event_id"],
|
||||
"decision_id": event["decision_id"],
|
||||
"pairing": event["pairing"],
|
||||
"evidence_count": event["evidence_count"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
if replacing_claim is not None:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
|
||||
WHERE bank_event_id = ?
|
||||
""",
|
||||
(event_id, event["event_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(event["event_id"], event_id),
|
||||
)
|
||||
from .subjects import store_suggestions
|
||||
|
||||
store_suggestions(connection, event_id)
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
def _append_bank_pending_revision(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
event: sqlite3.Row,
|
||||
actor: sqlite3.Row | None,
|
||||
) -> int:
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
ledger_event_id,
|
||||
state="pending_subject",
|
||||
effective_at=event["effective_at"],
|
||||
amount=event["amount"],
|
||||
currency=event["currency"],
|
||||
payer_company_id=event["payer_company_id"],
|
||||
payee_company_id=event["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
posting_kind="normal",
|
||||
rule_version=SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(
|
||||
{
|
||||
"bank_event_id": event["event_id"],
|
||||
"decision_id": event["decision_id"],
|
||||
"pairing": event["pairing"],
|
||||
"evidence_count": event["evidence_count"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
actor=actor,
|
||||
reason="B-43 事件事实更新,追加待审修订",
|
||||
supersedes_revision_id=current["id"] if current is not None else None,
|
||||
)
|
||||
from .subjects import store_suggestions
|
||||
|
||||
store_suggestions(connection, ledger_event_id)
|
||||
return revision_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projection rebuild
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rebuild_current_ledger_projection(connection: sqlite3.Connection) -> int:
|
||||
"""Rebuild current ledger revisions from the append-only log."""
|
||||
began = _ensure_transaction(connection)
|
||||
try:
|
||||
connection.execute("DELETE FROM current_ledger_event_revisions")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT e.id AS ledger_event_id,
|
||||
(SELECT r2.id FROM ledger_event_revisions r2
|
||||
WHERE r2.ledger_event_id = e.id
|
||||
ORDER BY r2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM ledger_events e
|
||||
WHERE e.lifecycle = 'active'
|
||||
""",
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
for row in rows:
|
||||
if row["latest_id"] is None:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(row["ledger_event_id"], row["latest_id"]),
|
||||
)
|
||||
rebuilt += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return rebuilt
|
||||
@@ -0,0 +1,818 @@
|
||||
"""Manual evidence records, administrator approval and audit-safe reversal.
|
||||
|
||||
Manual records are immutable submitted facts. Only an approved record becomes
|
||||
a canonical ledger event (``approve_new``) or joins one (``approve_link``);
|
||||
returned/exception/pending records never affect a balance and never leak to
|
||||
the counterparty. Approved facts change only through a ``reverse`` decision
|
||||
that creates an opposite new event (or detaches a linked claim) — the original
|
||||
is never edited. Idempotency keys and UNIQUE claims prevent double counting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .ledger_events import (
|
||||
LedgerConflictError,
|
||||
LedgerInputError,
|
||||
create_event,
|
||||
create_reversal,
|
||||
current_revision,
|
||||
manual_source_claim,
|
||||
)
|
||||
from .subjects import SUBJECTS
|
||||
|
||||
MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed")
|
||||
FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other")
|
||||
DATE_KEYS = ("occurred_at",)
|
||||
|
||||
|
||||
class ManualConflictError(ValueError):
|
||||
"""A claim/idempotency/revision conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class ManualInputError(ValueError):
|
||||
"""Invalid manual record input (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
def _parse_amount(amount: object) -> Decimal:
|
||||
try:
|
||||
value = Decimal(str(amount))
|
||||
except InvalidOperation:
|
||||
raise ManualInputError("金额不是有效的十进制数。") from None
|
||||
if not value.is_finite() or value <= 0:
|
||||
raise ManualInputError("金额必须大于零。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_date(value: object, field: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) < 10:
|
||||
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。")
|
||||
try:
|
||||
datetime.fromisoformat(text[:10])
|
||||
except ValueError:
|
||||
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None
|
||||
return text
|
||||
|
||||
|
||||
def _business_today() -> str:
|
||||
"""Shanghai business date (the default reversal effective date)."""
|
||||
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def submit(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
counterparty_company_id: int,
|
||||
occurred_at: str,
|
||||
direction: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
funding_source: str,
|
||||
requested_subject: str,
|
||||
request_key: str,
|
||||
actor: sqlite3.Row,
|
||||
bank_account_id: object = None,
|
||||
personal_transit_mapping_id: object = None,
|
||||
related_source_row_id: object = None,
|
||||
summary: object = None,
|
||||
reason: object = None,
|
||||
evidence: object = None,
|
||||
supersedes_record_id: object = None,
|
||||
) -> dict[str, object]:
|
||||
"""Submit one manual record for review. Idempotent on ``(company_id, request_key)``."""
|
||||
request_key = str(request_key or "").strip()
|
||||
if not request_key:
|
||||
raise ManualInputError("必须提供提交幂等键 request_key。")
|
||||
if int(company_id) == int(counterparty_company_id):
|
||||
raise ManualInputError("对方公司不能与本公司相同。")
|
||||
if direction not in ("outgoing", "incoming"):
|
||||
raise ManualInputError("方向必须是 outgoing 或 incoming。")
|
||||
if funding_source not in FUNDING_SOURCES:
|
||||
raise ManualInputError(f"资金来源必须是:{'、'.join(FUNDING_SOURCES)}。")
|
||||
if requested_subject not in SUBJECTS:
|
||||
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
_validate_date(occurred_at, "业务日期")
|
||||
currency = str(currency or "").strip()
|
||||
if not currency:
|
||||
raise ManualInputError("币种不能为空。")
|
||||
amount = str(_parse_amount(amount))
|
||||
|
||||
for label, raw in (
|
||||
("company_id", company_id), ("counterparty_company_id", counterparty_company_id),
|
||||
):
|
||||
row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone()
|
||||
if row is None:
|
||||
raise ManualInputError(f"{label} 指向的公司不存在。")
|
||||
|
||||
bank_account_id = _resolve_account_ref(
|
||||
connection, bank_account_id, company_id, "银行账户"
|
||||
)
|
||||
mapping_id = _resolve_account_ref(
|
||||
connection, personal_transit_mapping_id, company_id, "个人过账映射"
|
||||
)
|
||||
related_row = None
|
||||
if related_source_row_id not in (None, ""):
|
||||
related_row = connection.execute(
|
||||
"SELECT r.id, b.company_id FROM source_rows r "
|
||||
"JOIN sheet_batches s ON s.id = r.sheet_batch_id "
|
||||
"JOIN import_batches b ON b.id = s.import_batch_id "
|
||||
"WHERE r.id = ?",
|
||||
(int(related_source_row_id),),
|
||||
).fetchone()
|
||||
if related_row is None:
|
||||
raise ManualInputError("关联银行源行不存在。")
|
||||
|
||||
if funding_source == "approved_bank_account" and bank_account_id is None:
|
||||
raise ManualInputError("资金来源为已批准账户时必须指定银行账户。")
|
||||
if funding_source == "personal_transit" and mapping_id is None:
|
||||
raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。")
|
||||
|
||||
supersedes_id = None
|
||||
if supersedes_record_id not in (None, ""):
|
||||
parent = connection.execute(
|
||||
"SELECT id, company_id FROM manual_records WHERE id = ?",
|
||||
(int(supersedes_record_id),),
|
||||
).fetchone()
|
||||
if parent is None or parent["company_id"] != int(company_id):
|
||||
raise ManualInputError("supersedes_record_id 无效。")
|
||||
supersedes_id = int(supersedes_record_id)
|
||||
|
||||
now = utc_now()
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
# Re-check inside the write transaction: concurrent identical submits
|
||||
# serialize here, so a replay is found before any INSERT.
|
||||
existing = connection.execute(
|
||||
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
|
||||
(int(company_id), request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _record_payload(connection, existing["id"], idempotent_replay=True)
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_records (
|
||||
company_id, counterparty_company_id, occurred_at, direction,
|
||||
amount, amount_scale, currency, funding_source, bank_account_id,
|
||||
personal_transit_mapping_id, related_source_row_id,
|
||||
requested_subject, summary, reason, evidence_json, request_key,
|
||||
supersedes_record_id, submitted_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(company_id), int(counterparty_company_id), occurred_at, direction,
|
||||
amount, _scale_of(amount), currency, funding_source, bank_account_id,
|
||||
mapping_id, related_row["id"] if related_row is not None else None,
|
||||
requested_subject, str(summary or "") or None,
|
||||
str(reason or "") or None,
|
||||
json.dumps(evidence, ensure_ascii=False) if evidence else None,
|
||||
request_key, supersedes_id,
|
||||
actor["id"], now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# A concurrent identical submit won the race and committed first;
|
||||
# surface the existing record idempotently instead of a UNIQUE 500.
|
||||
if began:
|
||||
connection.rollback()
|
||||
existing = connection.execute(
|
||||
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
|
||||
(int(company_id), request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return _record_payload(connection, existing["id"], idempotent_replay=True)
|
||||
raise
|
||||
record_id = int(cursor.lastrowid)
|
||||
_append_decision(
|
||||
connection, record_id, state="pending", action="submit",
|
||||
reason=str(reason or "") or None, actor=actor,
|
||||
)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _record_payload(connection, record_id)
|
||||
|
||||
|
||||
def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
row = connection.execute(
|
||||
"SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ManualInputError(f"{label}不存在。")
|
||||
if row["company_id"] != int(company_id):
|
||||
raise ManualInputError(f"{label}必须属于提交公司。")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _scale_of(amount: str) -> int:
|
||||
exponent = Decimal(amount).as_tuple().exponent
|
||||
return max(0, -int(exponent))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _append_decision(
|
||||
connection: sqlite3.Connection,
|
||||
record_id: int,
|
||||
*,
|
||||
state: str,
|
||||
action: str,
|
||||
reason: str | None,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
supersedes_decision_id: int | None = None,
|
||||
) -> int:
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?",
|
||||
(record_id,),
|
||||
).fetchone()
|
||||
revision = int(row["m"]) + 1
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_record_decisions (
|
||||
record_id, revision, state, action, reason, actor_user_id,
|
||||
actor_username, idempotency_key, supersedes_decision_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
record_id, revision, state, action, reason,
|
||||
actor["id"], actor["username"], idempotency_key,
|
||||
supersedes_decision_id, now,
|
||||
),
|
||||
)
|
||||
decision_id = int(cursor.lastrowid)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record_id, decision_id),
|
||||
)
|
||||
return decision_id
|
||||
|
||||
|
||||
def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT d.* FROM current_manual_record_decisions c
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE c.record_id = ?
|
||||
""",
|
||||
(record_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def decide(
|
||||
connection: sqlite3.Connection,
|
||||
record_id: int,
|
||||
action: str,
|
||||
*,
|
||||
reason: str,
|
||||
expected_decision_id: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
subject_code: object = None,
|
||||
target_ledger_event_id: object = None,
|
||||
effective_at: object = None,
|
||||
) -> dict[str, object]:
|
||||
"""Apply an administrator decision to a manual record.
|
||||
|
||||
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
|
||||
existing ledger event without adding a second economic impact; ``return``
|
||||
and ``exception`` never produce a balance; ``reverse`` creates an opposite
|
||||
reversal event (or detaches a linked claim) with an independent business
|
||||
effective date — explicit ``effective_at`` or the approval business day.
|
||||
Replays return the earlier outcome via ``idempotency_key``.
|
||||
"""
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise ManualInputError("必须填写审核原因。")
|
||||
if action not in ("approve_new", "approve_link", "return", "exception", "reverse"):
|
||||
raise ManualInputError("未知的审核决定类型。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
if record is None:
|
||||
raise ManualConflictError("手工记录不存在。")
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?",
|
||||
(record_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _decision_payload(connection, record_id, existing["id"])
|
||||
|
||||
current = _current_decision(connection, record_id)
|
||||
if current is None:
|
||||
raise ManualConflictError("该记录没有当前状态。")
|
||||
if expected_decision_id is not None and int(expected_decision_id) != current["id"]:
|
||||
raise ManualConflictError("记录已发生变更,请刷新后重试。")
|
||||
|
||||
if action == "approve_new":
|
||||
outcome = _approve_new(
|
||||
connection, record, current, actor, subject_code, reason, request_key
|
||||
)
|
||||
elif action == "approve_link":
|
||||
outcome = _approve_link(
|
||||
connection, record, current, actor, target_ledger_event_id, reason,
|
||||
request_key,
|
||||
)
|
||||
elif action == "return":
|
||||
if current["state"] != "pending":
|
||||
raise ManualConflictError("只有待复核的记录可以退回。")
|
||||
decision_id = _append_decision(
|
||||
connection, record_id, state="returned", action=action,
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
outcome = {"decision_id": decision_id, "ledger_event_id": None}
|
||||
elif action == "exception":
|
||||
if current["state"] != "pending":
|
||||
raise ManualConflictError("只有待复核的记录可以转为异常。")
|
||||
decision_id = _append_decision(
|
||||
connection, record_id, state="exception", action=action,
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
outcome = {"decision_id": decision_id, "ledger_event_id": None}
|
||||
else: # reverse
|
||||
if current["state"] != "approved":
|
||||
raise ManualConflictError("只有已批准记录可以冲销。")
|
||||
outcome = _reverse(
|
||||
connection, record, current, actor, request_key, reason,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
|
||||
_store_audit(connection, record, current, action, outcome, reason, actor)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _decision_payload(connection, record_id, outcome["decision_id"])
|
||||
|
||||
|
||||
def _approve_new(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
subject_code: object,
|
||||
reason: str,
|
||||
idempotency_key: str | None,
|
||||
) -> dict[str, object]:
|
||||
subject = str(subject_code or record["requested_subject"] or "")
|
||||
if subject not in SUBJECTS:
|
||||
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if record["direction"] == "outgoing":
|
||||
payer, payee = record["company_id"], record["counterparty_company_id"]
|
||||
else:
|
||||
payer, payee = record["counterparty_company_id"], record["company_id"]
|
||||
event_id, _revision_id = create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=record["occurred_at"],
|
||||
amount=record["amount"],
|
||||
currency=record["currency"],
|
||||
payer_company_id=payer,
|
||||
payee_company_id=payee,
|
||||
perspective_company_id=record["company_id"],
|
||||
subject_code=subject,
|
||||
source_kind="manual",
|
||||
source_revision_token=None,
|
||||
posting_kind="normal",
|
||||
rule_version="manual-record-v1",
|
||||
evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="approved", action="approve_new",
|
||||
reason=reason, actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record["id"], event_id),
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": event_id}
|
||||
|
||||
|
||||
def _approve_link(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
target_ledger_event_id: object,
|
||||
reason: str,
|
||||
idempotency_key: str | None,
|
||||
) -> dict[str, object]:
|
||||
if target_ledger_event_id in (None, ""):
|
||||
raise ManualInputError("approve_link 必须指定目标往来事件。")
|
||||
target = connection.execute(
|
||||
"SELECT id, lifecycle FROM ledger_events WHERE id = ?",
|
||||
(int(target_ledger_event_id),),
|
||||
).fetchone()
|
||||
if target is None or target["lifecycle"] != "active":
|
||||
raise ManualConflictError("目标往来事件不存在。")
|
||||
if manual_source_claim(connection, record["id"]) is not None:
|
||||
raise ManualConflictError("该手工记录已关联往来事件。")
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="approved", action="approve_link",
|
||||
reason=reason, actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record["id"], int(target_ledger_event_id)),
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)}
|
||||
|
||||
|
||||
def _reverse(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
request_key: str | None,
|
||||
reason: str,
|
||||
effective_at: object = None,
|
||||
) -> dict[str, object]:
|
||||
claim = manual_source_claim(connection, record["id"])
|
||||
if claim is None:
|
||||
raise ManualConflictError("该记录尚未关联往来事件,无法冲销。")
|
||||
event_id = claim["ledger_event_id"]
|
||||
revision = current_revision(connection, event_id)
|
||||
if revision is None:
|
||||
raise ManualConflictError("关联的往来事件没有当前修订。")
|
||||
|
||||
if _is_manual_creation_source(revision, record["id"]):
|
||||
# This record created the event (approve_new): it added the economic
|
||||
# impact, so reversing it must always produce an equal-amount reversal
|
||||
# event — even when other manual evidence was later linked onto the
|
||||
# same event. The original impact must not survive in balances.
|
||||
if effective_at is not None and str(effective_at).strip():
|
||||
effective_at = _validate_date(effective_at, "冲销生效日")
|
||||
else:
|
||||
effective_at = _business_today()
|
||||
create_reversal(
|
||||
connection,
|
||||
event_id,
|
||||
source_kind="manual",
|
||||
source_revision_token=str(record["id"]),
|
||||
effective_at=effective_at,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
idempotency_key=request_key,
|
||||
rule_version="manual-record-v1",
|
||||
)
|
||||
else:
|
||||
# The manual was linked evidence (approve_link) on an event it never
|
||||
# created: it added no second impact, so reversing detaches the claim
|
||||
# and the underlying economic impact stays.
|
||||
connection.execute(
|
||||
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(record["id"],),
|
||||
)
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="reversed", action="reverse",
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": None}
|
||||
|
||||
|
||||
def _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool:
|
||||
"""True when ``manual_record_id`` created the event via ``approve_new``.
|
||||
|
||||
The creation source is recorded in the event's immutable revision
|
||||
``evidence_json``; a linked evidence record is never the creation source
|
||||
and carries no second economic impact.
|
||||
"""
|
||||
if revision["source_kind"] != "manual":
|
||||
return False
|
||||
try:
|
||||
evidence = json.loads(revision["evidence_json"] or "{}")
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return evidence.get("manual_record_id") == manual_record_id
|
||||
|
||||
|
||||
def _store_audit(
|
||||
connection, record, current, action, outcome, reason, actor
|
||||
) -> None:
|
||||
from .auth import audit
|
||||
|
||||
audit(
|
||||
connection,
|
||||
f"manual_{action}",
|
||||
actor=actor,
|
||||
target=f"manual_record:{record['id']}",
|
||||
detail=(
|
||||
f"decision:{outcome['decision_id']};"
|
||||
f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Candidates and queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]:
|
||||
"""Deterministic hints shown before approval; never auto-merged."""
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
if record is None:
|
||||
return []
|
||||
wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing"
|
||||
date_prefix = str(record["occurred_at"])[:10]
|
||||
candidates: list[dict[str, object]] = []
|
||||
|
||||
bank_rows = connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing,
|
||||
e.payer_company_id, e.payee_company_id, e.decision_id
|
||||
FROM eligible_intercompany_events e
|
||||
WHERE (e.payer_company_id = ? AND e.payee_company_id = ?)
|
||||
OR (e.payer_company_id = ? AND e.payee_company_id = ?)
|
||||
ORDER BY e.event_id
|
||||
""",
|
||||
(
|
||||
record["company_id"], record["counterparty_company_id"],
|
||||
record["counterparty_company_id"], record["company_id"],
|
||||
),
|
||||
).fetchall()
|
||||
for row in bank_rows:
|
||||
if row["amount"] != record["amount"] or row["currency"] != record["currency"]:
|
||||
continue
|
||||
event_direction = (
|
||||
"outgoing" if row["payer_company_id"] == record["company_id"] else "incoming"
|
||||
)
|
||||
if event_direction != wanted_direction:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "bank_event",
|
||||
"ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]),
|
||||
"bank_event_id": row["event_id"],
|
||||
"decision_id": row["decision_id"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"effective_at": row["effective_at"],
|
||||
"pairing": row["pairing"],
|
||||
"hint": "已存在匹配的银行规范事件,建议关联",
|
||||
}
|
||||
)
|
||||
|
||||
manual_rows = connection.execute(
|
||||
"""
|
||||
SELECT m.id, m.company_id, m.counterparty_company_id, m.direction,
|
||||
m.amount, m.currency, m.occurred_at, d.state
|
||||
FROM manual_records m
|
||||
JOIN current_manual_record_decisions c ON c.record_id = m.id
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE m.id != ? AND m.amount = ? AND m.currency = ?
|
||||
AND (
|
||||
(m.company_id = ? AND m.counterparty_company_id = ?)
|
||||
OR (m.company_id = ? AND m.counterparty_company_id = ?)
|
||||
)
|
||||
ORDER BY m.id
|
||||
""",
|
||||
(
|
||||
record["id"], record["amount"], record["currency"],
|
||||
record["company_id"], record["counterparty_company_id"],
|
||||
record["counterparty_company_id"], record["company_id"],
|
||||
),
|
||||
).fetchall()
|
||||
for row in manual_rows:
|
||||
if row["direction"] != wanted_direction:
|
||||
continue
|
||||
if row["state"] not in ("approved", "pending"):
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "manual_record",
|
||||
"ledger_event_id": None,
|
||||
"manual_record_id": row["id"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"occurred_at": row["occurred_at"],
|
||||
"state": row["state"],
|
||||
"hint": "存在方向相反的同额手工记录,建议核对后关联",
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None:
|
||||
claim = connection.execute(
|
||||
"SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?",
|
||||
(bank_event_id,),
|
||||
).fetchone()
|
||||
return claim["ledger_event_id"] if claim is not None else None
|
||||
|
||||
|
||||
def list_records(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
state: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[sqlite3.Row]:
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)")
|
||||
params.extend([company_id, company_id])
|
||||
if state is not None:
|
||||
if state not in MANUAL_STATES:
|
||||
raise ManualInputError("无效的记录状态。")
|
||||
conditions.append("d.state = ?")
|
||||
params.append(state)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
return connection.execute(
|
||||
f"""
|
||||
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
|
||||
d.action AS action, d.reason AS decision_reason,
|
||||
d.actor_username AS decision_actor, d.created_at AS decision_at,
|
||||
c.name AS company_name, cc.name AS counterparty_company_name,
|
||||
u.username AS submitted_by_username
|
||||
FROM manual_records m
|
||||
JOIN current_manual_record_decisions c ON c.record_id = m.id
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
LEFT JOIN companies c ON c.id = m.company_id
|
||||
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
|
||||
LEFT JOIN users u ON u.id = m.submitted_by
|
||||
{where}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*params, max(1, int(limit))),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int:
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
connection.execute("DELETE FROM current_manual_record_decisions")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT m.id AS record_id,
|
||||
(SELECT d2.id FROM manual_record_decisions d2
|
||||
WHERE d2.record_id = m.id
|
||||
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM manual_records m
|
||||
"""
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
for row in rows:
|
||||
if row["latest_id"] is None:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(row["record_id"], row["latest_id"]),
|
||||
)
|
||||
rebuilt += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return rebuilt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]:
|
||||
rows = list_records(connection, limit=1000)
|
||||
row = next((item for item in rows if item["id"] == record_id), None)
|
||||
if row is None:
|
||||
raise ManualInputError("手工记录不存在。")
|
||||
payload = _row_payload(connection, row)
|
||||
if idempotent_replay:
|
||||
payload["idempotent_replay"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
|
||||
evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": row["company_name"],
|
||||
"counterparty_company_id": row["counterparty_company_id"],
|
||||
"counterparty_company_name": row["counterparty_company_name"],
|
||||
"occurred_at": row["occurred_at"],
|
||||
"direction": row["direction"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"funding_source": row["funding_source"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"personal_transit_mapping_id": row["personal_transit_mapping_id"],
|
||||
"related_source_row_id": row["related_source_row_id"],
|
||||
"requested_subject": row["requested_subject"],
|
||||
"summary": row["summary"],
|
||||
"reason": row["reason"],
|
||||
"request_key": row["request_key"],
|
||||
"supersedes_record_id": row["supersedes_record_id"],
|
||||
"submitted_by": row["submitted_by"],
|
||||
"submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None,
|
||||
"attachment_name": evidence.get("attachment_name"),
|
||||
"created_at": row["created_at"],
|
||||
"state": row["state"],
|
||||
"decision_id": row["decision_id"],
|
||||
"decision_revision": row["decision_revision"],
|
||||
"decision_action": row["action"],
|
||||
"decision_reason": row["decision_reason"],
|
||||
"decision_actor": row["decision_actor"],
|
||||
"decision_at": row["decision_at"],
|
||||
"candidates": find_candidates(connection, row["id"]),
|
||||
}
|
||||
|
||||
|
||||
def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT d.* FROM manual_record_decisions d
|
||||
WHERE d.id = ?
|
||||
""",
|
||||
(decision_id,),
|
||||
).fetchone()
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
claim = manual_source_claim(connection, record_id)
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"decision_id": decision_id,
|
||||
"revision": row["revision"],
|
||||
"state": row["state"],
|
||||
"action": row["action"],
|
||||
"reason": row["reason"],
|
||||
"actor_username": row["actor_username"],
|
||||
"created_at": row["created_at"],
|
||||
"ledger_event_id": claim["ledger_event_id"] if claim is not None else None,
|
||||
"requested_subject": record["requested_subject"],
|
||||
"amount": record["amount"],
|
||||
"currency": record["currency"],
|
||||
"occurred_at": record["occurred_at"],
|
||||
}
|
||||
@@ -262,7 +262,7 @@ def submit_bank_account(
|
||||
|
||||
if existing["company_id"] == company_id:
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
|
||||
|
||||
|
||||
def review_bank_account(
|
||||
|
||||
@@ -1523,7 +1523,7 @@ def unresolved_amounts(
|
||||
count as unresolved; paired intercompany, same-company and external events
|
||||
are resolved classifications and never appear here.
|
||||
"""
|
||||
cutoff_where = "AND d.effective_at <= ?" if cutoff else ""
|
||||
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?" if cutoff else ""
|
||||
params: list[object] = []
|
||||
if cutoff:
|
||||
params.append(cutoff)
|
||||
@@ -1563,6 +1563,87 @@ def exposed_status(decision: sqlite3.Row | dict) -> str:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
"""System settings and reminder item generation.
|
||||
|
||||
System-wide parameters (closing day, global start date, auto-reminder toggle
|
||||
and lead days) are persisted in ``system_settings`` with an append-only
|
||||
``system_setting_changes`` trail. Reminder pending items are derived from real
|
||||
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
|
||||
rather than hard-coded rosters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import utc_now
|
||||
|
||||
# Defaults are applied when a key is absent; the value type is always string.
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
"closing_day": "5",
|
||||
"start_date": "2026-01-01",
|
||||
"auto_remind": "1",
|
||||
"remind_days": "3",
|
||||
}
|
||||
|
||||
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _valid_date(value: str) -> bool:
|
||||
if not _DATE_RE.match(value):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
rows = connection.execute(
|
||||
"SELECT key, value FROM system_settings"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
|
||||
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
|
||||
cleaned: dict[str, str] = {}
|
||||
if "closing_day" in values:
|
||||
raw = str(values["closing_day"]).strip()
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
if day < 1 or day > 28:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
cleaned["closing_day"] = str(day)
|
||||
if "start_date" in values:
|
||||
raw = str(values["start_date"]).strip()
|
||||
if not _valid_date(raw):
|
||||
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
|
||||
cleaned["start_date"] = raw
|
||||
if "auto_remind" in values:
|
||||
raw = str(values["auto_remind"]).strip()
|
||||
if raw not in {"0", "1"}:
|
||||
return cleaned, "自动提醒开关须为 0 或 1。"
|
||||
cleaned["auto_remind"] = raw
|
||||
if "remind_days" in values:
|
||||
raw = str(values["remind_days"]).strip()
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "提前提醒天数须为不小于 1 的整数。"
|
||||
if days < 1 or days > 30:
|
||||
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
|
||||
cleaned["remind_days"] = str(days)
|
||||
return cleaned, None
|
||||
|
||||
|
||||
def update_settings(
|
||||
connection: sqlite3.Connection,
|
||||
values: dict[str, object],
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, str]:
|
||||
cleaned, error = validate_settings(values)
|
||||
if error is not None:
|
||||
raise ValueError(error)
|
||||
if not cleaned:
|
||||
raise ValueError("没有需要保存的设置项。")
|
||||
current = get_settings(connection)
|
||||
with connection:
|
||||
for key, new_value in cleaned.items():
|
||||
old_value = current.get(key)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, new_value, actor["id"], utc_now()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
old_value,
|
||||
new_value,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Reminder pending items
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _current_period() -> tuple[str, str]:
|
||||
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
|
||||
today = datetime.now(timezone.utc)
|
||||
start = today.strftime("%Y-%m-01")
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
end = f"{year + 1}-01-01"
|
||||
else:
|
||||
end = f"{year}-{month + 1:02d}-01"
|
||||
return start, end
|
||||
|
||||
|
||||
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
|
||||
"""Derive the list of pending reminder items for one company."""
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
return items
|
||||
|
||||
period_start, _ = _current_period()
|
||||
|
||||
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
|
||||
active_accounts = connection.execute(
|
||||
"""
|
||||
SELECT id, account_number FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'active'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
confirmed_this_period = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
|
||||
AND rv.sheet_batch_id IN (
|
||||
SELECT id FROM sheet_batches s
|
||||
WHERE s.period_end >= ?
|
||||
)
|
||||
""",
|
||||
(company_id, period_start),
|
||||
).fetchone()["n"]
|
||||
if active_accounts and confirmed_this_period == 0:
|
||||
items.append(
|
||||
{
|
||||
"kind": "流水未提交",
|
||||
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
|
||||
}
|
||||
)
|
||||
|
||||
# 2) 待确认工作表:仍有未确认的解析结果。
|
||||
pending_sheets = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_sheets:
|
||||
items.append(
|
||||
{
|
||||
"kind": "待确认工作表",
|
||||
"content": f"有 {pending_sheets} 个导入工作表尚未确认,请核对后确认。",
|
||||
}
|
||||
)
|
||||
|
||||
# 3) 待审核账户登记:处于待复核状态的银行账户。
|
||||
pending_accounts = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_accounts:
|
||||
items.append(
|
||||
{
|
||||
"kind": "账户登记",
|
||||
"content": f"有 {pending_accounts} 个银行账户登记待审核。",
|
||||
}
|
||||
)
|
||||
|
||||
# 4) 待确认往来事项:尚未解决的往来匹配。
|
||||
pending_transfers = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants p
|
||||
ON p.decision_id = d.id AND p.company_id = ?
|
||||
WHERE d.classification IN ('unresolved', 'needs_review')
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_transfers:
|
||||
items.append(
|
||||
{
|
||||
"kind": "往来待确认",
|
||||
"content": f"有 {pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def send_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
actor: sqlite3.Row,
|
||||
) -> tuple[list[dict[str, str]], str | None]:
|
||||
"""Create reminder rows for a company's pending items.
|
||||
|
||||
Returns ``(created, deadline)``. ``created`` is the list of persisted
|
||||
reminder payloads; ``deadline`` is derived from the closing-day setting.
|
||||
"""
|
||||
items = pending_items(connection, company_id)
|
||||
if not items:
|
||||
return [], None
|
||||
|
||||
settings = get_settings(connection)
|
||||
try:
|
||||
closing_day = int(settings["closing_day"])
|
||||
except ValueError:
|
||||
closing_day = 5
|
||||
today = datetime.now(timezone.utc)
|
||||
# Deadline: next month's closing day (current month if today is before it).
|
||||
if today.day < closing_day:
|
||||
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
|
||||
else:
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
year, month = year + 1, 1
|
||||
else:
|
||||
month += 1
|
||||
deadline = f"{year}-{month:02d}-{closing_day:02d}"
|
||||
|
||||
created: list[dict[str, str]] = []
|
||||
with connection:
|
||||
for item in items:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, kind, content, deadline, source,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
item["kind"],
|
||||
item["content"],
|
||||
deadline,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
created.append(
|
||||
{
|
||||
"id": cursor.lastrowid,
|
||||
"company_id": company_id,
|
||||
"kind": item["kind"],
|
||||
"content": item["content"],
|
||||
"deadline": deadline,
|
||||
"source": "manual",
|
||||
"status": "unread",
|
||||
"actor_username": actor["username"],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
)
|
||||
return created, deadline
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Statutory subject suggestions, mirror mapping and confirmation (B-44).
|
||||
|
||||
Subjects are stored from one participating company's perspective and the
|
||||
other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two
|
||||
companies can never record conflicting subjects. Bank summary/purpose text
|
||||
only ever produces a *suggestion*; nothing here confirms a subject
|
||||
automatically. Confirmation is an explicit administrator decision carrying
|
||||
``expected_revision`` and an idempotency key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from . import matching
|
||||
|
||||
SUBJECTS = ("receivable", "payable", "other_receivable", "other_payable")
|
||||
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
|
||||
|
||||
MIRROR = {
|
||||
"receivable": "payable",
|
||||
"payable": "receivable",
|
||||
"other_receivable": "other_payable",
|
||||
"other_payable": "other_receivable",
|
||||
}
|
||||
|
||||
SUBJECT_LABELS = {
|
||||
"receivable": "应收",
|
||||
"payable": "应付",
|
||||
"other_receivable": "其他应收",
|
||||
"other_payable": "其他应付",
|
||||
}
|
||||
|
||||
_FULL_WIDTH = str.maketrans(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
)
|
||||
|
||||
# Draft v1 dictionary. Exact-keyword matching only; every hit is a suggestion
|
||||
# and never an automatic posting. Trade-type keywords are deliberately absent
|
||||
# until the group supplies an approved dictionary (they always go to review).
|
||||
_LOAN_LIKE = ("借款", "往来款", "资金往来", "临时借款", "资金调拨", "代垫", "垫付")
|
||||
_REPAY_LIKE = ("还款", "归还借款", "归还往来款")
|
||||
|
||||
|
||||
class SubjectConflictError(ValueError):
|
||||
"""A stale revision or idempotency conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class SubjectInputError(ValueError):
|
||||
"""Invalid input for a subject decision (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
def mirror_subject(subject_code: str) -> str:
|
||||
if subject_code not in MIRROR:
|
||||
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
return MIRROR[subject_code]
|
||||
|
||||
|
||||
def subject_label(subject_code: str) -> str:
|
||||
return SUBJECT_LABELS.get(subject_code, subject_code)
|
||||
|
||||
|
||||
def _normalize(text: object) -> str:
|
||||
return re.sub(r"[\s\ufeff]+", "", str(text or "").translate(_FULL_WIDTH))
|
||||
|
||||
|
||||
def _bank_evidence_texts(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, str]:
|
||||
"""Purpose/summary text of the B-43 source rows behind a bank event."""
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
|
||||
WHERE bs.ledger_event_id = ?
|
||||
""",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"purpose": "", "summary": ""}
|
||||
decision = matching._current_decision_for_event(connection, row["bank_event_id"])
|
||||
if decision is None:
|
||||
return {"purpose": "", "summary": ""}
|
||||
observations = matching._decision_observations(connection, decision["id"])
|
||||
texts: dict[str, list[str]] = {"purpose": [], "summary": []}
|
||||
for observation in observations:
|
||||
source = connection.execute(
|
||||
"SELECT purpose, summary FROM source_rows WHERE id = ?",
|
||||
(observation["source_row_id"],),
|
||||
).fetchone()
|
||||
if source is None:
|
||||
continue
|
||||
for key in ("purpose", "summary"):
|
||||
value = str(source[key] or "").strip()
|
||||
if value:
|
||||
texts[key].append(value)
|
||||
return {
|
||||
"purpose": " ".join(texts["purpose"]),
|
||||
"summary": " ".join(texts["summary"]),
|
||||
}
|
||||
|
||||
|
||||
def compute_suggestions(
|
||||
connection: sqlite3.Connection, ledger_event_id: int
|
||||
) -> list[dict[str, object]]:
|
||||
""" Deterministic draft suggestions for a pending event, never confirmation.
|
||||
|
||||
Purpose rules take precedence over summary rules. When both loan-like and
|
||||
repay-like keywords match, both candidates are returned as a conflict for
|
||||
the reviewer; no priority breaks the tie.
|
||||
"""
|
||||
from .ledger_events import current_revision
|
||||
|
||||
revision = current_revision(connection, ledger_event_id)
|
||||
if revision is None or revision["state"] != "pending_subject":
|
||||
return []
|
||||
texts = _bank_evidence_texts(connection, ledger_event_id)
|
||||
purpose = _normalize(texts["purpose"])
|
||||
summary = _normalize(texts["summary"])
|
||||
search = purpose or summary
|
||||
payer = revision["payer_company_id"]
|
||||
payee = revision["payee_company_id"]
|
||||
|
||||
loan_hit = next((word for word in _LOAN_LIKE if word in search), None)
|
||||
repay_hit = next((word for word in _REPAY_LIKE if word in search), None)
|
||||
|
||||
suggestions: list[dict[str, object]] = []
|
||||
if loan_hit:
|
||||
suggestions.append(
|
||||
{
|
||||
"suggested_perspective_company_id": payer,
|
||||
"suggested_subject_code": "other_receivable",
|
||||
"reason": f"匹配建议词典「{loan_hit}」,建议付款方其他应收",
|
||||
"rule_version": SUBJECT_RULE_VERSION,
|
||||
"evidence": {
|
||||
"keyword": loan_hit,
|
||||
"matched_text": search,
|
||||
"approved": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
if repay_hit:
|
||||
suggestions.append(
|
||||
{
|
||||
"suggested_perspective_company_id": payee,
|
||||
"suggested_subject_code": "other_receivable",
|
||||
"reason": f"匹配建议词典「{repay_hit}」,建议收款方其他应收",
|
||||
"rule_version": SUBJECT_RULE_VERSION,
|
||||
"evidence": {
|
||||
"keyword": repay_hit,
|
||||
"matched_text": search,
|
||||
"approved": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return suggestions
|
||||
|
||||
|
||||
def store_suggestions(connection: sqlite3.Connection, ledger_event_id: int) -> int:
|
||||
"""Compute and append suggestions for a pending event. Returns count stored."""
|
||||
from .ledger_events import current_revision
|
||||
|
||||
revision = current_revision(connection, ledger_event_id)
|
||||
if revision is None or revision["state"] != "pending_subject":
|
||||
return 0
|
||||
stored = 0
|
||||
for suggestion in compute_suggestions(connection, ledger_event_id):
|
||||
from .db import utc_now
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_subject_suggestions (
|
||||
ledger_event_id, source_revision_id,
|
||||
suggested_perspective_company_id, suggested_subject_code,
|
||||
rule_version, evidence_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ledger_event_id, revision["id"],
|
||||
suggestion["suggested_perspective_company_id"],
|
||||
suggestion["suggested_subject_code"],
|
||||
suggestion["rule_version"],
|
||||
json.dumps(suggestion.get("evidence", {}), ensure_ascii=False),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def confirm_subject(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
perspective_company_id: int,
|
||||
subject_code: str,
|
||||
reason: str,
|
||||
expected_revision: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
"""Confirm a subject, turning a pending event into a confirmed revision."""
|
||||
from .ledger_events import append_revision, current_revision
|
||||
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise SubjectInputError("必须填写科目确认依据。")
|
||||
if subject_code not in SUBJECTS:
|
||||
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"""
|
||||
SELECT * FROM ledger_event_revisions
|
||||
WHERE ledger_event_id = ? AND idempotency_key = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(ledger_event_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, existing)
|
||||
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
if current is None:
|
||||
raise SubjectConflictError("该事件不存在或没有当前修订。")
|
||||
if current["state"] != "pending_subject":
|
||||
raise SubjectConflictError("只有待确认科目的事件可以确认科目。")
|
||||
# ``expected_revision`` may be the revision row id (what the API/UI
|
||||
# sends as ``ledger_revision_id``) or the per-event sequence number;
|
||||
# both identify the exact revision the client saw.
|
||||
if expected_revision is not None and int(expected_revision) not in (
|
||||
current["id"], current["revision"],
|
||||
):
|
||||
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
|
||||
participants = {current["payer_company_id"], current["payee_company_id"]}
|
||||
if perspective_company_id not in participants:
|
||||
raise SubjectInputError("视角公司必须是事件参与方。")
|
||||
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
ledger_event_id,
|
||||
state="confirmed",
|
||||
effective_at=current["effective_at"],
|
||||
amount=current["amount"],
|
||||
currency=current["currency"],
|
||||
payer_company_id=current["payer_company_id"],
|
||||
payee_company_id=current["payee_company_id"],
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=subject_code,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
posting_kind=current["posting_kind"],
|
||||
reverses_ledger_event_id=current["reverses_ledger_event_id"],
|
||||
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
|
||||
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
|
||||
evidence_json=current["evidence_json"],
|
||||
idempotency_key=request_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
supersedes_revision_id=current["id"],
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, row)
|
||||
|
||||
|
||||
def park_subject(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
disposition: str,
|
||||
reason: str,
|
||||
expected_revision: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
"""Record 退回/转异常 without confirming a statutory subject.
|
||||
|
||||
The event stays ``pending_subject`` so it never enters confirmed balances.
|
||||
``exception`` is hidden from the active review queue; ``return`` remains
|
||||
visible so the company can supplement materials.
|
||||
"""
|
||||
from .ledger_events import append_revision, current_revision
|
||||
|
||||
if disposition not in ("return", "exception"):
|
||||
raise SubjectInputError("科目处理只能是退回或转异常。")
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise SubjectInputError("必须填写处理依据。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"""
|
||||
SELECT * FROM ledger_event_revisions
|
||||
WHERE ledger_event_id = ? AND idempotency_key = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(ledger_event_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, existing)
|
||||
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
if current is None:
|
||||
raise SubjectConflictError("该事件不存在或没有当前修订。")
|
||||
if current["state"] != "pending_subject":
|
||||
raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。")
|
||||
if expected_revision is not None and int(expected_revision) not in (
|
||||
current["id"], current["revision"],
|
||||
):
|
||||
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
|
||||
evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {}
|
||||
evidence["admin_disposition"] = disposition
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
ledger_event_id,
|
||||
state="pending_subject",
|
||||
effective_at=current["effective_at"],
|
||||
amount=current["amount"],
|
||||
currency=current["currency"],
|
||||
payer_company_id=current["payer_company_id"],
|
||||
payee_company_id=current["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
posting_kind=current["posting_kind"],
|
||||
reverses_ledger_event_id=current["reverses_ledger_event_id"],
|
||||
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
|
||||
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(evidence, ensure_ascii=False),
|
||||
idempotency_key=request_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
supersedes_revision_id=current["id"],
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, row)
|
||||
|
||||
|
||||
def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]:
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],)
|
||||
).fetchone()
|
||||
return {
|
||||
"ledger_event_id": revision["ledger_event_id"],
|
||||
"revision_id": revision["id"],
|
||||
"revision": revision["revision"],
|
||||
"state": revision["state"],
|
||||
"effective_at": revision["effective_at"],
|
||||
"amount": revision["amount"],
|
||||
"currency": revision["currency"],
|
||||
"payer_company_id": revision["payer_company_id"],
|
||||
"payee_company_id": revision["payee_company_id"],
|
||||
"perspective_company_id": revision["perspective_company_id"],
|
||||
"perspective_company_name": company["name"] if company else None,
|
||||
"subject_code": revision["subject_code"],
|
||||
"subject_label": subject_label(revision["subject_code"])
|
||||
if revision["subject_code"]
|
||||
else None,
|
||||
"posting_kind": revision["posting_kind"],
|
||||
"source_kind": revision["source_kind"],
|
||||
"reason": revision["reason"],
|
||||
"created_at": revision["created_at"],
|
||||
}
|
||||
@@ -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()
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
|
||||
self.assertIsNotNone(user)
|
||||
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):
|
||||
def test_create_and_resolve_roundtrip(self) -> None:
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Tests for calculation window: start date, opening balances, coverage gaps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import auth, calculation, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
|
||||
class CalculationBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.db_path = Path(self.temp_dir.name) / "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.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_confirmed_row(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
account_id: int,
|
||||
own_account: str,
|
||||
at: str,
|
||||
income: str = "0",
|
||||
expense: str = "0",
|
||||
cp_account: str | None = None,
|
||||
sheet: str = "流水",
|
||||
source_row: int = 1,
|
||||
) -> 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', ?)
|
||||
""",
|
||||
(f"sha-{at}-{own_account}-{source_row}", utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_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, transaction_count, warnings, created_at
|
||||
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, sheet, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet, sheet_batch_id, utc_now()),
|
||||
)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, counterparty_account, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(sheet_batch_id, source_row, at, income, expense, own_account, cp_account, utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def add_confirmed_batch_range(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
account_id: int,
|
||||
own_account: str,
|
||||
start: str,
|
||||
end: str,
|
||||
sheet: str,
|
||||
) -> None:
|
||||
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', ?)
|
||||
""",
|
||||
(f"sha-batch-{sheet}", utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_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, transaction_count, warnings, created_at
|
||||
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 2, '[]', ?)
|
||||
""",
|
||||
(batch_id, sheet, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet, sheet_batch_id, utc_now()),
|
||||
)
|
||||
for source_row, day in ((1, start), (2, end)):
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, created_at
|
||||
) VALUES (?, ?, ?, '0', '0', ?, ?)
|
||||
""",
|
||||
(sheet_batch_id, source_row, f"{day}T10:00:00", own_account, utc_now()),
|
||||
)
|
||||
|
||||
|
||||
class StartDateTests(CalculationBase):
|
||||
def test_set_start_date_records_change(self) -> None:
|
||||
result = calculation.set_calculation_start_date(
|
||||
self.connection, "2026-01-01", "首次设定", self.admin
|
||||
)
|
||||
self.assertEqual("2026-01-01", result["calculation_start_date"])
|
||||
row = self.connection.execute(
|
||||
"SELECT 1 FROM master_data_changes WHERE entity_type = 'system_setting'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
def test_locked_after_closed_period(self) -> None:
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-01-01", "首次设定", self.admin
|
||||
)
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO closed_periods (year_month, closed_at, closed_by) VALUES ('2026-01', ?, ?)",
|
||||
(utc_now(), self.admin["id"]),
|
||||
)
|
||||
with self.assertRaises(calculation.LockedError):
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-02-01", "尝试修改", self.admin
|
||||
)
|
||||
|
||||
|
||||
class OpeningBalanceTests(CalculationBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-01-01", "测试起算日", self.admin
|
||||
)
|
||||
|
||||
def test_bilateral_conservation_on_storage(self) -> None:
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection,
|
||||
self.company_a,
|
||||
self.company_b,
|
||||
"100.00",
|
||||
"期初录入",
|
||||
self.admin,
|
||||
viewer_company_id=self.company_a,
|
||||
)
|
||||
low, high = calculation.normalize_pair(self.company_a, self.company_b)
|
||||
self.assertEqual(low, item["company_id_low"])
|
||||
stored = calculation.confirmed_opening_amount(self.connection, low, high)
|
||||
self.assertIsNone(stored)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认期初", self.admin
|
||||
)
|
||||
stored = calculation.confirmed_opening_amount(self.connection, low, high)
|
||||
self.assertEqual(Decimal("100.00"), stored)
|
||||
from_b = calculation.signed_from_viewer(self.company_b, low, high, stored)
|
||||
self.assertEqual(Decimal("-100.00"), from_b)
|
||||
|
||||
def test_confirmed_requires_revision_not_overwrite(self) -> None:
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "50", "录入", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
with self.assertRaises(calculation.ConflictError):
|
||||
calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "80", "重复录入", self.admin
|
||||
)
|
||||
revised = calculation.revise_opening_balance(
|
||||
self.connection, item["id"], "80", "修订", self.admin
|
||||
)
|
||||
self.assertEqual("draft", revised["status"])
|
||||
|
||||
|
||||
class CoverageGapTests(CalculationBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-06-01", "起算", self.admin
|
||||
)
|
||||
|
||||
def test_adjacent_intervals_no_mid_gap(self) -> None:
|
||||
self.add_confirmed_batch_range(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
start="2026-06-21",
|
||||
end="2026-07-21",
|
||||
sheet="批次A",
|
||||
)
|
||||
self.add_confirmed_batch_range(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
start="2026-07-22",
|
||||
end="2026-08-21",
|
||||
sheet="批次B",
|
||||
)
|
||||
calculation.recalculate_coverage_gaps(self.connection)
|
||||
mids = self.connection.execute(
|
||||
"SELECT * FROM coverage_gaps WHERE gap_kind = 'mid'"
|
||||
).fetchall()
|
||||
self.assertEqual([], mids)
|
||||
|
||||
def test_missing_day_mid_gap(self) -> None:
|
||||
self.add_confirmed_batch_range(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
start="2026-06-21",
|
||||
end="2026-07-21",
|
||||
sheet="批次A",
|
||||
)
|
||||
self.add_confirmed_batch_range(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
start="2026-07-23",
|
||||
end="2026-08-21",
|
||||
sheet="批次B",
|
||||
)
|
||||
calculation.recalculate_coverage_gaps(self.connection)
|
||||
gap = self.connection.execute(
|
||||
"""
|
||||
SELECT gap_start, gap_end FROM coverage_gaps
|
||||
WHERE gap_kind = 'mid' AND gap_start = '2026-07-22'
|
||||
"""
|
||||
).fetchone()
|
||||
self.assertIsNotNone(gap)
|
||||
self.assertEqual("2026-07-22", gap["gap_end"])
|
||||
|
||||
def test_attestation_closes_gap_without_bank_row(self) -> None:
|
||||
self.add_confirmed_row(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
at="2026-06-21T10:00:00",
|
||||
)
|
||||
calculation.recalculate_coverage_gaps(self.connection)
|
||||
gap = self.connection.execute(
|
||||
"SELECT * FROM coverage_gaps WHERE status = 'open'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(gap)
|
||||
before_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
|
||||
cashier_id = auth.create_user(
|
||||
self.connection, "cashier-a", "CashierA123", "company", self.company_a
|
||||
)
|
||||
cashier = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (cashier_id,)
|
||||
).fetchone()
|
||||
att = calculation.submit_no_business_attestation(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
bank_account_id=self.account_a["id"],
|
||||
gap_start=gap["gap_start"],
|
||||
gap_end=gap["gap_end"],
|
||||
reason="当日账户无资金往来",
|
||||
evidence=None,
|
||||
actor=cashier,
|
||||
)
|
||||
calculation.review_no_business_attestation(
|
||||
self.connection, att["id"], "approve", "审核通过", self.admin
|
||||
)
|
||||
after_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
|
||||
self.assertEqual(before_rows, after_rows)
|
||||
closed = self.connection.execute(
|
||||
"SELECT status FROM coverage_gaps WHERE id = ?", (gap["id"],)
|
||||
).fetchone()
|
||||
self.assertEqual("closed_attested", closed["status"])
|
||||
|
||||
|
||||
class BalanceBasisTests(CalculationBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-01-01", "起算", self.admin
|
||||
)
|
||||
|
||||
def test_without_opening_returns_net_change(self) -> None:
|
||||
row_a = self.add_confirmed_row(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
at="2026-01-05T10:00:00",
|
||||
expense="100.00",
|
||||
cp_account="6222000000000002",
|
||||
)
|
||||
row_b = self.add_confirmed_row(
|
||||
self.company_b,
|
||||
account_id=self.account_b["id"],
|
||||
own_account="6222000000000002",
|
||||
at="2026-01-05T11:00:00",
|
||||
income="100.00",
|
||||
cp_account="6222000000000001",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a, row_b])
|
||||
balance = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("net_change", balance["basis"])
|
||||
self.assertNotIn("closing", balance)
|
||||
|
||||
def test_with_opening_returns_full_basis(self) -> None:
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "200", "录入", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
balance = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("full", balance["basis"])
|
||||
self.assertEqual("200", balance["opening"])
|
||||
self.assertEqual("200", balance["closing"])
|
||||
|
||||
def test_pre_start_events_excluded(self) -> None:
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
row_a = self.add_confirmed_row(
|
||||
self.company_a,
|
||||
account_id=self.account_a["id"],
|
||||
own_account="6222000000000001",
|
||||
at="2025-12-31T10:00:00",
|
||||
expense="50.00",
|
||||
cp_account="6222000000000002",
|
||||
)
|
||||
row_b = self.add_confirmed_row(
|
||||
self.company_b,
|
||||
account_id=self.account_b["id"],
|
||||
own_account="6222000000000002",
|
||||
at="2025-12-31T11:00:00",
|
||||
income="50.00",
|
||||
cp_account="6222000000000001",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a, row_b])
|
||||
balance = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("0", balance["net_change"])
|
||||
|
||||
def test_cutoff_day_event_is_included(self) -> None:
|
||||
"""effective_at with time on the cutoff date must still count."""
|
||||
from test_matching import MatchingBase
|
||||
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
helper = object.__new__(MatchingBase)
|
||||
helper.connection = self.connection
|
||||
row_a = helper.add_row(
|
||||
self.company_a,
|
||||
own_account="6222000000000001",
|
||||
cp_account="6222000000000002",
|
||||
expense="80.00",
|
||||
at="2026-01-31T10:00:00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a])
|
||||
row_b = helper.add_row(
|
||||
self.company_b,
|
||||
own_account="6222000000000002",
|
||||
cp_account="6222000000000001",
|
||||
income="80.00",
|
||||
at="2026-01-31T11:00:00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_b])
|
||||
eligible = self.connection.execute(
|
||||
"SELECT effective_at, amount FROM eligible_intercompany_events"
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(eligible))
|
||||
self.assertEqual("2026-01-31T10:00:00", eligible[0]["effective_at"])
|
||||
# Full-timestamp string compare wrongly excludes the cutoff day.
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.connection.execute(
|
||||
"""
|
||||
SELECT 1 FROM eligible_intercompany_events
|
||||
WHERE effective_at <= '2026-01-31'
|
||||
"""
|
||||
).fetchall(),
|
||||
)
|
||||
balance = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
# 甲转出 80 → 应收方向为正
|
||||
self.assertEqual("80.00", balance["net_change"])
|
||||
self.assertEqual("80.00", balance["closing"])
|
||||
|
||||
def test_opening_plus_outflow_increases_receivable(self) -> None:
|
||||
"""期初应收 200 + 本期转出垫付 100 → 期末应收 300;乙方对称为应付 300。"""
|
||||
from test_matching import MatchingBase
|
||||
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
helper = object.__new__(MatchingBase)
|
||||
helper.connection = self.connection
|
||||
row_a = helper.add_row(
|
||||
self.company_a,
|
||||
own_account="6222000000000001",
|
||||
cp_account="6222000000000002",
|
||||
expense="100.00",
|
||||
at="2026-01-15T10:00:00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a])
|
||||
row_b = helper.add_row(
|
||||
self.company_b,
|
||||
own_account="6222000000000002",
|
||||
cp_account="6222000000000001",
|
||||
income="100.00",
|
||||
at="2026-01-15T11:00:00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_b])
|
||||
|
||||
bal_a = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("full", bal_a["basis"])
|
||||
self.assertEqual("200", bal_a["opening"])
|
||||
self.assertEqual("100.00", bal_a["net_change"])
|
||||
self.assertEqual("300.00", bal_a["closing"])
|
||||
|
||||
bal_b = calculation.compute_pair_balance(
|
||||
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("full", bal_b["basis"])
|
||||
self.assertEqual("-200", bal_b["opening"])
|
||||
self.assertEqual("-100.00", bal_b["net_change"])
|
||||
self.assertEqual("-300.00", bal_b["closing"])
|
||||
# 双边守恒
|
||||
self.assertEqual(
|
||||
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
class CalculationApiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
cls.db_path = Path(cls.temp_dir.name) / "app.db"
|
||||
cls.storage_dir = Path(cls.temp_dir.name) / "files"
|
||||
cls.storage_dir.mkdir()
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage_dir
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
auth.create_user(
|
||||
connection, "group-admin", "AdminPass123", "admin",
|
||||
must_change_password=False,
|
||||
)
|
||||
company_id = master_data.create_company(connection, "甲公司", None, None, None)
|
||||
auth.create_user(
|
||||
connection, "cashier-a", "CashierA123", "company", company_id,
|
||||
must_change_password=False,
|
||||
)
|
||||
connection.close()
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.AppHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.client = Client("127.0.0.1", cls.port)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_company_cannot_call_admin_start_date(self) -> None:
|
||||
self.client.post_json("/api/login", {
|
||||
"username": "cashier-a", "password": "CashierA123", "portal": "company",
|
||||
})
|
||||
status, _, _ = self.client.request(
|
||||
"PUT",
|
||||
"/api/admin/settings/calculation-start",
|
||||
body=json.dumps(
|
||||
{"calculation_start_date": "2026-01-01", "reason": "越权"}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(403, status)
|
||||
|
||||
def test_admin_can_set_start_date(self) -> None:
|
||||
client = Client("127.0.0.1", self.port)
|
||||
client.post_json("/api/login", {
|
||||
"username": "group-admin", "password": "AdminPass123", "portal": "admin",
|
||||
})
|
||||
status, _, body = client.request(
|
||||
"PUT",
|
||||
"/api/admin/settings/calculation-start",
|
||||
body=json.dumps(
|
||||
{"calculation_start_date": "2026-01-01", "reason": "初始化"}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
data = as_json(body)
|
||||
self.assertEqual("2026-01-01", data["calculation_start_date"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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=13", 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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=13", 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=13"', '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()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""HEL-168: company workspace pending unilateral counts sync after confirm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "Cashier123"
|
||||
|
||||
CCB_HEADER = [
|
||||
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
|
||||
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
|
||||
]
|
||||
|
||||
ACCOUNT_A = "6222000000001001"
|
||||
ACCOUNT_B = "6222000000001002"
|
||||
|
||||
|
||||
def workbook_bytes(rows) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "正常流水"
|
||||
sheet.append(CCB_HEADER)
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def outgoing(own: str, cp: str, amount: str, at: str) -> list:
|
||||
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
|
||||
|
||||
|
||||
class CompanyWorkspaceTodoTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
cls.initial_passwords: dict[str, str] = {}
|
||||
cls.company_a = cls._create_company("甲公司", "ws-cashier-a")
|
||||
cls.company_b = cls._create_company("乙公司", "ws-cashier-b")
|
||||
cls.cashier_a = cls._login_company("ws-cashier-a")
|
||||
cls.cashier_b = cls._login_company("ws-cashier-b")
|
||||
cls._approve_account(cls.cashier_a, ACCOUNT_A)
|
||||
cls._approve_account(cls.cashier_b, ACCOUNT_B)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
@classmethod
|
||||
def _create_company(cls, name: str, username: str) -> int:
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/companies",
|
||||
{"name": name, "username": username},
|
||||
)
|
||||
assert status == 200, data
|
||||
payload = as_json(data)
|
||||
cls.initial_passwords[username] = payload["initial_password"]
|
||||
return payload["company_id"]
|
||||
|
||||
@classmethod
|
||||
def _login_company(cls, username: str) -> Client:
|
||||
client = Client("127.0.0.1", cls.port)
|
||||
initial = cls.initial_passwords[username]
|
||||
status, _, data = client.post_json(
|
||||
"/api/login",
|
||||
{"username": username, "password": initial, "portal": "company"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = client.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": CASHIER_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def _approve_account(cls, company_client: Client, number: str) -> int:
|
||||
status, _, data = company_client.post_json(
|
||||
"/api/company/accounts",
|
||||
{
|
||||
"bank_name": "中信银行",
|
||||
"account_type": "基本户",
|
||||
"account_number": number,
|
||||
"start_date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert status == 200, data
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
status, _, data = cls.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
return account_id
|
||||
|
||||
def _seed_unilateral(self, amount: str, at: str, cp_account: str = "9999999999999999") -> dict:
|
||||
status, _, data = self.admin.post_multipart(
|
||||
"/api/parse",
|
||||
{"company_id": str(self.company_a)},
|
||||
f"单边-{amount}.xlsx",
|
||||
workbook_bytes([outgoing(ACCOUNT_A, cp_account, amount, at)]),
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
batch_id = as_json(data)["batch_id"]
|
||||
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
|
||||
self.assertEqual(200, status, data)
|
||||
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/batches/{batch_id}/confirm", {"sheets": names}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.cashier_a.get("/api/company/workspace")
|
||||
self.assertEqual(200, status, data)
|
||||
events = [
|
||||
e for e in as_json(data)["unilateral_events"] if e["amount"] == amount
|
||||
]
|
||||
self.assertEqual(1, len(events), data)
|
||||
return events[0]
|
||||
|
||||
def _workspace(self, client: Client) -> dict:
|
||||
status, _, data = client.get("/api/company/workspace")
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertEqual("ok", payload["status"])
|
||||
return payload
|
||||
|
||||
def test_seed_three_confirm_sync_and_isolation(self) -> None:
|
||||
# Seed 3 pending unilaterals for company A.
|
||||
e1 = self._seed_unilateral("11.00", "2026-03-01 10:00:00")
|
||||
e2 = self._seed_unilateral("22.00", "2026-03-02 10:00:00")
|
||||
e3 = self._seed_unilateral("33.00", "2026-03-03 10:00:00")
|
||||
seeded_ids = {e1["event_id"], e2["event_id"], e3["event_id"]}
|
||||
|
||||
ws = self._workspace(self.cashier_a)
|
||||
own = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
|
||||
self.assertEqual(3, len(own))
|
||||
self.assertEqual(ws["pending_unilateral"], ws["pending_total"])
|
||||
self.assertGreaterEqual(ws["pending_unilateral"], 3)
|
||||
# 两处数字与列表同一权威口径
|
||||
self.assertEqual(ws["pending_unilateral"], len(ws["unilateral_events"]))
|
||||
|
||||
# 另一公司看不到甲公司这 3 笔
|
||||
ws_b = self._workspace(self.cashier_b)
|
||||
self.assertEqual(
|
||||
[],
|
||||
[e for e in ws_b["unilateral_events"] if e["event_id"] in seeded_ids],
|
||||
)
|
||||
|
||||
# match-exceptions 与 workspace 同集合
|
||||
status, _, data = self.cashier_a.get("/api/company/match-exceptions")
|
||||
self.assertEqual(200, status, data)
|
||||
exceptions = as_json(data)["exceptions"]
|
||||
exception_ids = {e["event_id"] for e in exceptions}
|
||||
self.assertTrue(seeded_ids.issubset(exception_ids))
|
||||
|
||||
remaining = [e1, e2, e3]
|
||||
for expected in (2, 1, 0):
|
||||
event = remaining.pop(0)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{event['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": event["revision"],
|
||||
"request_key": f"hel168-{event['event_id']}-ok",
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
body = as_json(data)
|
||||
self.assertIn("workspace", body)
|
||||
left = [
|
||||
e
|
||||
for e in body["workspace"]["unilateral_events"]
|
||||
if e["event_id"] in seeded_ids
|
||||
]
|
||||
self.assertEqual(expected, len(left))
|
||||
self.assertEqual(body["workspace"]["pending_unilateral"], len(body["workspace"]["unilateral_events"]))
|
||||
|
||||
# 刷新等价:重新 GET 仍为同步后的数字
|
||||
ws = self._workspace(self.cashier_a)
|
||||
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
|
||||
self.assertEqual(expected, len(left))
|
||||
|
||||
# 重复确认:事件已离开待确认集合且 request_key 不同 → 404,数字不得误减
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{e1['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e1["revision"],
|
||||
"request_key": f"hel168-{e1['event_id']}-again",
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
ws = self._workspace(self.cashier_a)
|
||||
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
|
||||
self.assertEqual(0, len(left))
|
||||
|
||||
# 同一 request_key 重放:幂等成功,数字不误减
|
||||
e4 = self._seed_unilateral("44.00", "2026-03-04 10:00:00")
|
||||
key = f"hel168-{e4['event_id']}-replay"
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{e4['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e4["revision"],
|
||||
"request_key": key,
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
after_first = self._workspace(self.cashier_a)["pending_unilateral"]
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
f"/api/company/transfer-events/{e4['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e4["revision"],
|
||||
"request_key": key,
|
||||
"counterparty_company_id": self.company_b,
|
||||
"reason": "公司端确认单边流水",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual(after_first, as_json(data)["workspace"]["pending_unilateral"])
|
||||
self.assertEqual(after_first, self._workspace(self.cashier_a)["pending_unilateral"])
|
||||
|
||||
# 失败请求不得误减:用不存在的事件
|
||||
before = self._workspace(self.cashier_a)["pending_unilateral"]
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/transfer-events/999999/confirm",
|
||||
{
|
||||
"expected_revision": 1,
|
||||
"request_key": "hel168-fail",
|
||||
"counterparty_company_id": self.company_b,
|
||||
},
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
self.assertEqual(before, self._workspace(self.cashier_a)["pending_unilateral"])
|
||||
|
||||
# 乙公司不能确认甲公司事件(即使猜到 id)
|
||||
status, _, data = self.cashier_b.post_json(
|
||||
f"/api/company/transfer-events/{e2['event_id']}/confirm",
|
||||
{
|
||||
"expected_revision": e2["revision"],
|
||||
"request_key": "hel168-cross",
|
||||
"counterparty_company_id": self.company_a,
|
||||
},
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
def test_admin_forbidden_on_company_workspace(self) -> None:
|
||||
status, _, data = self.admin.get("/api/company/workspace")
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Dashboard aggregate helpers and admin HTTP endpoints (no openpyxl dependency)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import dashboard, master_data
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class DashboardUnitTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
migrate(self.connection)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def test_empty_dashboard(self) -> None:
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(0, payload["audit"]["total"])
|
||||
self.assertEqual([], payload["companies"])
|
||||
self.assertEqual(7, len(payload["weekly_flow"]["labels"]))
|
||||
|
||||
def test_pending_account_counts_as_medium(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
company_id = int(cursor.lastrowid)
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000001",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_pending_manual_counts_as_medium(self) -> None:
|
||||
from bank_importer import auth, manual_records
|
||||
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
self.connection.commit()
|
||||
auth.create_user(
|
||||
self.connection, "cashier-a", "CashierPass123", "company", company_id=int(a)
|
||||
)
|
||||
actor = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual_records.submit(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
counterparty_company_id=int(b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-manual-1",
|
||||
actor=actor,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_audit_total_equals_queue_sum(self) -> None:
|
||||
"""Homepage / badge / audit heading must share one backend total."""
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000002",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
|
||||
'single', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(counts, payload["audit"])
|
||||
self.assertEqual(
|
||||
counts["total"],
|
||||
counts["high"] + counts["medium"] + counts["low"],
|
||||
)
|
||||
self.assertEqual(1, counts["high"])
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(2, counts["total"])
|
||||
|
||||
def test_company_summaries_from_eligible_events(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'intercompany',
|
||||
'paired', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_decision_participants (
|
||||
decision_id, role, company_id, bank_account_id, resolve_method, created_at
|
||||
) VALUES (?, 'payer', ?, NULL, 'manual', ?), (?, 'payee', ?, NULL, 'manual', ?)
|
||||
""",
|
||||
(decision_id, a, now, decision_id, b, now),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
items, totals = dashboard.company_summaries(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, totals["detail_count"])
|
||||
by_name = {row["name"]: row for row in items}
|
||||
self.assertEqual(1, by_name["甲公司"]["detail_count"])
|
||||
self.assertEqual("10.00", by_name["甲公司"]["debit_wan"])
|
||||
self.assertEqual("-10.00", by_name["甲公司"]["net_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["credit_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["net_wan"])
|
||||
|
||||
detail = dashboard.company_peer_groups(
|
||||
self.connection, int(a), from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, len(detail["groups"]))
|
||||
self.assertEqual("乙公司", detail["groups"][0]["peer_name"])
|
||||
self.assertIsNone(detail["groups"][0]["opening"])
|
||||
|
||||
|
||||
class DashboardApiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/companies", {"name": "甲公司", "username": "cashier-a"}
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_dashboard_ok_for_admin(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("ok", data["status"])
|
||||
self.assertIn("audit", data)
|
||||
self.assertEqual(1, len(data["companies"]))
|
||||
self.assertEqual("甲公司", data["companies"][0]["name"])
|
||||
|
||||
def test_audit_counts_sync_after_account_review(self) -> None:
|
||||
"""Pending account raises dashboard.audit; approve brings total back down."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000099",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
before = as_json(raw)["audit"]
|
||||
self.assertEqual(200, status)
|
||||
self.assertGreaterEqual(before["medium"], 1)
|
||||
self.assertGreaterEqual(before["total"], 1)
|
||||
|
||||
status, _, raw = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 sync test"},
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
after = as_json(raw)["audit"]
|
||||
self.assertEqual(after["medium"], before["medium"] - 1)
|
||||
self.assertEqual(after["total"], before["total"] - 1)
|
||||
self.assertEqual(
|
||||
after["total"], after["high"] + after["medium"] + after["low"]
|
||||
)
|
||||
|
||||
def test_three_queue_audit_parity_and_dispose_sync(self) -> None:
|
||||
"""Seed account+manual+exception: list sizes == dashboard.audit; dispose syncs -1."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import auth, manual_records, master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
# Second company for manual counterparty.
|
||||
status, _, raw = self.admin.post_json(
|
||||
"/api/admin/companies", {"name": "乙公司", "username": "cashier-b-hel157"}
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
company_b = as_json(raw).get("company_id") or as_json(raw).get("id")
|
||||
self.assertIsNotNone(company_b)
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000157",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
|
||||
cashier = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
if cashier is None:
|
||||
auth.create_user(
|
||||
connection,
|
||||
"cashier-a",
|
||||
"CashierPass123",
|
||||
"company",
|
||||
company_id=company_id,
|
||||
)
|
||||
cashier = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual = manual_records.submit(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
counterparty_company_id=int(company_b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-queue-manual",
|
||||
actor=cashier,
|
||||
)
|
||||
|
||||
now = md.utc_now()
|
||||
event_id = connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
|
||||
'single', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
connection.commit()
|
||||
manual_id = manual["id"]
|
||||
manual_decision_id = manual["decision_id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def queue_sizes():
|
||||
st, _, body = self.admin.get("/api/admin/accounts?status=pending")
|
||||
self.assertEqual(200, st, body)
|
||||
accounts_n = len(as_json(body)["accounts"])
|
||||
st, _, body = self.admin.get("/api/admin/manual-records?state=pending")
|
||||
self.assertEqual(200, st, body)
|
||||
manuals_n = len(as_json(body)["records"])
|
||||
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||
self.assertEqual(200, st, body)
|
||||
exceptions_n = len(as_json(body)["exceptions"])
|
||||
return accounts_n, manuals_n, exceptions_n, accounts_n + manuals_n + exceptions_n
|
||||
|
||||
def assert_parity(expected_total: int) -> dict:
|
||||
st, _, body = self.admin.get("/api/admin/dashboard")
|
||||
self.assertEqual(200, st, body)
|
||||
audit = as_json(body)["audit"]
|
||||
accounts_n, manuals_n, exceptions_n, list_total = queue_sizes()
|
||||
self.assertEqual(expected_total, audit["total"])
|
||||
self.assertEqual(expected_total, list_total)
|
||||
self.assertEqual(
|
||||
audit["total"], audit["high"] + audit["medium"] + audit["low"]
|
||||
)
|
||||
self.assertGreaterEqual(accounts_n, 1 if expected_total >= 3 else 0)
|
||||
return {
|
||||
"audit": audit,
|
||||
"accounts": accounts_n,
|
||||
"manuals": manuals_n,
|
||||
"exceptions": exceptions_n,
|
||||
}
|
||||
|
||||
before = assert_parity(3)
|
||||
self.assertEqual(1, before["accounts"])
|
||||
self.assertEqual(1, before["manuals"])
|
||||
self.assertEqual(1, before["exceptions"])
|
||||
self.assertEqual(1, before["audit"]["high"])
|
||||
self.assertEqual(2, before["audit"]["medium"])
|
||||
|
||||
# Dispose account → total 2
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 three-queue dispose account"},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_account = assert_parity(2)
|
||||
|
||||
# Dispose manual → total 1
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{manual_id}/decisions",
|
||||
{
|
||||
"action": "approve_new",
|
||||
"reason": "HEL-157 three-queue dispose manual",
|
||||
"expected_decision_id": manual_decision_id,
|
||||
"request_key": "hel157-dispose-manual",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_manual = assert_parity(1)
|
||||
self.assertEqual(0, after_manual["manuals"])
|
||||
|
||||
# Dispose match exception via reverse → total 0
|
||||
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||
exceptions = as_json(body)["exceptions"]
|
||||
self.assertEqual(1, len(exceptions))
|
||||
target = exceptions[0]
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/transfer-events/{target['event_id']}/decisions",
|
||||
{
|
||||
"action": "reverse",
|
||||
"reason": "HEL-157 three-queue dispose match",
|
||||
"expected_revision": target["revision"],
|
||||
"request_key": "hel157-dispose-match",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_match = assert_parity(0)
|
||||
self.assertEqual(0, after_match["exceptions"])
|
||||
self.assertEqual(after_account["audit"]["total"] - 1, after_manual["audit"]["total"])
|
||||
self.assertEqual(after_manual["audit"]["total"] - 1, after_match["audit"]["total"])
|
||||
|
||||
def test_company_detail_missing(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(404, status)
|
||||
self.assertEqual("error", data["status"])
|
||||
|
||||
def test_company_detail_ok(self) -> None:
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
data = as_json(raw)
|
||||
company_id = data["companies"][0]["id"]
|
||||
status, _, raw = self.admin.get(
|
||||
f"/api/admin/dashboard/companies/{company_id}?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
detail = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], detail["groups"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,518 @@
|
||||
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bank_importer import auth, calculation, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
import server
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "CashierA123"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
def _prepare_chrome_libs() -> Path | None:
|
||||
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
|
||||
candidates = [ROOT / ".chrome-libs" / "lib"]
|
||||
for lib_dir in candidates:
|
||||
if (lib_dir / "libatk-1.0.so.0").exists():
|
||||
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
prefix = str(lib_dir)
|
||||
if prefix not in current.split(":"):
|
||||
os.environ["LD_LIBRARY_PATH"] = (
|
||||
f"{prefix}:{current}" if current else prefix
|
||||
)
|
||||
return lib_dir
|
||||
return None
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
if not sync_playwright:
|
||||
return False
|
||||
_prepare_chrome_libs()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
browser.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Hel203SourceContractTests(unittest.TestCase):
|
||||
"""不依赖浏览器:锁住 N1~N3 的源码契约。"""
|
||||
|
||||
def test_open_modal_is_single_top_level(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
defs = list(re.finditer(r"(?m)^function openModal\(", js))
|
||||
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
|
||||
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
|
||||
self.assertNotRegex(
|
||||
js,
|
||||
r"function initAdmin\(\)[\s\S]*?function openModal\(",
|
||||
)
|
||||
self.assertNotRegex(
|
||||
js,
|
||||
r"function initCompany\(\)[\s\S]*?function openModal\(",
|
||||
)
|
||||
self.assertIn("function askReason(", js)
|
||||
ask_pos = js.index("function askReason(")
|
||||
open_pos = defs[0].start()
|
||||
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
|
||||
|
||||
def test_init_company_boots_coverage_gaps(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
company_fn = js[js.index("function initCompany(") :]
|
||||
boot = company_fn[: company_fn.index("\nif (portal ===")]
|
||||
self.assertIn("await loadCompanyWorkspace()", boot)
|
||||
self.assertIn("await loadCompanyCoverageGaps()", boot)
|
||||
|
||||
def test_async_forms_capture_form_before_await(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("event.currentTarget.reset()", js)
|
||||
for marker in ("#companyForm", "#openingForm", "#accountForm"):
|
||||
idx = js.index(marker)
|
||||
chunk = js[idx : idx + 2500]
|
||||
self.assertIn("const form = event.currentTarget", chunk)
|
||||
self.assertIn("form.reset()", chunk)
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
|
||||
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
|
||||
class Hel203BrowserSmokeTests(unittest.TestCase):
|
||||
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_prepare_chrome_libs()
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
cls.storage.mkdir()
|
||||
|
||||
cls._old_db = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
auth.create_user(
|
||||
connection,
|
||||
"group-admin",
|
||||
ADMIN_PASSWORD,
|
||||
"admin",
|
||||
must_change_password=False,
|
||||
)
|
||||
cls.company_a = master_data.create_company(
|
||||
connection, "甲公司", None, None, None
|
||||
)
|
||||
cls.company_b = master_data.create_company(
|
||||
connection, "乙公司", None, None, None
|
||||
)
|
||||
auth.create_user(
|
||||
connection,
|
||||
"cashier-a",
|
||||
CASHIER_PASSWORD,
|
||||
"company",
|
||||
cls.company_a,
|
||||
must_change_password=False,
|
||||
)
|
||||
admin = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'group-admin'"
|
||||
).fetchone()
|
||||
account = master_data.submit_bank_account(
|
||||
connection,
|
||||
company_id=cls.company_a,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number="6222000000000001",
|
||||
start_date="2026-06-01",
|
||||
actor=None,
|
||||
)
|
||||
cls.account_a = master_data.review_bank_account(
|
||||
connection,
|
||||
account["id"],
|
||||
"approve",
|
||||
None,
|
||||
admin,
|
||||
effective_from="2026-06-01",
|
||||
)
|
||||
account_b = master_data.submit_bank_account(
|
||||
connection,
|
||||
company_id=cls.company_b,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number="6222000000000002",
|
||||
start_date="2026-06-01",
|
||||
actor=None,
|
||||
)
|
||||
master_data.review_bank_account(
|
||||
connection,
|
||||
account_b["id"],
|
||||
"approve",
|
||||
None,
|
||||
admin,
|
||||
effective_from="2026-06-01",
|
||||
)
|
||||
# 制造一处 mid 断档,供公司端首屏提醒
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
|
||||
""",
|
||||
("sha-hel203-gap", utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source_file_id,
|
||||
cls.company_a,
|
||||
cls.account_a["id"],
|
||||
utc_now(),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
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()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, created_at
|
||||
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
|
||||
""",
|
||||
(sheet_batch_id, "6222000000000001", utc_now()),
|
||||
)
|
||||
calculation.set_calculation_start_date(
|
||||
connection, "2026-06-01", "初始化起算", admin
|
||||
)
|
||||
calculation.recalculate_coverage_gaps(connection)
|
||||
|
||||
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
|
||||
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||
""",
|
||||
(ref, utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
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 = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
summary, purpose, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||
""",
|
||||
(
|
||||
sheet_batch_id,
|
||||
at,
|
||||
income,
|
||||
expense,
|
||||
own,
|
||||
cp,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
row_a = _add_row(
|
||||
cls.company_a,
|
||||
cls.account_a["id"],
|
||||
"6222000000000001",
|
||||
"6222000000000002",
|
||||
income="0",
|
||||
expense="100.00",
|
||||
at="2026-06-20T10:00:00",
|
||||
ref="sha-hel203-a",
|
||||
)
|
||||
row_b = _add_row(
|
||||
cls.company_b,
|
||||
account_b["id"],
|
||||
"6222000000000002",
|
||||
"6222000000000001",
|
||||
income="100.00",
|
||||
expense="0",
|
||||
at="2026-06-20T11:00:00",
|
||||
ref="sha-hel203-b",
|
||||
)
|
||||
matching.reconcile_rows(connection, [row_a, row_b])
|
||||
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.base = f"http://127.0.0.1:{cls.port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def _new_page(self, playwright):
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda err: errors.append(str(err)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
|
||||
if msg.type == "error"
|
||||
else None,
|
||||
)
|
||||
return browser, page, errors
|
||||
|
||||
def _login(self, page, *, portal: str, username: str, password: str) -> None:
|
||||
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
|
||||
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
|
||||
page.fill("#account", username)
|
||||
page.fill("#password", password)
|
||||
page.click('button[type="submit"]')
|
||||
expect = "admin.html" if portal == "admin" else "company.html"
|
||||
page.wait_for_url(f"**/{expect}", timeout=15000)
|
||||
|
||||
def test_02_admin_start_date_opening_company_ending(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser, page, errors = self._new_page(p)
|
||||
try:
|
||||
self._login(
|
||||
page,
|
||||
portal="admin",
|
||||
username="group-admin",
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
page.click('a[data-view="settings"]')
|
||||
page.wait_for_selector("#cs-start", state="visible")
|
||||
|
||||
# 修改起算日 → 原因弹窗必须打开且发出 PUT
|
||||
page.fill("#cs-start", "2026-06-15")
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "PUT"
|
||||
and "/api/admin/settings/calculation-start" in req.url
|
||||
) as start_req:
|
||||
page.click('#systemSettings button[type="submit"]')
|
||||
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||
page.fill("#reasonInput", "调整起算日供冒烟")
|
||||
page.click("#reasonSubmit")
|
||||
self.assertTrue(start_req.value.post_data)
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
|
||||
)
|
||||
|
||||
# 创建期初并确认
|
||||
page.click("#openOpeningDialog")
|
||||
page.wait_for_selector("#openingDialog.open")
|
||||
page.select_option("#ob-from", label="甲公司")
|
||||
page.select_option("#ob-to", label="乙公司")
|
||||
page.fill("#ob-amount", "200")
|
||||
page.fill("#ob-reason", "冒烟期初录入")
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and req.url.endswith("/api/admin/opening-balances")
|
||||
):
|
||||
page.click('#openingForm button[type="submit"]')
|
||||
page.wait_for_selector(
|
||||
'#openingRows button[data-confirm-opening]',
|
||||
timeout=8000,
|
||||
)
|
||||
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and "/opening-balances/" in req.url
|
||||
and req.url.endswith("/confirm")
|
||||
):
|
||||
page.click('#openingRows button[data-confirm-opening]')
|
||||
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||
page.fill("#reasonInput", "确认期初冒烟")
|
||||
page.click("#reasonSubmit")
|
||||
page.wait_for_selector(
|
||||
'#openingRows button[data-void-opening]',
|
||||
timeout=8000,
|
||||
)
|
||||
|
||||
# 公司端看到完整期末口径
|
||||
self._login(
|
||||
page,
|
||||
portal="company",
|
||||
username="cashier-a",
|
||||
password=CASHIER_PASSWORD,
|
||||
)
|
||||
page.click('a[data-view="transfers"]')
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const data = document.getElementById('transfersData');
|
||||
const empty = document.getElementById('transfersEmpty');
|
||||
const ready = (data && !data.hidden) || (empty && !empty.hidden);
|
||||
const card = document.getElementById('tfStatEndingCard');
|
||||
const title = document.getElementById('tfStatNetTitle');
|
||||
const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
|
||||
return ready && (
|
||||
(card && !card.hidden) ||
|
||||
(title && title.textContent.includes('期末')) ||
|
||||
emptyHtml.includes('期末')
|
||||
);
|
||||
}""",
|
||||
timeout=15000,
|
||||
)
|
||||
fatal = [e for e in errors if "openModal is not defined" in e
|
||||
or "Cannot read properties of null" in e]
|
||||
self.assertEqual([], fatal, fatal)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
def test_01_company_gap_notice_then_admin_approve(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser, page, errors = self._new_page(p)
|
||||
try:
|
||||
self._login(
|
||||
page,
|
||||
portal="company",
|
||||
username="cashier-a",
|
||||
password=CASHIER_PASSWORD,
|
||||
)
|
||||
# 首屏即可见断档提醒,无需手动刷新
|
||||
page.wait_for_selector(
|
||||
"#companyCoverageNotice",
|
||||
state="visible",
|
||||
timeout=10000,
|
||||
)
|
||||
body = page.locator("#companyCoverageBody").inner_text()
|
||||
self.assertTrue(body.strip())
|
||||
self.assertNotIn("0002", body)
|
||||
|
||||
page.click("#openAttestationFromWorkspace")
|
||||
page.wait_for_selector("#attestationDialog.open", timeout=5000)
|
||||
page.fill("#att-reason", "节假日账户无资金往来")
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and req.url.endswith("/api/company/no-business-attestations")
|
||||
) as att_req:
|
||||
page.click('#attestationForm button[type="submit"]')
|
||||
self.assertTrue(att_req.value.post_data)
|
||||
page.wait_for_function(
|
||||
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
|
||||
timeout=8000,
|
||||
)
|
||||
|
||||
self._login(
|
||||
page,
|
||||
portal="admin",
|
||||
username="group-admin",
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
page.click('a[data-view="audit"]')
|
||||
page.wait_for_selector(
|
||||
'button[data-audit-action="approve-attestation"]',
|
||||
timeout=10000,
|
||||
)
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and "/no-business-attestations/" in req.url
|
||||
and req.url.endswith("/review")
|
||||
):
|
||||
page.click('button[data-audit-action="approve-attestation"]')
|
||||
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||
page.fill("#reasonInput", "审核通过说明")
|
||||
page.click("#reasonSubmit")
|
||||
page.wait_for_function(
|
||||
"""() => !document.querySelector(
|
||||
'button[data-audit-action=\"approve-attestation\"]'
|
||||
)""",
|
||||
timeout=10000,
|
||||
)
|
||||
fatal = [e for e in errors if "openModal is not defined" in e]
|
||||
self.assertEqual([], fatal, fatal)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""HEL-206: 公司间期末余额方向——期初应收 + 转出垫付 = 期末应收增加。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from bank_importer import auth, calculation, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
import server
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_A_PASSWORD = "CashierA123"
|
||||
CASHIER_B_PASSWORD = "CashierB123"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
def _prepare_chrome_libs() -> Path | None:
|
||||
candidates = [ROOT / ".chrome-libs" / "lib"]
|
||||
for lib_dir in candidates:
|
||||
if (lib_dir / "libatk-1.0.so.0").exists():
|
||||
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
prefix = str(lib_dir)
|
||||
if prefix not in current.split(":"):
|
||||
os.environ["LD_LIBRARY_PATH"] = (
|
||||
f"{prefix}:{current}" if current else prefix
|
||||
)
|
||||
return lib_dir
|
||||
return None
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
if not sync_playwright:
|
||||
return False
|
||||
_prepare_chrome_libs()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
browser.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Hel206BalanceDirectionUnitTests(unittest.TestCase):
|
||||
"""不依赖浏览器:期初 200 + 转出 100 → 甲应收 300 / 乙应付 300。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.db_path = Path(self.temp_dir.name) / "app.db"
|
||||
self.connection = connect(self.db_path)
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
auth.create_user(self.connection, "admin-u", ADMIN_PASSWORD, "admin")
|
||||
self.admin = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'admin-u'"
|
||||
).fetchone()
|
||||
self.company_a = master_data.create_company(
|
||||
self.connection, "甲公司", None, None, None
|
||||
)
|
||||
self.company_b = master_data.create_company(
|
||||
self.connection, "乙公司", None, None, None
|
||||
)
|
||||
self.account_a = self._approve("6222000000000001", self.company_a)
|
||||
self.account_b = self._approve("6222000000000002", self.company_b)
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-01-01", "起算", self.admin
|
||||
)
|
||||
|
||||
def _approve(self, number: str, company_id: int):
|
||||
account = master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=company_id,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number=number,
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
return master_data.review_bank_account(
|
||||
self.connection,
|
||||
account["id"],
|
||||
"approve",
|
||||
None,
|
||||
self.admin,
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
|
||||
def _add_row(self, company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||
with self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||
""",
|
||||
(ref, utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_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, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 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,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
summary, purpose, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||
""",
|
||||
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def test_opening_200_plus_outflow_100_equals_closing_300(self) -> None:
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
row_a = self._add_row(
|
||||
self.company_a,
|
||||
self.account_a["id"],
|
||||
"6222000000000001",
|
||||
"6222000000000002",
|
||||
income="0",
|
||||
expense="100.00",
|
||||
at="2026-01-15T10:00:00",
|
||||
ref="sha-hel206-a",
|
||||
)
|
||||
row_b = self._add_row(
|
||||
self.company_b,
|
||||
self.account_b["id"],
|
||||
"6222000000000002",
|
||||
"6222000000000001",
|
||||
income="100.00",
|
||||
expense="0",
|
||||
at="2026-01-15T11:00:00",
|
||||
ref="sha-hel206-b",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a, row_b])
|
||||
|
||||
bal_a = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
bal_b = calculation.compute_pair_balance(
|
||||
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("200", bal_a["opening"])
|
||||
self.assertEqual("100.00", bal_a["net_change"])
|
||||
self.assertEqual("300.00", bal_a["closing"])
|
||||
self.assertEqual("-200", bal_b["opening"])
|
||||
self.assertEqual("-100.00", bal_b["net_change"])
|
||||
self.assertEqual("-300.00", bal_b["closing"])
|
||||
self.assertEqual(
|
||||
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
|
||||
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
|
||||
class Hel206BrowserDirectionTests(unittest.TestCase):
|
||||
"""真实 Chromium:公司端转账往来页期末方向与对手公司对称。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_prepare_chrome_libs()
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
cls.storage.mkdir()
|
||||
|
||||
cls._old_db = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
auth.create_user(
|
||||
connection, "group-admin", ADMIN_PASSWORD, "admin",
|
||||
must_change_password=False,
|
||||
)
|
||||
cls.company_a = master_data.create_company(
|
||||
connection, "甲公司", None, None, None
|
||||
)
|
||||
cls.company_b = master_data.create_company(
|
||||
connection, "乙公司", None, None, None
|
||||
)
|
||||
auth.create_user(
|
||||
connection, "cashier-a", CASHIER_A_PASSWORD, "company",
|
||||
cls.company_a, must_change_password=False,
|
||||
)
|
||||
auth.create_user(
|
||||
connection, "cashier-b", CASHIER_B_PASSWORD, "company",
|
||||
cls.company_b, must_change_password=False,
|
||||
)
|
||||
admin = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'group-admin'"
|
||||
).fetchone()
|
||||
|
||||
def approve(company_id, number):
|
||||
account = master_data.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number=number,
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
return master_data.review_bank_account(
|
||||
connection, account["id"], "approve", None, admin,
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
|
||||
account_a = approve(cls.company_a, "6222000000000001")
|
||||
account_b = approve(cls.company_b, "6222000000000002")
|
||||
calculation.set_calculation_start_date(
|
||||
connection, "2026-01-01", "初始化起算", admin
|
||||
)
|
||||
item = calculation.create_opening_balance(
|
||||
connection, cls.company_a, cls.company_b, "200", "期初应收", admin
|
||||
)
|
||||
calculation.confirm_opening_balance(connection, item["id"], "确认", admin)
|
||||
|
||||
def add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||
""",
|
||||
(ref, utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
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 = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
summary, purpose, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||
""",
|
||||
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
row_a = add_row(
|
||||
cls.company_a, account_a["id"], "6222000000000001", "6222000000000002",
|
||||
income="0", expense="100.00", at="2026-01-15T10:00:00", ref="sha-hel206-br-a",
|
||||
)
|
||||
row_b = add_row(
|
||||
cls.company_b, account_b["id"], "6222000000000002", "6222000000000001",
|
||||
income="100.00", expense="0", at="2026-01-15T11:00:00", ref="sha-hel206-br-b",
|
||||
)
|
||||
matching.reconcile_rows(connection, [row_a, row_b])
|
||||
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.base = f"http://127.0.0.1:{cls.port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def _login(self, page, *, username: str, password: str) -> None:
|
||||
page.goto(f"{self.base}/login-company.html", wait_until="domcontentloaded")
|
||||
page.fill("#account", username)
|
||||
page.fill("#password", password)
|
||||
page.click('button[type="submit"]')
|
||||
page.wait_for_url("**/company.html", timeout=15000)
|
||||
|
||||
def test_company_ending_direction_in_browser(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
try:
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda err: errors.append(str(err)))
|
||||
|
||||
# 甲公司:期初 200 + 转出 100 → 期末应收 300
|
||||
self._login(page, username="cashier-a", password=CASHIER_A_PASSWORD)
|
||||
page.click('a[data-view="transfers"]')
|
||||
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
|
||||
bal_a = page.evaluate(
|
||||
"""async () => {
|
||||
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
|
||||
return await res.json();
|
||||
}"""
|
||||
)
|
||||
self.assertEqual("ok", bal_a.get("status"))
|
||||
self.assertEqual("full", bal_a.get("basis"))
|
||||
pair_a = next(
|
||||
p for p in bal_a["pairs"]
|
||||
if p["counterparty_company_id"] == self.company_b
|
||||
)
|
||||
self.assertEqual("200", pair_a["opening"])
|
||||
self.assertEqual("100.00", pair_a["net_change"])
|
||||
self.assertEqual("300.00", pair_a["closing"])
|
||||
ending_text = page.locator("#tfStatEnding").inner_text()
|
||||
self.assertIn("0.03", ending_text) # 300 元 = 0.03 万元
|
||||
self.assertEqual([], errors, errors)
|
||||
|
||||
# 乙公司:对称应付 300
|
||||
self._login(page, username="cashier-b", password=CASHIER_B_PASSWORD)
|
||||
page.click('a[data-view="transfers"]')
|
||||
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
|
||||
bal_b = page.evaluate(
|
||||
"""async () => {
|
||||
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
|
||||
return await res.json();
|
||||
}"""
|
||||
)
|
||||
pair_b = next(
|
||||
p for p in bal_b["pairs"]
|
||||
if p["counterparty_company_id"] == self.company_a
|
||||
)
|
||||
self.assertEqual("-200", pair_b["opening"])
|
||||
self.assertEqual("-100.00", pair_b["net_change"])
|
||||
self.assertEqual("-300.00", pair_b["closing"])
|
||||
ending_b = page.locator("#tfStatEnding").inner_text()
|
||||
self.assertIn("0.03", ending_b)
|
||||
fatal = [e for e in errors if "is not defined" in e or "Cannot read" in e]
|
||||
self.assertEqual([], fatal, fatal)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,306 @@
|
||||
"""B-44 ledger event layer tests: bank reconciliation, revision chain,
|
||||
reversal/adjustment, projection rebuild and immutability boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, matching, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
class BankReconcileTests(LedgerBase):
|
||||
def test_paired_eligible_event_creates_pending_subject_only(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.assertEqual(1, len(self.eligible()))
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["created"])
|
||||
events = self.ledger_events()
|
||||
self.assertEqual(1, len(events))
|
||||
revision = self.current(events[0]["id"])
|
||||
self.assertEqual("pending_subject", revision["state"])
|
||||
self.assertEqual(Decimal("100.00"), Decimal(revision["amount"]))
|
||||
self.assertEqual("CNY", revision["currency"])
|
||||
# Nothing confirmed yet -> no position event.
|
||||
self.assertEqual([], self.position_events())
|
||||
# The event is unresolved as subject_review.
|
||||
from bank_importer import positions
|
||||
unresolved = positions.unresolved_for_company(self.connection, self.company_a, "2026-12-31")
|
||||
self.assertEqual("100.00", unresolved["by_reason"]["subject_review"]["gross_amount"])
|
||||
|
||||
def test_unlocked_single_never_enters_ledger(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [1])
|
||||
self.assertEqual([], self.eligible())
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["created"])
|
||||
self.assertEqual([], self.ledger_events())
|
||||
|
||||
def test_same_company_transfer_never_enters_ledger(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000001", expense="100.00",
|
||||
)
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000001", income="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [1, 2])
|
||||
eligible = self.eligible()
|
||||
self.assertEqual(0, len([e for e in eligible if e["payer_company_id"] == e["payee_company_id"]]))
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["created"])
|
||||
|
||||
def test_reconcile_is_idempotent(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
events = self.ledger_events()
|
||||
self.assertEqual(1, len(events))
|
||||
before_revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
|
||||
).fetchone()["n"]
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["unchanged"])
|
||||
after_revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(before_revisions, after_revisions)
|
||||
|
||||
def test_reconcile_picks_up_newly_linked_rows_as_new_pending_event(self) -> None:
|
||||
# A single observation locked as intercompany -> pending ledger event.
|
||||
row_a = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a])
|
||||
single = self.connection.execute(
|
||||
"SELECT * FROM current_transfer_decisions"
|
||||
).fetchone()
|
||||
event_id = single["event_id"]
|
||||
decision = self.connection.execute(
|
||||
"SELECT * FROM transfer_match_decisions WHERE id = ?",
|
||||
(single["decision_id"],),
|
||||
).fetchone()
|
||||
matching.apply_manual_decision(
|
||||
self.connection, event_id, "assign_participant",
|
||||
reason="函证确认对方",
|
||||
expected_revision=decision["revision"], request_key="assign-1",
|
||||
actor=self.admin,
|
||||
participant={"role": "payee", "company_id": self.company_b},
|
||||
)
|
||||
self.assertEqual(1, len(self.eligible()))
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, len(self.ledger_events()))
|
||||
|
||||
# The administrator links two fresh rows with a different amount onto a
|
||||
# new canonical event; reconcile creates a second pending event.
|
||||
row_a2 = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="150.00",
|
||||
at="2026-02-01T10:00:00",
|
||||
)
|
||||
row_b2 = self.add_row(
|
||||
self.company_b, own_account="6222000000000002",
|
||||
cp_account="6222000000000001", income="150.00",
|
||||
at="2026-02-01T11:00:00",
|
||||
)
|
||||
current_row = self.connection.execute(
|
||||
"SELECT d.* FROM current_transfer_decisions c "
|
||||
"JOIN transfer_match_decisions d ON d.id = c.decision_id "
|
||||
"WHERE c.event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
matching.apply_manual_decision(
|
||||
self.connection, event_id, "link_rows",
|
||||
reason="补录双边流水",
|
||||
expected_revision=current_row["revision"], request_key="link-1",
|
||||
actor=self.admin, source_row_ids=[row_a2, row_b2],
|
||||
)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
amounts = sorted(
|
||||
row["amount"] for row in self.connection.execute(
|
||||
"SELECT amount FROM ledger_event_revisions WHERE state = 'pending_subject'"
|
||||
).fetchall()
|
||||
)
|
||||
self.assertEqual(["100.00", "150.00"], amounts)
|
||||
|
||||
def test_confirm_subject_enters_position_view(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款建议其他应收", expected_revision=1, request_key="k1",
|
||||
actor=self.admin,
|
||||
)
|
||||
positions = self.position_events()
|
||||
self.assertEqual(1, len(positions))
|
||||
self.assertEqual("other_receivable", positions[0]["subject_code"])
|
||||
self.assertEqual(self.company_a, positions[0]["perspective_company_id"])
|
||||
|
||||
def test_confirm_stale_revision_conflicts(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认应收", expected_revision=1, request_key="k1",
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(subjects.SubjectConflictError):
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="重复确认", expected_revision=1, request_key="k2",
|
||||
actor=self.admin,
|
||||
)
|
||||
|
||||
def test_reversal_mirrors_direction_and_subject(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
reversal_id, _ = ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
reason="冲销错误确认", actor=self.admin,
|
||||
)
|
||||
reversal = self.current(reversal_id)
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(self.company_b, reversal["payer_company_id"])
|
||||
self.assertEqual(self.company_a, reversal["payee_company_id"])
|
||||
self.assertEqual("other_payable", reversal["subject_code"])
|
||||
self.assertEqual(self.company_b, reversal["perspective_company_id"])
|
||||
# Original event stays confirmed and still in the position view.
|
||||
self.assertEqual("confirmed", self.current(event_id)["state"])
|
||||
positions = self.position_events()
|
||||
self.assertEqual(2, len(positions))
|
||||
signed = sum(
|
||||
Decimal(position["amount"]) * (
|
||||
1 if position["payer_company_id"] == self.company_a else -1
|
||||
)
|
||||
for position in positions
|
||||
)
|
||||
self.assertEqual(Decimal("0"), signed)
|
||||
|
||||
def test_evidence_rows_stay_immutable_under_reversal(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
before = self.connection.execute(
|
||||
"SELECT income, expense FROM source_rows ORDER BY id"
|
||||
).fetchall()
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
reason="冲销", actor=self.admin,
|
||||
)
|
||||
after = self.connection.execute(
|
||||
"SELECT income, expense FROM source_rows ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_reversal_idempotent_guard(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
# Simulate the B-43 event leaving the eligible set.
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
|
||||
(self.eligible()[0]["event_id"],),
|
||||
)
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["reversal"])
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, reversals)
|
||||
# Re-run does not create a second reversal.
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["reversal"])
|
||||
|
||||
|
||||
class ProjectionTests(LedgerBase):
|
||||
def test_rebuild_current_projection(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
self.connection.execute("DELETE FROM current_ledger_event_revisions")
|
||||
rebuilt = ledger_events.rebuild_current_ledger_projection(self.connection)
|
||||
self.assertGreaterEqual(rebuilt, 1)
|
||||
revision = self.current(event_id)
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
self.assertEqual("receivable", revision["subject_code"])
|
||||
|
||||
|
||||
class SubjectSuggestionTests(LedgerBase):
|
||||
def test_mirror_mapping_is_symmetric(self) -> None:
|
||||
for subject, mirror in (
|
||||
("receivable", "payable"), ("payable", "receivable"),
|
||||
("other_receivable", "other_payable"), ("other_payable", "other_receivable"),
|
||||
):
|
||||
self.assertEqual(mirror, subjects.mirror_subject(subject))
|
||||
self.assertEqual(subject, subjects.mirror_subject(mirror))
|
||||
|
||||
def test_loan_keyword_suggests_payer_other_receivable(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="借款", purpose="往来款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
suggestions = subjects.compute_suggestions(self.connection, event_id)
|
||||
self.assertEqual(1, len(suggestions))
|
||||
self.assertEqual(self.company_a, suggestions[0]["suggested_perspective_company_id"])
|
||||
self.assertEqual("other_receivable", suggestions[0]["suggested_subject_code"])
|
||||
self.assertFalse(suggestions[0]["evidence"]["approved"])
|
||||
|
||||
def test_repay_keyword_suggests_payee(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="归还往来款", purpose="还款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
suggestions = subjects.compute_suggestions(self.connection, event_id)
|
||||
self.assertEqual(1, len(suggestions))
|
||||
self.assertEqual(self.company_b, suggestions[0]["suggested_perspective_company_id"])
|
||||
|
||||
def test_trade_keyword_never_suggests(self) -> None:
|
||||
# Trade vocabulary needs group approval; no dictionary hit -> review.
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="货款", purpose="采购货款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
|
||||
|
||||
def test_name_alone_never_confirms(self) -> None:
|
||||
# A name match on the counterparty cannot decide the subject.
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="某客户往来", purpose=None)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
current = self.current(event_id)
|
||||
self.assertEqual("pending_subject", current["state"])
|
||||
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,607 @@
|
||||
"""B-44 manual record tests: submit idempotency, approve new/link, return,
|
||||
exception, reverse, deduplication, idempotent replay and concurrency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, manual_records, matching, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
class SubmitTests(LedgerBase):
|
||||
def submit(self, **overrides):
|
||||
params = dict(
|
||||
company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00",
|
||||
direction="incoming",
|
||||
amount="50.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="other_receivable",
|
||||
request_key="mr-key",
|
||||
actor=self.admin,
|
||||
summary="归还往来款",
|
||||
)
|
||||
params.update(overrides)
|
||||
return manual_records.submit(self.connection, **params)
|
||||
|
||||
def test_submit_creates_pending_decision(self) -> None:
|
||||
payload = self.submit()
|
||||
self.assertEqual("pending", payload["state"])
|
||||
self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
|
||||
self.assertEqual(1, payload["decision_revision"])
|
||||
self.assertEqual([], payload["candidates"])
|
||||
|
||||
def test_submit_idempotent_on_request_key(self) -> None:
|
||||
first = self.submit()
|
||||
second = self.submit()
|
||||
self.assertEqual(first["id"], second["id"])
|
||||
self.assertTrue(second["idempotent_replay"])
|
||||
decisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM manual_record_decisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, decisions)
|
||||
|
||||
def test_submit_validations(self) -> None:
|
||||
cases = (
|
||||
({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
|
||||
({"amount": "0"}, "必须大于零"),
|
||||
({"amount": "abc"}, "十进制"),
|
||||
({"funding_source": "credit_card"}, "资金来源"),
|
||||
({"requested_subject": "equity"}, "科目"),
|
||||
({"request_key": ""}, "request_key"),
|
||||
({"counterparty_company_id": 9999}, "公司不存在"),
|
||||
)
|
||||
for overrides, expected in cases:
|
||||
with self.subTest(overrides=overrides):
|
||||
with self.assertRaises(manual_records.ManualInputError) as ctx:
|
||||
self.submit(**overrides)
|
||||
self.assertIn(expected, str(ctx.exception))
|
||||
|
||||
def test_bank_account_must_belong_to_submitting_company(self) -> None:
|
||||
with self.assertRaises(manual_records.ManualInputError):
|
||||
self.submit(
|
||||
funding_source="approved_bank_account",
|
||||
bank_account_id=self.account_b["id"],
|
||||
)
|
||||
|
||||
def test_related_source_row_never_modified(self) -> None:
|
||||
row_id = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
payload = self.submit(related_source_row_id=row_id)
|
||||
self.assertEqual(row_id, payload["related_source_row_id"])
|
||||
with self.assertRaises(Exception):
|
||||
self.connection.execute(
|
||||
"UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
|
||||
)
|
||||
self.connection.rollback()
|
||||
|
||||
|
||||
class DecisionTests(LedgerBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.record = manual_records.submit(
|
||||
self.connection,
|
||||
company_id=self.company_a, counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_receivable", request_key="mr-1",
|
||||
actor=self.admin, summary="归还往来款",
|
||||
)
|
||||
|
||||
def decide(self, action, **overrides):
|
||||
params = dict(
|
||||
record_id=self.record["id"],
|
||||
action=action,
|
||||
reason="管理员审核",
|
||||
expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
params.update(overrides)
|
||||
return manual_records.decide(self.connection, **params)
|
||||
|
||||
def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
|
||||
outcome = self.decide("approve_new")
|
||||
self.assertEqual("approved", outcome["state"])
|
||||
event_id = outcome["ledger_event_id"]
|
||||
revision = ledger_events.current_revision(self.connection, event_id)
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
# incoming 50 from A's perspective: B is the payer, A the payee.
|
||||
self.assertEqual(self.company_b, revision["payer_company_id"])
|
||||
self.assertEqual(self.company_a, revision["payee_company_id"])
|
||||
self.assertEqual(self.company_a, revision["perspective_company_id"])
|
||||
self.assertEqual("other_receivable", revision["subject_code"])
|
||||
# Exactly one position impact.
|
||||
positions = self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events"
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(positions))
|
||||
|
||||
def test_approve_new_replay_is_idempotent(self) -> None:
|
||||
first = self.decide("approve_new")
|
||||
second = self.decide("approve_new")
|
||||
self.assertEqual(first["decision_id"], second["decision_id"])
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_events"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, events)
|
||||
|
||||
def test_return_and_exception_never_produce_balance(self) -> None:
|
||||
for action in ("return", "exception"):
|
||||
with self.subTest(action=action):
|
||||
fresh = manual_records.submit(
|
||||
self.connection,
|
||||
company_id=self.company_a, counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-11T09:00:00", direction="incoming",
|
||||
amount="10.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key=f"mr-{action}",
|
||||
actor=self.admin,
|
||||
)
|
||||
outcome = manual_records.decide(
|
||||
self.connection, fresh["id"], action,
|
||||
reason="材料不足" if action == "return" else "转入异常",
|
||||
expected_decision_id=fresh["decision_id"],
|
||||
request_key=f"dec-{action}", actor=self.admin,
|
||||
)
|
||||
expected_state = "returned" if action == "return" else "exception"
|
||||
self.assertEqual(expected_state, outcome["state"])
|
||||
self.assertEqual(0, self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM eligible_position_events"
|
||||
).fetchone()["n"])
|
||||
|
||||
def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
|
||||
approved = self.decide("approve_new")
|
||||
reversed_outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录,冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev", actor=self.admin,
|
||||
)
|
||||
self.assertEqual("reversed", reversed_outcome["state"])
|
||||
rows = self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(rows))
|
||||
original, reversal = rows
|
||||
self.assertEqual("normal", original["posting_kind"])
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
|
||||
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
|
||||
# Net position is zero.
|
||||
signed = Decimal(original["amount"]) * (
|
||||
1 if original["payer_company_id"] == self.company_a else -1
|
||||
) + Decimal(reversal["amount"]) * (
|
||||
1 if reversal["payer_company_id"] == self.company_a else -1
|
||||
)
|
||||
self.assertEqual(Decimal("0"), signed)
|
||||
|
||||
def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
|
||||
# Build an eligible bank event and confirm its subject.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
bank_ledger_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, bank_ledger_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="subj-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
before = len(self.position_events())
|
||||
outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "approve_link",
|
||||
reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-link", actor=self.admin,
|
||||
target_ledger_event_id=bank_ledger_id,
|
||||
)
|
||||
self.assertEqual("approved", outcome["state"])
|
||||
self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
|
||||
after = len(self.position_events())
|
||||
self.assertEqual(before, after)
|
||||
sources = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(self.record["id"],),
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(sources))
|
||||
self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])
|
||||
|
||||
def test_approve_link_requires_target(self) -> None:
|
||||
with self.assertRaises(manual_records.ManualInputError):
|
||||
self.decide("approve_link")
|
||||
|
||||
def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
bank_ledger_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, bank_ledger_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="subj-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
approved = manual_records.decide(
|
||||
self.connection, self.record["id"], "approve_link",
|
||||
reason="同源", expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-link", actor=self.admin,
|
||||
target_ledger_event_id=bank_ledger_id,
|
||||
)
|
||||
reversed_outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="撤销关联", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-unlink", actor=self.admin,
|
||||
)
|
||||
self.assertEqual("reversed", reversed_outcome["state"])
|
||||
# No reversal event is created for a linked claim.
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(0, reversals)
|
||||
# The bank impact is untouched.
|
||||
self.assertEqual(1, len(self.position_events()))
|
||||
|
||||
def test_stale_expected_decision_conflicts(self) -> None:
|
||||
approved = self.decide("approve_new")
|
||||
with self.assertRaises(manual_records.ManualConflictError):
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="冲销", expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-stale", actor=self.admin,
|
||||
)
|
||||
|
||||
def test_returned_record_cannot_be_reversed(self) -> None:
|
||||
returned = self.decide("return", reason="材料不足")
|
||||
with self.assertRaises(manual_records.ManualConflictError):
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="冲销", expected_decision_id=returned["decision_id"],
|
||||
request_key="dec-x", actor=self.admin,
|
||||
)
|
||||
|
||||
def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None:
|
||||
# Reversing with an independent effective date must keep the original
|
||||
# impact for cutoffs before it and net it to zero only on/after it —
|
||||
# never rewrite the historical period retroactively.
|
||||
from bank_importer import positions
|
||||
approved = self.decide("approve_new")
|
||||
event_id = approved["ledger_event_id"]
|
||||
self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10])
|
||||
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-date", actor=self.admin,
|
||||
effective_at="2026-06-15",
|
||||
)
|
||||
reversal = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()
|
||||
self.assertEqual("2026-06-15", reversal["effective_at"][:10])
|
||||
|
||||
before = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-05-31"
|
||||
)
|
||||
after = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in before["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual("-50.00", item["result"]["signed_amount"])
|
||||
for item in after["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual("0.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_reverse_defaults_to_approval_business_day(self) -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from bank_importer import positions
|
||||
approved = self.decide("approve_new")
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-default", actor=self.admin,
|
||||
)
|
||||
today = datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
reversal = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()
|
||||
self.assertEqual(today, reversal["effective_at"][:10])
|
||||
|
||||
# The day before the approval business day keeps the original impact.
|
||||
before = positions.company_balances(
|
||||
self.connection, from_="2026-01-01",
|
||||
cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(),
|
||||
)
|
||||
for item in before["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual("-50.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None:
|
||||
# approve_new created the event; a second manual record later links
|
||||
# onto it. Reversing the creation source must still create an
|
||||
# equal-amount reversal — the original economic impact must not survive
|
||||
# just because other evidence was attached later.
|
||||
approved = self.decide("approve_new")
|
||||
event_id = approved["ledger_event_id"]
|
||||
|
||||
linked = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-12T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-link-ev",
|
||||
actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, linked["id"], "approve_link",
|
||||
reason="同源补充证据", expected_decision_id=linked["decision_id"],
|
||||
request_key="dec-link-ev", actor=self.admin,
|
||||
target_ledger_event_id=event_id,
|
||||
)
|
||||
self.assertEqual(1, len(self.position_events()))
|
||||
|
||||
reversed_outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-create", actor=self.admin,
|
||||
)
|
||||
self.assertEqual("reversed", reversed_outcome["state"])
|
||||
rows = self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(rows))
|
||||
original, reversal = rows
|
||||
self.assertEqual("normal", original["posting_kind"])
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
|
||||
# The later linked evidence stays attached to the event.
|
||||
links = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(links))
|
||||
|
||||
def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None:
|
||||
# M1 creates the event, M2 links; after M1's reversal created the
|
||||
# offset, reversing the linked M2 must only detach, never add a second
|
||||
# reversal event.
|
||||
approved = self.decide("approve_new")
|
||||
event_id = approved["ledger_event_id"]
|
||||
linked = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-12T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-link-ev2",
|
||||
actor=self.admin,
|
||||
)
|
||||
linked_approved = manual_records.decide(
|
||||
self.connection, linked["id"], "approve_link",
|
||||
reason="同源补充证据", expected_decision_id=linked["decision_id"],
|
||||
request_key="dec-link-ev2", actor=self.admin,
|
||||
target_ledger_event_id=event_id,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-create2", actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, linked["id"], "reverse",
|
||||
reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"],
|
||||
request_key="dec-rev-link2", actor=self.admin,
|
||||
)
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, reversals)
|
||||
links = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources"
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(links))
|
||||
|
||||
|
||||
class DedupAndConcurrencyTests(LedgerBase):
|
||||
def test_duplicate_manual_record_pairing_candidate(self) -> None:
|
||||
manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="outgoing",
|
||||
amount="40.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_receivable", request_key="mr-a",
|
||||
actor=self.admin,
|
||||
)
|
||||
other = manual_records.submit(
|
||||
self.connection, company_id=self.company_b,
|
||||
counterparty_company_id=self.company_a,
|
||||
occurred_at="2026-01-10T10:00:00", direction="incoming",
|
||||
amount="40.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_payable", request_key="mr-b",
|
||||
actor=self.admin,
|
||||
)
|
||||
candidates = manual_records.find_candidates(self.connection, other["id"])
|
||||
self.assertEqual(1, len(candidates))
|
||||
self.assertEqual("manual_record", candidates[0]["kind"])
|
||||
|
||||
def test_duplicate_bank_event_candidate_hints_link(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-05T12:00:00", direction="incoming",
|
||||
amount="100.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_receivable", request_key="mr-c",
|
||||
actor=self.admin,
|
||||
)
|
||||
candidates = manual_records.find_candidates(self.connection, record["id"])
|
||||
self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))
|
||||
|
||||
def test_concurrent_approve_new_counts_once(self) -> None:
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-conc",
|
||||
actor=self.admin,
|
||||
)
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker(thread_key: str) -> None:
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
manual_records.decide(
|
||||
connection, record["id"], "approve_new",
|
||||
reason="并发审批", expected_decision_id=record["decision_id"],
|
||||
request_key=f"dec-{thread_key}", actor=self.admin,
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Two connections can still double-commit, so the UNIQUE manual source
|
||||
# claim must make the second write fail or be a no-op; either way only
|
||||
# one ledger event may exist.
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_events"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, events)
|
||||
|
||||
def test_concurrent_submit_same_request_key_is_idempotent(self) -> None:
|
||||
import sqlite3
|
||||
|
||||
results: list[dict] = []
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker(thread_key: str) -> None:
|
||||
try:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
payload = manual_records.submit(
|
||||
connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="mr-conc-submit", actor=self.admin,
|
||||
)
|
||||
results.append(payload)
|
||||
finally:
|
||||
connection.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertEqual([], errors)
|
||||
rows = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM manual_records"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, rows)
|
||||
self.assertEqual(1, len({payload["id"] for payload in results}))
|
||||
decisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM manual_record_decisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, decisions)
|
||||
|
||||
def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None:
|
||||
import sqlite3
|
||||
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
from bank_importer import ledger_events
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
|
||||
outcomes: list[dict] = []
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker(thread_key: str) -> None:
|
||||
try:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
payload = subjects.confirm_subject(
|
||||
connection, event_id,
|
||||
perspective_company_id=self.company_a,
|
||||
subject_code="other_receivable", reason="并发确认",
|
||||
expected_revision=1, request_key="conc-subj-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
outcomes.append(payload)
|
||||
finally:
|
||||
connection.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertEqual([], errors)
|
||||
confirmed = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, confirmed)
|
||||
self.assertEqual(1, len({payload["revision_id"] for payload in outcomes}))
|
||||
|
||||
def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
from bank_importer import ledger_events
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
first = subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="确认", expected_revision=1, request_key="noise-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
second = subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="重复确认", expected_revision=1, request_key="noise-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
self.assertEqual(first["revision_id"], second["revision_id"])
|
||||
revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()["n"]
|
||||
# One pending_subject + one confirmed; the replay appended nothing.
|
||||
self.assertEqual(2, revisions)
|
||||
confirmed = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions "
|
||||
"WHERE ledger_event_id = ? AND state = 'confirmed'",
|
||||
(event_id,),
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, confirmed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+112
-5
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
|
||||
class MigrationTests(PersistenceTestCase):
|
||||
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
|
||||
first = applied_versions(self.connection)
|
||||
self.assertEqual([1, 2, 3, 4, 5], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -73,19 +73,35 @@ class MigrationTests(PersistenceTestCase):
|
||||
"transfer_match_candidates",
|
||||
"current_transfer_decisions",
|
||||
"transfer_observation_claims",
|
||||
"manual_records",
|
||||
"manual_record_decisions",
|
||||
"current_manual_record_decisions",
|
||||
"ledger_events",
|
||||
"ledger_event_revisions",
|
||||
"current_ledger_event_revisions",
|
||||
"ledger_event_bank_sources",
|
||||
"ledger_event_manual_sources",
|
||||
"ledger_subject_suggestions",
|
||||
"system_settings",
|
||||
"system_setting_changes",
|
||||
"reminders",
|
||||
"closed_periods",
|
||||
"opening_balance_revisions",
|
||||
"coverage_gaps",
|
||||
"no_business_attestations",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([], applied_versions(self.connection))
|
||||
remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -93,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([5], rollback(self.connection, 4))
|
||||
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
@@ -109,6 +125,10 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
ledger_remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
|
||||
).fetchone()
|
||||
self.assertIsNone(ledger_remaining)
|
||||
|
||||
def test_event_layer_views_exist_after_migration(self) -> None:
|
||||
view = self.connection.execute(
|
||||
@@ -116,6 +136,93 @@ class MigrationTests(PersistenceTestCase):
|
||||
).fetchone()
|
||||
self.assertIsNotNone(view)
|
||||
|
||||
def test_ledger_layer_views_exist_after_migration(self) -> None:
|
||||
view = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'view' AND name = 'eligible_position_events'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(view)
|
||||
table = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(table)
|
||||
|
||||
def test_ledger_revision_log_is_immutable(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
|
||||
]
|
||||
self.connection.execute(
|
||||
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(utc_now(),),
|
||||
)
|
||||
event_id = self.connection.execute(
|
||||
"SELECT id FROM ledger_events LIMIT 1"
|
||||
).fetchone()["id"]
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_revisions (
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale, currency, payer_company_id, payee_company_id,
|
||||
source_kind, posting_kind, created_at
|
||||
) VALUES (?, 1, 'pending_subject', '2026-01-01T00:00:00', '1.00', 2,
|
||||
'CNY', ?, ?, 'bank', 'normal', ?)
|
||||
""",
|
||||
(event_id, company_a, company_b, utc_now()),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE ledger_event_revisions SET amount = '9.99'",
|
||||
"DELETE FROM ledger_event_revisions",
|
||||
"UPDATE ledger_events SET created_at = '2020-01-01T00:00:00'",
|
||||
"DELETE FROM ledger_events",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_manual_record_facts_are_immutable(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
|
||||
]
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_records (
|
||||
company_id, counterparty_company_id, occurred_at, direction,
|
||||
amount, amount_scale, currency, funding_source, requested_subject,
|
||||
request_key, created_at
|
||||
) VALUES (?, ?, '2026-01-01T00:00:00', 'incoming', '1.00', 2,
|
||||
'CNY', 'other', 'receivable', 'k1', ?)
|
||||
""",
|
||||
(company_a, company_b, utc_now()),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE manual_records SET amount = '9.99'",
|
||||
"DELETE FROM manual_records",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_decision_log_immutability_triggers(self) -> None:
|
||||
self.import_sample()
|
||||
source_row_id = self.connection.execute(
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""B-44 position aggregation tests: conservation, repayments, reversals across
|
||||
cutoffs, currency isolation, Decimal precision, unresolved gross, normal
|
||||
balance exceptions and keyset pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, getcontext
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, manual_records, positions, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
def confirm(connection, event_id: int, perspective: int, subject: str, key: str, actor):
|
||||
return subjects.confirm_subject(
|
||||
connection, event_id,
|
||||
perspective_company_id=perspective, subject_code=subject,
|
||||
reason="审核确认", expected_revision=1, request_key=key, actor=actor,
|
||||
)
|
||||
|
||||
|
||||
class ConservationTests(LedgerBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
getcontext().prec = 50
|
||||
|
||||
def _confirm_all(self, subject: str = "other_receivable"):
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(
|
||||
self.connection, event["id"],
|
||||
revision["payer_company_id"], subject, f"subj-{event['id']}",
|
||||
self.admin,
|
||||
)
|
||||
|
||||
def test_both_perspectives_mirror_exactly(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_a, self.company_b, "30.00", at="2026-01-10T10:00:00")
|
||||
self.pair(self.company_a, self.company_b, "70.00", at="2026-02-05T10:00:00")
|
||||
self._confirm_all()
|
||||
pair = positions.pair_detail(
|
||||
self.connection, self.company_a, self.company_b,
|
||||
from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
item = pair["items"][0]
|
||||
self.assertTrue(item["conservation"]["opposite"])
|
||||
self.assertTrue(item["conservation"]["abs_equal"])
|
||||
self.assertEqual(Decimal("200.00"), Decimal(item["a"]["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("-200.00"), Decimal(item["b"]["result"]["signed_amount"]))
|
||||
|
||||
def test_repayment_reduces_the_original_balance(self) -> None:
|
||||
# A lends B 100 (A pays) then B repays 40 (B pays).
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_b, self.company_a, "40.00", at="2026-03-01T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
self._confirm_all()
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("60.00"), Decimal(item["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["debit"]))
|
||||
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["credit"]))
|
||||
else:
|
||||
self.assertEqual(Decimal("-60.00"), Decimal(item["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["debit"]))
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["credit"]))
|
||||
|
||||
def test_over_repayment_flags_normal_balance_exception(self) -> None:
|
||||
# A lends B 100, then B repays 150 while A books the repayment against
|
||||
# its receivable: A's receivable and B's payable both go negative.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_b, self.company_a, "150.00", at="2026-03-01T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
# Book every event from A's perspective on the receivable side so
|
||||
# the over-repayment produces abnormal balances.
|
||||
confirm(
|
||||
self.connection, event["id"], self.company_a,
|
||||
"other_receivable", f"k-{event['id']}", self.admin,
|
||||
)
|
||||
pair = positions.pair_detail(
|
||||
self.connection, self.company_a, self.company_b,
|
||||
from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
item = pair["items"][0]
|
||||
self.assertTrue(item["normal_balance_exception"])
|
||||
self.assertIn("other_receivable", item["normal_balance_exception"])
|
||||
self.assertIn("other_payable", item["normal_balance_exception"])
|
||||
|
||||
|
||||
class CutoffAndReversalTests(LedgerBase):
|
||||
def _loan(self) -> int:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "other_receivable", "k1", self.admin)
|
||||
return event_id
|
||||
|
||||
def test_cutoff_before_reversal_keeps_original_impact(self) -> None:
|
||||
event_id = self._loan()
|
||||
# Reversal dated after the original event.
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
effective_at="2026-06-15T00:00:00", reason="冲销", actor=self.admin,
|
||||
)
|
||||
before = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-05-31"
|
||||
)
|
||||
after = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in before["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
|
||||
for item in after["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("0"), Decimal(item["result"]["signed_amount"]))
|
||||
|
||||
def test_from_gt_cutoff_is_rejected(self) -> None:
|
||||
with self.assertRaises(positions.PositionInputError):
|
||||
positions.validate_window("2026-08-01", "2026-07-31")
|
||||
|
||||
def test_events_before_from_are_excluded_from_period(self) -> None:
|
||||
self._loan()
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-06-01", cutoff="2026-07-31"
|
||||
)
|
||||
self.assertEqual([], balances["items"])
|
||||
|
||||
def test_reversal_uses_approved_effective_date(self) -> None:
|
||||
event_id = self._loan()
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
effective_at="2026-08-01T00:00:00", reason="未来冲销", actor=self.admin,
|
||||
)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
# The future reversal does not rewrite the earlier cutoff.
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
|
||||
|
||||
|
||||
class CurrencyAndPrecisionTests(LedgerBase):
|
||||
def test_currencies_never_mix(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", currency="CNY")
|
||||
self.pair(self.company_a, self.company_b, "50.00", at="2026-01-20T10:00:00",
|
||||
currency="USD")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
events = self.ledger_events()
|
||||
for event in events:
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
by_company = {}
|
||||
for item in balances["items"]:
|
||||
by_company.setdefault(item["company_id"], {})[item["currency"]] = item["result"]
|
||||
self.assertEqual(Decimal("100.00"), Decimal(by_company[self.company_a]["CNY"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("50.00"), Decimal(by_company[self.company_a]["USD"]["signed_amount"]))
|
||||
|
||||
def test_decimal_precision_is_preserved(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "0.01")
|
||||
self.pair(self.company_a, self.company_b, "12345.6789", at="2026-01-11T10:00:00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(
|
||||
Decimal("12345.6889"), Decimal(item["result"]["signed_amount"])
|
||||
)
|
||||
# The stored string keeps every decimal place.
|
||||
self.assertIn("12345.6889", item["result"]["signed_amount"])
|
||||
|
||||
def test_manual_record_preserves_amount_scale(self) -> None:
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-02-01T09:00:00", direction="incoming",
|
||||
amount="12.3400", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-prec",
|
||||
actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, record["id"], "approve_new",
|
||||
reason="确认", expected_decision_id=record["decision_id"],
|
||||
request_key="dec-prec", actor=self.admin,
|
||||
)
|
||||
stored = self.connection.execute(
|
||||
"SELECT amount, amount_scale FROM eligible_position_events"
|
||||
).fetchone()
|
||||
self.assertEqual("12.3400", stored["amount"])
|
||||
self.assertEqual(4, stored["amount_scale"])
|
||||
|
||||
|
||||
class UnresolvedTests(LedgerBase):
|
||||
def test_unresolved_gross_never_nets(self) -> None:
|
||||
# Two pending_subject events of opposite economic signs must sum their
|
||||
# absolute values, never cancel.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_a, self.company_b, "40.00", at="2026-01-20T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(2, unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("140.00"), Decimal(unresolved["gross_amount"])
|
||||
)
|
||||
self.assertEqual(
|
||||
Decimal("140.00"),
|
||||
Decimal(unresolved["by_reason"]["subject_review"]["gross_amount"]),
|
||||
)
|
||||
|
||||
def test_unmatched_single_reason_bucket(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="88.00",
|
||||
)
|
||||
from bank_importer import matching
|
||||
matching.reconcile_rows(self.connection, [1])
|
||||
unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(1, unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("88.00"),
|
||||
Decimal(unresolved["by_reason"]["unmatched_single"]["gross_amount"]),
|
||||
)
|
||||
|
||||
def test_pending_manual_only_counts_for_submitting_company(self) -> None:
|
||||
manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-a",
|
||||
actor=self.admin,
|
||||
)
|
||||
a_unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
b_unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_b, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(1, a_unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("50.00"), Decimal(a_unresolved["by_reason"]["manual_pending"]["gross_amount"])
|
||||
)
|
||||
# The counterparty never sees the unapproved declaration.
|
||||
self.assertEqual(0, b_unresolved["count"])
|
||||
|
||||
|
||||
class PaginationTests(LedgerBase):
|
||||
def test_subject_filter_matches_stored_and_mirror_subjects(self) -> None:
|
||||
# A books "receivable" from its own perspective; B sees the mirror
|
||||
# "payable". Filtering by B's mirror subject must still find the event.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "receivable", "k-mirror",
|
||||
self.admin)
|
||||
|
||||
stored = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="receivable",
|
||||
)
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == event_id for item in stored["items"]
|
||||
))
|
||||
# Company B filters by its own perspective: the stored "receivable" is
|
||||
# the mirror of "payable", so it must be included.
|
||||
mirror = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="payable", viewer_company_id=self.company_b,
|
||||
)
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == event_id for item in mirror["items"]
|
||||
), mirror)
|
||||
for item in mirror["items"]:
|
||||
if item["ledger_event_id"] == event_id:
|
||||
self.assertEqual("payable", item["own_subject"])
|
||||
|
||||
def test_subject_filter_without_match_returns_empty(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "receivable", "k-none",
|
||||
self.admin)
|
||||
other = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="other_receivable",
|
||||
)
|
||||
self.assertEqual([], other["items"])
|
||||
|
||||
def test_pagination_is_stable_and_complete(self) -> None:
|
||||
for index in range(7):
|
||||
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
|
||||
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
|
||||
seen: list[int] = []
|
||||
cursor = None
|
||||
pages = 0
|
||||
while True:
|
||||
page = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
limit=3, cursor=cursor,
|
||||
)
|
||||
pages += 1
|
||||
seen.extend(item["ledger_event_id"] for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
if not page["has_more"]:
|
||||
break
|
||||
self.assertEqual(7, len(seen))
|
||||
self.assertEqual(len(set(seen)), len(seen))
|
||||
self.assertGreater(pages, 2)
|
||||
|
||||
def test_directory_pagination_complete(self) -> None:
|
||||
for index in range(6):
|
||||
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
|
||||
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
seen: set[tuple[int, str]] = set()
|
||||
cursor = None
|
||||
while True:
|
||||
page = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
limit=1, cursor=cursor,
|
||||
)
|
||||
seen.update((item["company_id"], item["currency"]) for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
if not page["has_more"]:
|
||||
break
|
||||
self.assertEqual(
|
||||
{(self.company_a, "CNY"), (self.company_b, "CNY")}, seen
|
||||
)
|
||||
|
||||
def test_event_payload_account_chip_and_repayment_flag(self) -> None:
|
||||
self.pair(
|
||||
self.company_a, self.company_b, "40.00",
|
||||
summary="归还往来款", purpose="还款",
|
||||
)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = int(self.ledger_events()[0]["id"])
|
||||
detail = positions.event_detail(self.connection, event_id)
|
||||
event = detail["event"]
|
||||
self.assertEqual("visible", event["payer_account"]["visibility"])
|
||||
self.assertEqual("测试 0001", event["payer_account"]["label"])
|
||||
self.assertEqual("visible", event["payee_account"]["visibility"])
|
||||
self.assertEqual("测试 0002", event["payee_account"]["label"])
|
||||
self.assertEqual("归还往来款", event["summary"])
|
||||
self.assertTrue(event["is_repayment"])
|
||||
|
||||
company_view = positions.event_payload(
|
||||
self.connection,
|
||||
self.connection.execute(
|
||||
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone(),
|
||||
viewer_company_id=self.company_a,
|
||||
)
|
||||
self.assertEqual("visible", company_view["payer_account"]["visibility"])
|
||||
self.assertEqual("masked", company_view["payee_account"]["visibility"])
|
||||
self.assertEqual("按对方授权不可见", company_view["payee_account"]["label"])
|
||||
|
||||
def test_park_subject_exception_hides_from_review_queue(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = int(self.ledger_events()[0]["id"])
|
||||
current = self.current(event_id)
|
||||
subjects.park_subject(
|
||||
self.connection, event_id,
|
||||
disposition="exception", reason="转异常核查",
|
||||
expected_revision=current["id"], request_key="park-exc-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
queue = positions.subject_review_queue(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
self.assertFalse(
|
||||
any(item["ledger_event_id"] == event_id for item in queue["items"])
|
||||
)
|
||||
revision = self.current(event_id)
|
||||
self.assertEqual("pending_subject", revision["state"])
|
||||
|
||||
def test_park_subject_return_stays_in_review_queue(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "80.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = int(self.ledger_events()[0]["id"])
|
||||
current = self.current(event_id)
|
||||
subjects.park_subject(
|
||||
self.connection, event_id,
|
||||
disposition="return", reason="退回补充摘要",
|
||||
expected_revision=current["id"], request_key="park-ret-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
queue = positions.subject_review_queue(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
self.assertTrue(
|
||||
any(item["ledger_event_id"] == event_id for item in queue["items"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,594 @@
|
||||
"""HTTP integration tests for the B-44 intercompany position APIs.
|
||||
|
||||
Covers the admin balances/pair/events/evidence/subject-review/manual-record
|
||||
endpoints and the company-side scoped reads with masked evidence, plus
|
||||
cross-tenant 404s and company-forbidden admin writes. Every test method spins
|
||||
up its own server with a fresh database so subject confirmations and matched
|
||||
fixtures never leak across tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "Cashier123"
|
||||
|
||||
CCB_HEADER = [
|
||||
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
|
||||
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
|
||||
]
|
||||
|
||||
ACCOUNT_A = "6222000000000001"
|
||||
ACCOUNT_B = "6222000000000002"
|
||||
|
||||
|
||||
def workbook_bytes(rows) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "正常流水"
|
||||
sheet.append(CCB_HEADER)
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00",
|
||||
currency: str = "RMB"):
|
||||
return [own, "测试公司", at, amount, "", "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
|
||||
currency: str = "RMB"):
|
||||
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
class IntercompanyApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.db_path = root / "app.db"
|
||||
self.storage = root / "files"
|
||||
self.initial_passwords: dict[str, str] = {}
|
||||
|
||||
self._old_db_path = server.DB_PATH
|
||||
self._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = self.db_path
|
||||
server.STORAGE_DIR = self.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(self.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
self.port = self.httpd.server_address[1]
|
||||
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
self.admin = Client("127.0.0.1", self.port)
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
self.company_a = self._create_company("甲公司", "cashier-a")
|
||||
self.company_b = self._create_company("乙公司", "cashier-b")
|
||||
self.company_c = self._create_company("丙公司", "cashier-c")
|
||||
self.cashier_a = self._login_company("cashier-a", self.company_a)
|
||||
self.cashier_b = self._login_company("cashier-b", self.company_b)
|
||||
self.cashier_c = self._login_company("cashier-c", self.company_c)
|
||||
|
||||
self._approve_account(self.company_a, ACCOUNT_A)
|
||||
self._approve_account(self.company_b, ACCOUNT_B)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
server.DB_PATH = self._old_db_path
|
||||
server.STORAGE_DIR = self._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
|
||||
def _create_company(self, name: str, username: str) -> int:
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/companies", {"name": name, "username": username}
|
||||
)
|
||||
assert status == 200, data
|
||||
self.initial_passwords.setdefault(username, as_json(data)["initial_password"])
|
||||
return as_json(data)["company_id"]
|
||||
|
||||
def _login_company(self, username: str, company_id: int) -> Client:
|
||||
client = Client("127.0.0.1", self.port)
|
||||
initial = self.initial_passwords[username]
|
||||
status, _, data = client.post_json(
|
||||
"/api/login", {"username": username, "password": initial, "portal": "company"}
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = client.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": CASHIER_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
return client
|
||||
|
||||
def _approve_account(self, company_id: int, number: str) -> None:
|
||||
client = {self.company_a: self.cashier_a, self.company_b: self.cashier_b}[company_id]
|
||||
status, _, data = client.post_json(
|
||||
"/api/company/accounts",
|
||||
{"bank_name": "中信银行", "account_type": "基本户",
|
||||
"account_number": number, "start_date": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
def _upload_and_confirm(self, client, company_id: int, rows) -> int:
|
||||
content = workbook_bytes(rows)
|
||||
status, _, data = self.admin.post_multipart(
|
||||
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
|
||||
)
|
||||
assert status == 200, data
|
||||
batch_id = as_json(data)["batch_id"]
|
||||
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
|
||||
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
|
||||
status, _, data = client.post_json(
|
||||
f"/api/batches/{batch_id}/confirm", {"sheets": names}
|
||||
)
|
||||
assert status == 200, data
|
||||
return batch_id
|
||||
|
||||
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00",
|
||||
currency: str = "RMB") -> dict:
|
||||
"""Upload+confirm a new A<->B pair; returns the pending review item."""
|
||||
self._upload_and_confirm(
|
||||
self.cashier_a, self.company_a,
|
||||
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at, currency)],
|
||||
)
|
||||
self._upload_and_confirm(
|
||||
self.cashier_b, self.company_b,
|
||||
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"), currency)],
|
||||
)
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
pending = [item for item in items if item["amount"] == amount]
|
||||
self.assertEqual(1, len(pending), data)
|
||||
return pending[0]
|
||||
|
||||
def _confirm(self, ledger_event_id: int) -> dict:
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{ledger_event_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
detail = as_json(data)
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{ledger_event_id}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": detail["event"]["payer_company_id"],
|
||||
"subject_code": "other_receivable",
|
||||
"reason": "借款确认其他应收",
|
||||
"expected_revision": detail["event"]["ledger_revision_id"],
|
||||
"request_key": f"subj-{ledger_event_id}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
return as_json(data)["revision"]
|
||||
|
||||
def balances(self, client=None, path="/api/admin/intercompany/balances") -> dict:
|
||||
client = client or self.admin
|
||||
status, _, data = client.get(path + "?from=2026-01-01&cutoff=2026-12-31")
|
||||
self.assertEqual(200, status, data)
|
||||
return as_json(data)
|
||||
|
||||
def _by_company(self, payload: dict, company_id: int) -> dict:
|
||||
return next(item for item in payload["items"] if item["company_id"] == company_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin balances / pair / events
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_admin_balances_directory(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
payload = self.balances()
|
||||
self.assertEqual("2026-12-31", payload["window"]["cutoff"])
|
||||
companies = {item["company_id"] for item in payload["items"]}
|
||||
self.assertIn(self.company_a, companies)
|
||||
self.assertIn(self.company_b, companies)
|
||||
for item in payload["items"]:
|
||||
self.assertEqual("unavailable", item["opening"]["status"])
|
||||
self.assertIsNone(item["opening"]["amount"])
|
||||
self.assertEqual("period_net_change", item["result"]["kind"])
|
||||
self.assertIn("gross_amount", item["unresolved"])
|
||||
self.assertIn("count", item["unresolved"])
|
||||
self.assertIn("by_reason", item["unresolved"])
|
||||
self.assertEqual("RMB", item["currency"])
|
||||
|
||||
def test_admin_balances_pending_subject_shows_unresolved(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
payload = self.balances()
|
||||
item = self._by_company(payload, self.company_a)
|
||||
self.assertEqual(1, item["unresolved"]["count"])
|
||||
self.assertEqual(
|
||||
"100.00", item["unresolved"]["by_reason"]["subject_review"]["gross_amount"]
|
||||
)
|
||||
self.assertEqual("0", item["period"]["debit"])
|
||||
self.assertEqual("0", item["period"]["credit"])
|
||||
|
||||
def test_admin_pair_detail_conserves(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/pairs/{self.company_a}/{self.company_b}"
|
||||
"?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
item = payload["items"][0]
|
||||
self.assertTrue(item["conservation"]["opposite"])
|
||||
self.assertTrue(item["conservation"]["abs_equal"])
|
||||
self.assertEqual("unavailable", item["opening"]["status"])
|
||||
self.assertIn("subjects", item)
|
||||
self.assertEqual(1, item["unresolved"]["count"])
|
||||
|
||||
def test_admin_subject_decision_flows_to_balances(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
self.assertEqual("other_receivable", revision["subject_code"])
|
||||
payload = self.balances()
|
||||
item = self._by_company(payload, self.company_a)
|
||||
self.assertEqual(0, item["unresolved"]["count"])
|
||||
self.assertEqual("100.00", item["period"]["debit"])
|
||||
self.assertEqual("100.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_admin_subject_decision_stale_revision_conflicts(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": pending["payer_company_id"],
|
||||
"subject_code": "other_receivable", "reason": "确认",
|
||||
"expected_revision": 999, "request_key": "stale-key",
|
||||
},
|
||||
)
|
||||
self.assertEqual(409, status, data)
|
||||
|
||||
def test_admin_events_list_and_evidence(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
event = next(
|
||||
item for item in items
|
||||
if item["ledger_event_id"] == revision["ledger_event_id"]
|
||||
)
|
||||
self.assertEqual("confirmed", event["state"])
|
||||
self.assertEqual("other_receivable", event["subject_code"])
|
||||
self.assertIn("payer_company_name", event)
|
||||
self.assertIn("amount", event)
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event['ledger_event_id']}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("confirmed", as_json(data)["state"])
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
blocks = as_json(data)["blocks"]
|
||||
self.assertTrue(blocks)
|
||||
for block in blocks:
|
||||
self.assertEqual("visible", block["visibility"])
|
||||
|
||||
def test_admin_events_list_includes_pending_subject(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
item = next(
|
||||
item for item in items
|
||||
if item["ledger_event_id"] == pending["ledger_event_id"]
|
||||
)
|
||||
self.assertEqual("pending_subject", item["state"])
|
||||
self.assertIsNone(item["subject_code"])
|
||||
|
||||
def test_admin_manual_record_decision_via_api(self) -> None:
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/manual-records",
|
||||
{
|
||||
"counterparty_company_id": self.company_b,
|
||||
"occurred_at": "2026-02-01T09:00:00",
|
||||
"direction": "incoming", "amount": "20.00", "currency": "CNY",
|
||||
"funding_source": "other", "requested_subject": "other_receivable",
|
||||
"request_key": "mr-api-1", "summary": "还款",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
record = as_json(data)["record"]
|
||||
self.assertEqual("pending", record["state"])
|
||||
|
||||
status, _, data = self.admin.get("/api/admin/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
records = as_json(data)["records"]
|
||||
self.assertTrue(any(item["id"] == record["id"] for item in records))
|
||||
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "approve_new", "reason": "银行流水中无此事实",
|
||||
"expected_decision_id": record["decision_id"], "request_key": "dec-api-1"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("approved", as_json(data)["decision"]["state"])
|
||||
|
||||
status, _, data = self.cashier_a.get("/api/company/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
own = [item for item in as_json(data)["records"] if item["id"] == record["id"]]
|
||||
self.assertEqual(1, len(own))
|
||||
self.assertEqual("approved", own[0]["state"])
|
||||
|
||||
status, _, data = self.cashier_b.get("/api/company/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual([], [
|
||||
item for item in as_json(data)["records"] if item["id"] == record["id"]
|
||||
])
|
||||
|
||||
def test_admin_adjustment_reverse_via_api(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{revision['ledger_event_id']}/adjustments",
|
||||
{"action": "reverse", "reason": "科目误判,冲销"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("reverse", as_json(data)["action"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Company scope and masking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_company_balances_scoped_to_own_company(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
for item in payload["items"]:
|
||||
self.assertEqual(self.company_a, item["company_id"])
|
||||
self.assertIn("counterparties", payload)
|
||||
self.assertTrue(payload["counterparties"])
|
||||
|
||||
def test_company_pair_and_events_are_own_scope(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get(
|
||||
f"/api/company/intercompany/pairs/{self.company_b}"
|
||||
"?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual(self.company_a, as_json(data)["companies"]["a"]["company_id"])
|
||||
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
for item in items:
|
||||
self.assertIn("direction", item)
|
||||
self.assertIn("own_subject_label", item)
|
||||
self.assertIn(item["direction"], ("incoming", "outgoing"))
|
||||
|
||||
def test_company_evidence_masks_counterparty_side(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.cashier_a.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
blocks = as_json(data)["blocks"]
|
||||
visibilities = {block["visibility"] for block in blocks}
|
||||
self.assertTrue(visibilities & {"visible", "masked"})
|
||||
for block in blocks:
|
||||
if block["visibility"] == "masked":
|
||||
self.assertEqual("按对方授权不可见", block["fields"].get("note"))
|
||||
self.assertNotIn(ACCOUNT_B, json.dumps(block["fields"], ensure_ascii=False))
|
||||
|
||||
def test_company_cannot_read_or_write_admin_intercompany(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get("/api/admin/intercompany/balances")
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.get("/api/admin/manual-records")
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/admin/manual-records/1/decisions", {"action": "approve_new", "reason": "x"}
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/admin/intercompany/events/1/adjustments",
|
||||
{"action": "reverse", "reason": "越权"},
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
def test_cross_company_reads_are_404(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_c.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}"
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
status, _, data = self.cashier_c.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
def test_company_counterparty_summary_splits_by_currency(self) -> None:
|
||||
# A->B 100 CNY and A->B 50 USD must render two rows, never a mixed
|
||||
# "CNY 150.00" bucket.
|
||||
cny = self._fresh_pair("100.00", currency="CNY")
|
||||
usd = self._fresh_pair("50.00", at="2026-01-20 10:00:00", currency="USD")
|
||||
for pending in (cny, usd):
|
||||
self._confirm(pending["ledger_event_id"])
|
||||
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
counterparties = as_json(data)["counterparties"]
|
||||
rows = [row for row in counterparties if row["counterparty_company_id"] == self.company_b]
|
||||
self.assertEqual(2, len(rows), counterparties)
|
||||
by_currency = {row["currency"]: row for row in rows}
|
||||
self.assertEqual({"CNY", "USD"}, set(by_currency))
|
||||
self.assertEqual("100.00", by_currency["CNY"]["result"]["signed_amount"])
|
||||
self.assertEqual("50.00", by_currency["USD"]["result"]["signed_amount"])
|
||||
for row in rows:
|
||||
self.assertNotEqual("150.00", row["result"]["signed_amount"])
|
||||
self.assertEqual(1, row["event_count"])
|
||||
|
||||
def test_company_events_subject_filter_matches_mirror(self) -> None:
|
||||
# A confirms subject "receivable" (stored from A's perspective); B must
|
||||
# still find the event when filtering by the mirror subject "payable".
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": self.company_a,
|
||||
"subject_code": "receivable", "reason": "确认应收",
|
||||
"expected_revision": 1, "request_key": "mirror-subj",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.cashier_b.get(
|
||||
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31&subject=payable"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == pending["ledger_event_id"] for item in items
|
||||
), data)
|
||||
|
||||
def test_manual_reverse_via_api_uses_explicit_effective_date(self) -> None:
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/manual-records",
|
||||
{
|
||||
"counterparty_company_id": self.company_b,
|
||||
"occurred_at": "2026-02-01T09:00:00",
|
||||
"direction": "incoming", "amount": "20.00", "currency": "CNY",
|
||||
"funding_source": "other", "requested_subject": "other_receivable",
|
||||
"request_key": "mr-rev-api", "summary": "还款",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
record = as_json(data)["record"]
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "approve_new", "reason": "确认入账",
|
||||
"expected_decision_id": record["decision_id"], "request_key": "dec-rev-api"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
event_id = as_json(data)["decision"]["ledger_event_id"]
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
revision_id = as_json(data)["event"]["ledger_revision_id"]
|
||||
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "reverse", "reason": "误录冲销", "effective_at": "2026-06-15",
|
||||
"expected_decision_id": None, "request_key": "dec-rev-api-2"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
reversals = [item for item in as_json(data)["items"] if item["posting_kind"] == "reversal"]
|
||||
self.assertEqual(1, len(reversals))
|
||||
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
|
||||
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
|
||||
|
||||
def test_admin_events_include_account_chips(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
event = next(
|
||||
item for item in as_json(data)["items"]
|
||||
if item["ledger_event_id"] == revision["ledger_event_id"]
|
||||
)
|
||||
self.assertIn("payer_account", event)
|
||||
self.assertIn("payee_account", event)
|
||||
self.assertEqual("visible", event["payer_account"]["visibility"])
|
||||
self.assertTrue(event["payer_account"]["label"])
|
||||
self.assertIn("summary", event)
|
||||
self.assertIn("is_repayment", event)
|
||||
|
||||
def test_subject_exception_drops_from_review_queue(self) -> None:
|
||||
pending = self._fresh_pair("77.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": pending["payer_company_id"],
|
||||
"action": "exception",
|
||||
"reason": "转异常待核查",
|
||||
"expected_revision": pending["revision_id"],
|
||||
"request_key": "park-exc-api",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("pending_subject", as_json(data)["revision"]["state"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
ids = [item["ledger_event_id"] for item in as_json(data)["items"]]
|
||||
self.assertNotIn(pending["ledger_event_id"], ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
+920
-189
File diff suppressed because it is too large
Load Diff
+3027
-475
File diff suppressed because it is too large
Load Diff
+1063
-151
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+30
-45
@@ -1,48 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="河南金牛实业集团内部资金往来与银行流水管理" />
|
||||
<title>登录 · 河南金牛实业集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body class="entry-page">
|
||||
<!--
|
||||
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
|
||||
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
|
||||
STORY: 居中集团登录卡:先确认集团身份与系统名称,再选择工作端口登录。
|
||||
FIRST VIEWPORT: 一张悬浮玻璃登录卡居于氛围光中央,集团名称置顶,端口选择、账号表单与系统事实依次排列。
|
||||
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<main class="entry-shell">
|
||||
<section class="entry-card" aria-labelledby="login-title">
|
||||
<div class="entry-brand"><span class="brand-mark">金</span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div>
|
||||
<form class="entry-form" id="loginForm">
|
||||
<header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header>
|
||||
<div class="role-switch" role="radiogroup" aria-label="工作端口">
|
||||
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label>
|
||||
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label>
|
||||
</div>
|
||||
<label class="field"><span>账号</span><input name="username" autocomplete="username" required /></label>
|
||||
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
|
||||
<div id="changePassword" hidden>
|
||||
<p class="entry-note">首次登录须修改密码,请设置新密码后再进入工作台。</p>
|
||||
<label class="field"><span>新密码</span><input name="new_password" type="password" autocomplete="new-password" /></label>
|
||||
<label class="field"><span>确认新密码</span><input name="confirm_password" type="password" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<p class="entry-note" id="loginError" role="alert" hidden></p>
|
||||
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
|
||||
<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>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>金牛实业资金往来管理系统 · 入口</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal-wrap">
|
||||
<div class="portal-inner">
|
||||
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
|
||||
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
|
||||
|
||||
<div class="portal-grid">
|
||||
<a class="portal-card" href="login-admin.html">
|
||||
<span class="pc-role">管理端 · 7 个页面</span>
|
||||
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
|
||||
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<span class="pc-go">去管理端登录 →</span>
|
||||
</a>
|
||||
<a class="portal-card" href="login-company.html">
|
||||
<span class="pc-role">公司业务端 · 7 个页面</span>
|
||||
<h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 往来确认 / 银行账户 / 通知</h3>
|
||||
<p>面向成员公司出纳:上传银行流水、登记手工往来、确认单边匹配与科目,跟踪本月完成进度。</p>
|
||||
<span class="pc-go">去公司端登录 →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-role="admin">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
<div class="aside-list">
|
||||
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
|
||||
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
|
||||
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
|
||||
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title admin">管理端</div>
|
||||
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录管理端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="account">账号</label>
|
||||
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
|
||||
<span class="hint error" id="account-err" hidden>请输入账号</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<span class="hint error" id="password-err" hidden>请输入密码</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="hint error" id="login-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="pwd-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
|
||||
</div>
|
||||
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="new-pwd">新密码</label>
|
||||
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-pwd2">确认新密码</label>
|
||||
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<span class="hint error" id="pwd-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-role="company">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside company">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
<div class="aside-list">
|
||||
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
|
||||
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
|
||||
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
|
||||
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back company" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title company">公司端</div>
|
||||
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="account">账号</label>
|
||||
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
|
||||
<span class="hint error" id="account-err" hidden>请输入账号</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<span class="hint error" id="password-err" hidden>请输入密码</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="hint error" id="login-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="pwd-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
|
||||
</div>
|
||||
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="new-pwd">新密码</label>
|
||||
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-pwd2">确认新密码</label>
|
||||
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
|
||||
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<span class="hint error" id="pwd-err" role="alert" hidden></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
const ROLE = document.body.dataset.role || "admin";
|
||||
const REDIRECT = ROLE === "admin" ? "admin.html" : "company.html";
|
||||
|
||||
const form = document.getElementById("login-form");
|
||||
const accountInput = document.getElementById("account");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const accountErr = document.getElementById("account-err");
|
||||
const passwordErr = document.getElementById("password-err");
|
||||
const loginErr = document.getElementById("login-err");
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
|
||||
const modal = document.getElementById("pwd-modal");
|
||||
const newPwd = document.getElementById("new-pwd");
|
||||
const newPwd2 = document.getElementById("new-pwd2");
|
||||
const newPwdErr = document.getElementById("new-pwd-err");
|
||||
const newPwd2Err = document.getElementById("new-pwd2-err");
|
||||
const pwdErr = document.getElementById("pwd-err");
|
||||
const pwdConfirm = document.getElementById("pwd-confirm");
|
||||
|
||||
const submitLabel = "登 录";
|
||||
|
||||
function setLoading(loading) {
|
||||
submitButton.disabled = loading;
|
||||
submitButton.textContent = loading ? "登录中…" : submitLabel;
|
||||
accountInput.disabled = loading;
|
||||
passwordInput.disabled = loading;
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
loginErr.textContent = message || "";
|
||||
loginErr.hidden = !message;
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
newPwd.value = "";
|
||||
newPwd2.value = "";
|
||||
newPwdErr.hidden = true;
|
||||
newPwd2Err.hidden = true;
|
||||
pwdErr.hidden = true;
|
||||
modal.classList.add("open");
|
||||
newPwd.focus();
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
showLoginError("");
|
||||
const account = accountInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
accountErr.hidden = Boolean(account);
|
||||
passwordErr.hidden = Boolean(password);
|
||||
if (!account || !password) return;
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: account, password, portal: ROLE }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showLoginError(result?.message || "登录服务暂时不可用,请稍后重试。");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (result.must_change_password) {
|
||||
setLoading(false);
|
||||
openPasswordModal();
|
||||
return;
|
||||
}
|
||||
window.location.href = REDIRECT;
|
||||
});
|
||||
|
||||
pwdConfirm.addEventListener("click", async () => {
|
||||
const p1 = newPwd.value;
|
||||
const p2 = newPwd2.value;
|
||||
const valid = p1.length >= 8 && /[a-zA-Z]/.test(p1) && /\d/.test(p1);
|
||||
newPwdErr.hidden = valid;
|
||||
newPwd2Err.hidden = p1 === p2;
|
||||
pwdErr.hidden = true;
|
||||
if (!valid || p1 !== p2) return;
|
||||
|
||||
pwdConfirm.disabled = true;
|
||||
const response = await fetch("/api/password/change", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old_password: passwordInput.value, new_password: p1 }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
pwdErr.textContent = result?.message || "修改密码失败,请稍后重试。";
|
||||
pwdErr.hidden = false;
|
||||
pwdConfirm.disabled = false;
|
||||
return;
|
||||
}
|
||||
window.location.href = REDIRECT;
|
||||
});
|
||||
-738
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user