Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e11a4f72 | ||
|
|
212b1f9b9b | ||
|
|
f17636183d | ||
|
|
d6f39e018d | ||
|
|
2fb1f2b462 | ||
|
|
fe6b4e59ec | ||
|
|
27f2b0b69a | ||
|
|
b27016d71d | ||
|
|
96309eb2cc | ||
|
|
40f7a91a0f | ||
|
|
cad12b3d28 | ||
|
|
7e7354a809 | ||
|
|
642604f688 | ||
|
|
bf5754ee09 | ||
|
|
551909ad0c | ||
|
|
eefdc92ab6 | ||
|
|
fdc5616115 | ||
|
|
c4f7573653 | ||
|
|
5b3ebad757 | ||
|
|
b42b6481f5 | ||
|
|
e04f47b9d2 | ||
|
|
7fb97119ba | ||
|
|
815b68c1fc | ||
|
|
609ebffdc9 | ||
|
|
77fc625ded | ||
|
|
ece3e53472 | ||
|
|
5816e8aa71 | ||
|
|
951b353765 | ||
|
|
20d967a4a4 | ||
|
|
31fdc2e456 | ||
|
|
1d07812fa8 | ||
|
|
2fe06b74ae | ||
|
|
307f9ba76f | ||
|
|
020b556801 | ||
|
|
7938a274f2 | ||
|
|
dc0fb76f8f | ||
|
|
5a6a629597 | ||
|
|
5f215271af | ||
|
|
3b10a58d93 | ||
|
|
6d23711e6c | ||
|
|
a621ebc323 |
@@ -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` 覆盖):
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md`、`项目需求.md`、`最新进度.md` 和 `任务清单.md`。
|
||||
|
||||
# 项目交接说明
|
||||
|
||||
更新时间:2026-08-06
|
||||
|
||||
@@ -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 分钟」的生产安全策略不变。
|
||||
|
||||
## 角色与公司绑定
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 仓库银行样本数据分级与脱敏治理
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 持久化层与不可变银行导入基础
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 正式认证、RBAC 与公司级数据隔离
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 动态公司、用户、账户和别名主数据
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 银行导入 API 加固与回归测试
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 规范转账事件、双边匹配与调拨排除
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -798,6 +798,304 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
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;
|
||||
""",
|
||||
),
|
||||
|
||||
Migration(
|
||||
version=9,
|
||||
name="0009_reminders_engine",
|
||||
# HEL-195/215 auto-reminder engine. The old manual reminders table
|
||||
# from 0007 is preserved verbatim under reminders_legacy_manual so
|
||||
# existing test-env history stays queryable; the new engine uses its
|
||||
# own append-only reminders/reminder_events schema.
|
||||
up="""
|
||||
ALTER TABLE reminders RENAME TO reminders_legacy_manual;
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
CREATE INDEX idx_reminders_legacy_company
|
||||
ON reminders_legacy_manual (company_id);
|
||||
|
||||
CREATE TABLE reminder_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO reminder_settings (key, value, updated_at) VALUES
|
||||
('monthly_start_day', '5', datetime('now')),
|
||||
('gap_days', '5', datetime('now')),
|
||||
('scan_time', '08:00', datetime('now'));
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
rule_key TEXT NOT NULL CHECK (rule_key IN (
|
||||
'unsubmitted', 'gap', 'pending_review', 'manual'
|
||||
)),
|
||||
dedupe_key TEXT NOT NULL UNIQUE,
|
||||
rule_params TEXT NOT NULL DEFAULT '{}',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('auto', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
send_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_sent_at TEXT,
|
||||
last_sent_at TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
action_link TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
CREATE INDEX idx_reminders_status ON reminders (status);
|
||||
|
||||
CREATE TABLE reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reminder_id INTEGER NOT NULL REFERENCES reminders (id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN (
|
||||
'sent', 'acknowledged', 'resolved', 'escalated'
|
||||
)),
|
||||
actor TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminder_events_reminder ON reminder_events (reminder_id);
|
||||
|
||||
CREATE TRIGGER reminder_events_no_update BEFORE UPDATE ON reminder_events
|
||||
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
|
||||
CREATE TRIGGER reminder_events_no_delete BEFORE DELETE ON reminder_events
|
||||
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
|
||||
CREATE TRIGGER reminders_no_delete BEFORE DELETE ON reminders
|
||||
BEGIN SELECT RAISE (ABORT, 'reminders rows are immutable history'); END;
|
||||
""",
|
||||
down="""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_update;
|
||||
DROP INDEX IF EXISTS idx_reminder_events_reminder;
|
||||
DROP TABLE IF EXISTS reminder_events;
|
||||
DROP INDEX IF EXISTS idx_reminders_status;
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS reminder_settings;
|
||||
DROP INDEX IF EXISTS idx_reminders_legacy_company;
|
||||
ALTER TABLE reminders_legacy_manual RENAME TO reminders;
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"""Read-only reminder rule engine and delivery helpers.
|
||||
|
||||
Scans existing tables to surface pending items; delivery writes append-only
|
||||
``reminders`` / ``reminder_events`` rows without touching bank or business data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import utc_now
|
||||
|
||||
RULE_UNSUBMITTED = "unsubmitted"
|
||||
RULE_GAP = "gap"
|
||||
RULE_PENDING = "pending_review"
|
||||
RULE_MANUAL = "manual"
|
||||
|
||||
SETTING_MONTHLY_START_DAY = "monthly_start_day"
|
||||
SETTING_GAP_DAYS = "gap_days"
|
||||
SETTING_SCAN_TIME = "scan_time"
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
SETTING_MONTHLY_START_DAY: "5",
|
||||
SETTING_GAP_DAYS: "5",
|
||||
SETTING_SCAN_TIME: "08:00",
|
||||
}
|
||||
|
||||
RULE_LABELS = {
|
||||
RULE_UNSUBMITTED: "流水未提交",
|
||||
RULE_GAP: "流水断档",
|
||||
RULE_PENDING: "待确认/待审核",
|
||||
}
|
||||
|
||||
ACTION_LINKS = {
|
||||
RULE_UNSUBMITTED: "upload",
|
||||
RULE_GAP: "flows",
|
||||
RULE_PENDING: "reconcile",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanFinding:
|
||||
company_id: int
|
||||
company_name: str
|
||||
rule_key: str
|
||||
dedupe_key: str
|
||||
rule_params: dict[str, Any]
|
||||
title: str
|
||||
content: str
|
||||
reason: str
|
||||
days_open: int
|
||||
deadline: str | None
|
||||
action_link: str
|
||||
existing_reminder_id: int | None
|
||||
send_count: int
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
rows = connection.execute("SELECT key, value FROM reminder_settings").fetchall()
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def _valid_scan_time(value: str) -> bool:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit():
|
||||
return False
|
||||
if len(parts[1]) != 2:
|
||||
return False
|
||||
hour, minute = int(parts[0]), int(parts[1])
|
||||
return 0 <= hour <= 23 and 0 <= minute <= 59
|
||||
|
||||
|
||||
def validate_setting(key: str, value: str) -> str:
|
||||
raw = str(value).strip()
|
||||
if key == SETTING_MONTHLY_START_DAY:
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("每月起始日须为 1 到 28 的整数。") from exc
|
||||
if day < 1 or day > 28:
|
||||
raise ValueError("每月起始日须为 1 到 28 的整数。")
|
||||
return str(day)
|
||||
if key == SETTING_GAP_DAYS:
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("断档天数须为大于等于 1 的整数。") from exc
|
||||
if days < 1:
|
||||
raise ValueError("断档天数须为大于等于 1 的整数。")
|
||||
return str(days)
|
||||
if key == SETTING_SCAN_TIME:
|
||||
if not _valid_scan_time(raw):
|
||||
raise ValueError("扫描时间须为合法的 HH:MM。")
|
||||
hour, minute = raw.split(":")
|
||||
return f"{int(hour):02d}:{minute}"
|
||||
raise ValueError(f"unknown setting: {key}")
|
||||
|
||||
|
||||
def update_settings(connection: sqlite3.Connection, updates: dict[str, str]) -> dict[str, str]:
|
||||
allowed = set(DEFAULT_SETTINGS)
|
||||
cleaned: dict[str, str] = {}
|
||||
for key, value in updates.items():
|
||||
if key not in allowed:
|
||||
raise ValueError(f"unknown setting: {key}")
|
||||
cleaned[key] = validate_setting(key, value)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
for key, value in cleaned.items():
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminder_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, value, now),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
def _setting_int(settings: dict[str, str], key: str) -> int:
|
||||
try:
|
||||
return int(validate_setting(key, settings.get(key, DEFAULT_SETTINGS[key])))
|
||||
except (TypeError, ValueError):
|
||||
return int(DEFAULT_SETTINGS[key])
|
||||
|
||||
|
||||
def _month_period(day: date | None = None) -> str:
|
||||
ref = day or _today()
|
||||
return f"{ref.year:04d}-{ref.month:02d}"
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _days_between(start: date, end: date) -> int:
|
||||
return max(0, (end - start).days)
|
||||
|
||||
|
||||
def _duration_pill_class(days: int) -> str:
|
||||
if days >= 7:
|
||||
return "pill-danger"
|
||||
if days >= 3:
|
||||
return "pill-warn"
|
||||
return "pill-muted"
|
||||
|
||||
|
||||
def _existing_reminder(connection: sqlite3.Connection, dedupe_key: str) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, send_count, status, last_sent_at
|
||||
FROM reminders
|
||||
WHERE dedupe_key = ? AND status != 'resolved'
|
||||
""",
|
||||
(dedupe_key,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _company_has_month_batch(connection: sqlite3.Connection, company_id: int, period: str) -> bool:
|
||||
year, month = period.split("-")
|
||||
prefix = f"{year}-{month}"
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM import_batches b
|
||||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
WHERE b.company_id = ?
|
||||
AND (
|
||||
substr(s.period_start, 1, 7) = ?
|
||||
OR substr(s.period_end, 1, 7) = ?
|
||||
OR (s.period_start <= ? || '-31' AND s.period_end >= ? || '-01')
|
||||
)
|
||||
LIMIT 1
|
||||
""",
|
||||
(company_id, prefix, prefix, prefix, prefix),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _latest_batch_end(connection: sqlite3.Connection, company_id: int) -> date | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT MAX(s.period_end) AS latest_end
|
||||
FROM import_batches b
|
||||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
WHERE b.company_id = ?
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()
|
||||
return _parse_date(row["latest_end"] if row else None)
|
||||
|
||||
|
||||
def _pending_review_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||||
match_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT e.id)
|
||||
FROM canonical_transfer_events e
|
||||
JOIN current_transfer_decisions c ON c.event_id = e.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 IN ('unresolved', 'needs_review')
|
||||
AND (payer.company_id = ? OR payee.company_id = ?)
|
||||
""",
|
||||
(company_id, company_id),
|
||||
).fetchone()[0]
|
||||
sheet_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sheet_reviews r
|
||||
JOIN import_batches b ON b.id = r.import_batch_id
|
||||
WHERE b.company_id = ?
|
||||
AND r.outcome = 'parsed'
|
||||
AND r.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()[0]
|
||||
account_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()[0]
|
||||
return int(match_count) + int(sheet_count) + int(account_count)
|
||||
|
||||
|
||||
def _active_companies(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, name FROM companies
|
||||
WHERE COALESCE(status, 'active') != 'disabled'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def scan_findings(connection: sqlite3.Connection) -> list[ScanFinding]:
|
||||
settings = get_settings(connection)
|
||||
today = _today()
|
||||
period = _month_period(today)
|
||||
monthly_start = _setting_int(settings, SETTING_MONTHLY_START_DAY)
|
||||
gap_days = _setting_int(settings, SETTING_GAP_DAYS)
|
||||
findings: list[ScanFinding] = []
|
||||
|
||||
for company in _active_companies(connection):
|
||||
company_id = int(company["id"])
|
||||
company_name = company["name"]
|
||||
|
||||
if today.day >= monthly_start and not _company_has_month_batch(connection, company_id, period):
|
||||
dedupe_key = f"{company_id}:{RULE_UNSUBMITTED}:{period}"
|
||||
days_open = _days_between(date(today.year, today.month, monthly_start), today)
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_UNSUBMITTED,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"period": period},
|
||||
title=f"{company_name} · {today.month} 月流水未提交",
|
||||
content=(
|
||||
f"贵公司 {period} 银行流水尚未提交。"
|
||||
f"请于截止日前完成全部账户流水上传。"
|
||||
),
|
||||
reason=f"应交 {today.month:02d}-{monthly_start:02d} · 已逾期 {days_open} 天",
|
||||
days_open=days_open,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_UNSUBMITTED],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
latest_end = _latest_batch_end(connection, company_id)
|
||||
if latest_end is not None:
|
||||
gap = _days_between(latest_end, today)
|
||||
if gap > gap_days:
|
||||
dedupe_key = f"{company_id}:{RULE_GAP}:{latest_end.isoformat()}"
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_GAP,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"latest_end": latest_end.isoformat(), "gap_days": gap},
|
||||
title=f"{company_name} · 流水断档 {gap} 天",
|
||||
content=(
|
||||
f"最近流水截止日为 {latest_end.isoformat()},"
|
||||
f"已连续 {gap} 天无新数据,请补传断档期间银行流水。"
|
||||
),
|
||||
reason=f"最近截止 {latest_end.strftime('%m-%d')} · 断档 {gap} 天",
|
||||
days_open=gap,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_GAP],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
pending_count = _pending_review_count(connection, company_id)
|
||||
if pending_count > 0:
|
||||
dedupe_key = f"{company_id}:{RULE_PENDING}:active"
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
first_seen = _parse_date(existing["last_sent_at"][:10] if existing and existing["last_sent_at"] else None)
|
||||
days_open = _days_between(first_seen, today) if first_seen else 0
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_PENDING,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"pending_count": pending_count},
|
||||
title=f"{company_name} · {pending_count} 项待确认/待审核",
|
||||
content=(
|
||||
f"贵公司当前有 {pending_count} 项往来确认、流水审核或账户登记待处理,"
|
||||
f"请尽快完成确认以免影响结账。"
|
||||
),
|
||||
reason=f"待处理 {pending_count} 项",
|
||||
days_open=days_open,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_PENDING],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
findings.sort(key=lambda item: item.days_open, reverse=True)
|
||||
return findings
|
||||
|
||||
|
||||
def run_scan(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
findings = scan_findings(connection)
|
||||
counts = {
|
||||
RULE_UNSUBMITTED: sum(1 for f in findings if f.rule_key == RULE_UNSUBMITTED),
|
||||
RULE_GAP: sum(1 for f in findings if f.rule_key == RULE_GAP),
|
||||
RULE_PENDING: sum(1 for f in findings if f.rule_key == RULE_PENDING),
|
||||
}
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_scan",
|
||||
actor=actor,
|
||||
detail=json.dumps({"total": len(findings), "by_rule": counts}, ensure_ascii=False),
|
||||
ip=ip,
|
||||
)
|
||||
return {"findings": findings, "counts": counts}
|
||||
|
||||
|
||||
def finding_to_dict(finding: ScanFinding) -> dict[str, Any]:
|
||||
return {
|
||||
"company_id": finding.company_id,
|
||||
"company_name": finding.company_name,
|
||||
"rule_key": finding.rule_key,
|
||||
"rule_label": RULE_LABELS.get(finding.rule_key, finding.rule_key),
|
||||
"dedupe_key": finding.dedupe_key,
|
||||
"rule_params": finding.rule_params,
|
||||
"title": finding.title,
|
||||
"content": finding.content,
|
||||
"reason": finding.reason,
|
||||
"days_open": finding.days_open,
|
||||
"duration_pill": _duration_pill_class(finding.days_open),
|
||||
"deadline": finding.deadline,
|
||||
"action_link": finding.action_link,
|
||||
"existing_reminder_id": finding.existing_reminder_id,
|
||||
"send_count": finding.send_count,
|
||||
}
|
||||
|
||||
|
||||
def _append_event(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
event_type: str,
|
||||
actor: str,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminder_events (reminder_id, event_type, actor, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(reminder_id, event_type, actor, detail, utc_now()),
|
||||
)
|
||||
|
||||
|
||||
def _actor_label(actor: sqlite3.Row | None) -> str:
|
||||
if actor is None:
|
||||
return "system"
|
||||
return str(actor["id"])
|
||||
|
||||
|
||||
def deliver_finding(
|
||||
connection: sqlite3.Connection,
|
||||
dedupe_key: str,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> int | None:
|
||||
findings = {item.dedupe_key: item for item in scan_findings(connection)}
|
||||
finding = findings.get(dedupe_key)
|
||||
if finding is None:
|
||||
return None
|
||||
return _deliver(connection, finding, actor=actor, ip=ip)
|
||||
|
||||
|
||||
def _deliver(
|
||||
connection: sqlite3.Connection,
|
||||
finding: ScanFinding,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
now = utc_now()
|
||||
actor_ref = _actor_label(actor)
|
||||
existing = connection.execute(
|
||||
"SELECT id, send_count, status FROM reminders WHERE dedupe_key = ?",
|
||||
(finding.dedupe_key,),
|
||||
).fetchone()
|
||||
|
||||
with connection:
|
||||
if existing is not None:
|
||||
reminder_id = int(existing["id"])
|
||||
send_count = int(existing["send_count"]) + 1
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET send_count = ?, last_sent_at = ?, status = 'open',
|
||||
title = ?, content = ?, rule_params = ?, action_link = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
send_count,
|
||||
now,
|
||||
finding.title,
|
||||
finding.content,
|
||||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||||
finding.action_link,
|
||||
reminder_id,
|
||||
),
|
||||
)
|
||||
event_type = "sent" if existing["status"] == "resolved" else (
|
||||
"escalated" if send_count > 1 else "sent"
|
||||
)
|
||||
_append_event(
|
||||
connection,
|
||||
reminder_id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
f"第 {send_count} 次催办",
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||||
created_by, created_at, action_link
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'auto', 'open', 1, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
finding.company_id,
|
||||
finding.rule_key,
|
||||
finding.dedupe_key,
|
||||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||||
finding.title,
|
||||
finding.content,
|
||||
finding.deadline,
|
||||
now,
|
||||
now,
|
||||
actor_ref,
|
||||
now,
|
||||
finding.action_link,
|
||||
),
|
||||
)
|
||||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
_append_event(connection, reminder_id, "sent", actor_ref, finding.reason)
|
||||
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
detail=finding.dedupe_key,
|
||||
ip=ip,
|
||||
)
|
||||
return reminder_id
|
||||
|
||||
|
||||
def deliver_many(
|
||||
connection: sqlite3.Connection,
|
||||
dedupe_keys: list[str],
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> list[int]:
|
||||
sent: list[int] = []
|
||||
for key in dedupe_keys:
|
||||
try:
|
||||
reminder_id = deliver_finding(connection, key, actor=actor, ip=ip)
|
||||
except Exception:
|
||||
continue
|
||||
if reminder_id is not None:
|
||||
sent.append(reminder_id)
|
||||
return sent
|
||||
|
||||
|
||||
def send_manual(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
display_type: str,
|
||||
content: str,
|
||||
deadline: str | None,
|
||||
actor: sqlite3.Row,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise ValueError("company not found")
|
||||
now = utc_now()
|
||||
dedupe_key = f"{company_id}:{RULE_MANUAL}:{uuid.uuid4().hex}"
|
||||
title = f"{company['name']} · {display_type}"
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||||
created_by, created_at, action_link
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'manual', 'open', 1, ?, ?, ?, ?, NULL)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
RULE_MANUAL,
|
||||
dedupe_key,
|
||||
json.dumps({"display_type": display_type}, ensure_ascii=False),
|
||||
title,
|
||||
content,
|
||||
deadline,
|
||||
now,
|
||||
now,
|
||||
str(actor["id"]),
|
||||
now,
|
||||
),
|
||||
)
|
||||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
_append_event(connection, reminder_id, "sent", str(actor["id"]), display_type)
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send_manual",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
detail=display_type,
|
||||
ip=ip,
|
||||
)
|
||||
return reminder_id
|
||||
|
||||
|
||||
def resend_reminder(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
*,
|
||||
actor: sqlite3.Row,
|
||||
ip: str | None = None,
|
||||
) -> None:
|
||||
row = connection.execute(
|
||||
"SELECT id, send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] == "resolved":
|
||||
raise ValueError("reminder not available")
|
||||
now = utc_now()
|
||||
send_count = int(row["send_count"]) + 1
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET send_count = ?, last_sent_at = ?, status = 'open'
|
||||
WHERE id = ?
|
||||
""",
|
||||
(send_count, now, reminder_id),
|
||||
)
|
||||
_append_event(
|
||||
connection,
|
||||
reminder_id,
|
||||
"escalated",
|
||||
str(actor["id"]),
|
||||
f"第 {send_count} 次催办",
|
||||
)
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_resend",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
|
||||
def _reminder_row_to_dict(row: sqlite3.Row, *, company_name: str | None = None) -> dict[str, Any]:
|
||||
params = json.loads(row["rule_params"]) if row["rule_params"] else {}
|
||||
rule_key = row["rule_key"]
|
||||
display_type = params.get("display_type") if rule_key == RULE_MANUAL else RULE_LABELS.get(rule_key, rule_key)
|
||||
status = row["status"]
|
||||
status_ui = {"open": "unread", "acknowledged": "doing", "resolved": "done"}[status]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": company_name or row["company_name"],
|
||||
"rule_key": rule_key,
|
||||
"display_type": display_type,
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"deadline": row["deadline"],
|
||||
"source": row["source"],
|
||||
"status": status,
|
||||
"status_ui": status_ui,
|
||||
"send_count": row["send_count"],
|
||||
"first_sent_at": row["first_sent_at"],
|
||||
"last_sent_at": row["last_sent_at"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"action_link": row["action_link"],
|
||||
"rule_params": params,
|
||||
}
|
||||
|
||||
|
||||
def list_admin_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
conditions = ["r.send_count > 0"]
|
||||
params: list[Any] = []
|
||||
if source in {"auto", "manual"}:
|
||||
conditions.append("r.source = ?")
|
||||
params.append(source)
|
||||
where = " AND ".join(conditions)
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE {where}
|
||||
ORDER BY r.last_sent_at DESC, r.id DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [_reminder_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def list_company_reminders(connection: sqlite3.Connection, company_id: int) -> list[dict[str, Any]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE r.company_id = ? AND r.send_count > 0
|
||||
ORDER BY
|
||||
CASE r.status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
|
||||
r.last_sent_at DESC
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
return [_reminder_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_reminder_detail(connection: sqlite3.Connection, reminder_id: int) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(reminder_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT event_type, actor, detail, created_at
|
||||
FROM reminder_events
|
||||
WHERE reminder_id = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(reminder_id,),
|
||||
).fetchall()
|
||||
payload = _reminder_row_to_dict(row)
|
||||
payload["events"] = [dict(item) for item in events]
|
||||
return payload
|
||||
|
||||
|
||||
def update_reminder_status(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
new_status: str,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
) -> bool:
|
||||
if new_status not in {"acknowledged", "resolved"}:
|
||||
raise ValueError("invalid status")
|
||||
row = connection.execute(
|
||||
"SELECT id, company_id, status FROM reminders WHERE id = ?",
|
||||
(reminder_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
if company_id is not None and int(row["company_id"]) != company_id:
|
||||
return False
|
||||
if row["status"] == "resolved":
|
||||
return False
|
||||
event_type = "acknowledged" if new_status == "acknowledged" else "resolved"
|
||||
actor_ref = _actor_label(actor)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE reminders SET status = ? WHERE id = ?",
|
||||
(new_status, reminder_id),
|
||||
)
|
||||
_append_event(connection, reminder_id, event_type, actor_ref, None)
|
||||
return True
|
||||
|
||||
|
||||
def company_unread_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM reminders
|
||||
WHERE company_id = ? AND status = 'open' AND send_count > 0
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
@@ -0,0 +1,133 @@
|
||||
"""System settings.
|
||||
|
||||
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. The auto-reminder engine itself lives in
|
||||
``reminders.py`` (HEL-195); this module only owns the settings store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
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
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -1,86 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const ui = require(path.join(__dirname, "..", "web", "app.js"));
|
||||
|
||||
assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
|
||||
assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
|
||||
assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
|
||||
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
|
||||
|
||||
assert.strictEqual(ui.cycleTab(0, 4, false), 1);
|
||||
assert.strictEqual(ui.cycleTab(3, 4, false), 0);
|
||||
assert.strictEqual(ui.cycleTab(0, 4, true), 3);
|
||||
assert.strictEqual(ui.cycleTab(2, 5, true), 1);
|
||||
assert.strictEqual(ui.cycleTab(0, 0, false), 0);
|
||||
|
||||
assert.strictEqual(ui.drawerEscAction(1), "close");
|
||||
assert.strictEqual(ui.drawerEscAction(0), "close");
|
||||
assert.strictEqual(ui.drawerEscAction(2), "back");
|
||||
assert.strictEqual(ui.drawerEscAction(3), "back");
|
||||
|
||||
const abs = ui.amountWithCurrency(1280, "CNY");
|
||||
assert.ok(abs.includes("CNY"));
|
||||
assert.ok(abs.includes("1,280.00"));
|
||||
assert.ok(!abs.includes("+"));
|
||||
assert.ok(!abs.includes("−"));
|
||||
|
||||
const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
|
||||
assert.ok(repay.includes("−"));
|
||||
assert.ok(!repay.includes("+"));
|
||||
assert.ok(repay.includes("3,200,000.00"));
|
||||
|
||||
assert.strictEqual(ui.resultDirection(10).label, "应收");
|
||||
assert.strictEqual(ui.resultDirection(-10).label, "应付");
|
||||
assert.strictEqual(ui.resultDirection(0).label, "持平");
|
||||
|
||||
assert.strictEqual(ui.isCompactAmount(1280), false);
|
||||
assert.strictEqual(ui.isCompactAmount(999999999), false);
|
||||
assert.strictEqual(ui.isCompactAmount(1000000000), true);
|
||||
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
|
||||
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
|
||||
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
|
||||
|
||||
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
|
||||
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
|
||||
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
|
||||
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
|
||||
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
|
||||
|
||||
const subjectFields = ui.auditEvidenceFields("subject-review", {
|
||||
direction: "outgoing",
|
||||
effectiveAt: "2026-07-18T09:00:00",
|
||||
amount: "600",
|
||||
currency: "CNY",
|
||||
summary: "资金调拨",
|
||||
});
|
||||
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
|
||||
assert.strictEqual(subjectFields[0][1], "转出");
|
||||
|
||||
const manualFields = ui.auditEvidenceFields("manual-review", {
|
||||
counterpartyName: "乙公司",
|
||||
direction: "incoming",
|
||||
relatedSourceRowId: "",
|
||||
submittedBy: "出纳甲",
|
||||
attachmentName: "",
|
||||
amount: "80",
|
||||
currency: "CNY",
|
||||
summary: "补记",
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
manualFields.map((item) => item[0]),
|
||||
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
|
||||
);
|
||||
assert.strictEqual(manualFields[0][1], "乙公司");
|
||||
assert.strictEqual(manualFields[1][1], "转入");
|
||||
assert.strictEqual(manualFields[2][1], "无关联流水");
|
||||
|
||||
assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
|
||||
assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
|
||||
assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
|
||||
|
||||
console.log("b44_ui_check ok");
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>B-44 余额目录布局回归</title>
|
||||
<link rel="stylesheet" href="../../web/styles.css" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" type="button"><span>往来查询</span></button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active">
|
||||
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
|
||||
<section class="panel company-ledger-panel" aria-label="公司余额目录">
|
||||
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
|
||||
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
|
||||
<div class="company-ledgers">
|
||||
<details class="company-ledger is-balances">
|
||||
<summary>
|
||||
<span class="company-name" title="甲公司"><i>甲</i><b>甲公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
|
||||
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
<details class="company-ledger is-balances" id="stressRow">
|
||||
<summary>
|
||||
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i>东</i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
|
||||
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer is-open" id="evidenceDrawer">
|
||||
<header class="drawer-header">
|
||||
<div>
|
||||
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
|
||||
<h2>甲公司 ↔ 乙公司</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="drawer-body">
|
||||
<div class="pair-balance-line is-six" id="drawerSix">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
|
||||
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
|
||||
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
|
||||
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<pre id="b175-metrics" hidden></pre>
|
||||
<script>
|
||||
function box(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
||||
}
|
||||
function overlap(a, b) {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(Math.min(dx, dy) === dy ? dx : dx);
|
||||
}
|
||||
function clipped(el) {
|
||||
return el.scrollWidth > el.clientWidth + 1;
|
||||
}
|
||||
function measure() {
|
||||
const row = document.querySelector("#stressRow");
|
||||
const summary = row.querySelector("summary");
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const head = document.querySelector(".ledger-head.is-balances");
|
||||
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
const cutoffBox = box(cutoff);
|
||||
const chevronBox = box(chevron);
|
||||
const directionBox = box(direction);
|
||||
const metrics = {
|
||||
viewport: window.innerWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
fiveColVisible: getComputedStyle(head).display !== "none",
|
||||
amountText: amount.textContent.replace(/\s+/g, " ").trim(),
|
||||
unresolvedText: unresolved.textContent.replace(/\s+/g, " ").trim(),
|
||||
cutoffText: cutoff.textContent.replace(/\s+/g, " ").trim(),
|
||||
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
|
||||
amountCutoffOverlap: overlap(amountBox, cutoffBox),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
|
||||
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
|
||||
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
|
||||
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
amountVisible: amountBox.width > 4 && amountBox.height > 4,
|
||||
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
|
||||
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
drawerAmountClipped: clipped(drawerAmount),
|
||||
summaryWidth: Math.round(box(summary).width),
|
||||
};
|
||||
const node = document.getElementById("b175-metrics");
|
||||
node.hidden = false;
|
||||
node.textContent = JSON.stringify(metrics);
|
||||
document.title = "B175 " + node.textContent;
|
||||
}
|
||||
window.measureB175 = measure;
|
||||
if (document.readyState === "complete") {
|
||||
measure();
|
||||
} else {
|
||||
window.addEventListener("load", measure);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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:
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Static + Node checks for the B-44 visual rework.
|
||||
|
||||
Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
|
||||
event table, and the exported keyboard/amount helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
|
||||
class B44FrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
|
||||
cls.company = (WEB / "company.html").read_text(encoding="utf-8")
|
||||
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
|
||||
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_admin_ids_are_unique(self) -> None:
|
||||
self.assertEqual(1, self.admin.count('id="companyLedgers"'))
|
||||
self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
|
||||
self.assertIn('data-view="pair"', self.admin)
|
||||
self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?<span>往来查询</span>')
|
||||
self.assertIn("<h1>往来查询</h1>", self.admin)
|
||||
self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
|
||||
self.assertIn('id="auditExceptionNote"', self.admin)
|
||||
|
||||
def test_company_balance_groups_and_nav(self) -> None:
|
||||
self.assertIn('id="companyBalanceGroups"', self.company)
|
||||
self.assertNotIn('id="companyBalanceLine"', self.company)
|
||||
self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?<span>往来余额</span>')
|
||||
self.assertIn("<h1>往来余额</h1>", self.company)
|
||||
|
||||
def test_css_directory_and_drawer_breakpoints(self) -> None:
|
||||
self.assertIn("@media (max-width: 375px)", self.css)
|
||||
self.assertIn("@media (max-width: 1179px)", self.css)
|
||||
self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css)
|
||||
self.assertIn(
|
||||
"minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px",
|
||||
self.css,
|
||||
)
|
||||
self.assertIn("@media (max-width: 767px)", self.css)
|
||||
self.assertIn(".drawer {", self.css)
|
||||
self.assertIn("width: 640px", self.css)
|
||||
self.assertIn(".drawer { width: 480px; }", self.css)
|
||||
self.assertIn(".drawer { width: 100%; }", self.css)
|
||||
self.assertIn(".drawer .pair-balance-line.is-six", self.css)
|
||||
self.assertIn("repeat(3, 1fr)", self.css)
|
||||
self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
|
||||
self.assertIn(".company-row.is-balance-counterparty", self.css)
|
||||
self.assertIn(".ledger-hide-md", self.css)
|
||||
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
|
||||
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
|
||||
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
|
||||
self.assertIn("grid-row: 1 / span 2", self.css)
|
||||
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css)
|
||||
|
||||
def test_js_selectors_and_event_table(self) -> None:
|
||||
self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
|
||||
self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
|
||||
self.assertIn("交易日期", self.js)
|
||||
self.assertIn("本方账户", self.js)
|
||||
self.assertIn("对方账户", self.js)
|
||||
self.assertIn("摘要", self.js)
|
||||
self.assertIn("匹配状态", self.js)
|
||||
self.assertIn("function eventTableHead()", self.js)
|
||||
self.assertIn("colspan=\"8\"", self.js)
|
||||
self.assertIn("function cycleTab(", self.js)
|
||||
self.assertIn("function drawerEscAction(", self.js)
|
||||
self.assertIn("function eventIsNegative(", self.js)
|
||||
self.assertIn("fmtAbsMoney", self.js)
|
||||
self.assertIn("is-balance-counterparty", self.js)
|
||||
self.assertIn("function isCompactAmount(", self.js)
|
||||
self.assertIn("function auditEvidenceFields(", self.js)
|
||||
self.assertIn("无关联流水", self.js)
|
||||
self.assertIn('row.dataset.direction = "outgoing"', self.js)
|
||||
self.assertIn("row.dataset.counterpartyName", self.js)
|
||||
self.assertIn("row.dataset.relatedSourceRowId", self.js)
|
||||
heads = re.search(
|
||||
r"function eventTableHead\(\) \{\s*return `([^`]+)`",
|
||||
self.js,
|
||||
)
|
||||
self.assertIsNotNone(heads)
|
||||
markup = heads.group(1)
|
||||
self.assertNotIn("来源", markup)
|
||||
for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
|
||||
self.assertIn(label, markup)
|
||||
self.assertEqual(8, len(re.findall(r"<th\b", markup)))
|
||||
|
||||
def test_node_keyboard_and_amount_helpers(self) -> None:
|
||||
result = subprocess.run(
|
||||
["node", str(ROOT / "tests" / "b44_ui_check.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertIn("b44_ui_check ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,530 +0,0 @@
|
||||
"""Chromium layout regression for the B-44 admin balance directory.
|
||||
|
||||
Measures 15-digit period-end amounts against the unresolved chip using
|
||||
getBoundingClientRect — not static text presence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
import unittest
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(Path(__file__).resolve().parent) not in sys.path:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
FIXTURE = "/tests/fixtures/b44-balance-directory.html"
|
||||
CHROME_CANDIDATES = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "Google/Chrome/Application/chrome.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "Microsoft/Edge/Application/msedge.exe",
|
||||
]
|
||||
VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440)
|
||||
|
||||
|
||||
def chrome_bin() -> Path:
|
||||
for path in CHROME_CANDIDATES:
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError("Chrome/Edge not found for layout regression")
|
||||
|
||||
|
||||
class QuietHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, directory=str(ROOT), **kwargs)
|
||||
|
||||
def log_message(self, *_args) -> None:
|
||||
pass
|
||||
|
||||
def handle(self) -> None:
|
||||
try:
|
||||
super().handle()
|
||||
except (ConnectionResetError, BrokenPipeError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class _Cdp:
|
||||
def __init__(self, ws_url: str) -> None:
|
||||
parsed = urlparse(ws_url)
|
||||
self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10)
|
||||
self._sock.settimeout(10)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
||||
self._sock.sendall(
|
||||
(
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {parsed.netloc}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
).encode()
|
||||
)
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
chunk = self._sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("CDP websocket handshake failed")
|
||||
header += chunk
|
||||
expected = base64.b64encode(
|
||||
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
|
||||
).decode()
|
||||
if expected not in header.decode("latin1"):
|
||||
raise ConnectionError("CDP websocket accept mismatch")
|
||||
leftover = header.split(b"\r\n\r\n", 1)[1]
|
||||
self._buf = leftover
|
||||
self._next_id = 1
|
||||
|
||||
def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
|
||||
msg_id = self._next_id
|
||||
self._next_id += 1
|
||||
self._send({"id": msg_id, "method": method, "params": params or {}})
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
payload = self._recv()
|
||||
if payload.get("id") == msg_id:
|
||||
if "error" in payload:
|
||||
raise RuntimeError(f"{method}: {payload['error']}")
|
||||
return payload.get("result") or {}
|
||||
raise TimeoutError(method)
|
||||
|
||||
def wait_event(self, name: str, timeout: float = 15) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
payload = self._recv()
|
||||
if payload.get("method") == name:
|
||||
return payload.get("params") or {}
|
||||
raise TimeoutError(name)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send(self, obj: dict) -> None:
|
||||
data = json.dumps(obj, separators=(",", ":")).encode()
|
||||
mask = os.urandom(4)
|
||||
header = bytearray([0x81])
|
||||
length = len(data)
|
||||
if length < 126:
|
||||
header.append(0x80 | length)
|
||||
elif length < 65536:
|
||||
header.append(0x80 | 126)
|
||||
header.extend(length.to_bytes(2, "big"))
|
||||
else:
|
||||
header.append(0x80 | 127)
|
||||
header.extend(length.to_bytes(8, "big"))
|
||||
header.extend(mask)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
self._sock.sendall(header + masked)
|
||||
|
||||
def _recv(self) -> dict:
|
||||
while True:
|
||||
opcode, payload = self._read_frame()
|
||||
if opcode == 0x9:
|
||||
self._send_pong(payload)
|
||||
continue
|
||||
if opcode == 0xA:
|
||||
continue
|
||||
if opcode == 0x8:
|
||||
raise ConnectionError("CDP websocket closed")
|
||||
return json.loads(payload.decode())
|
||||
|
||||
def _send_pong(self, payload: bytes) -> None:
|
||||
mask = os.urandom(4)
|
||||
header = bytearray([0x8A, 0x80 | len(payload)])
|
||||
header.extend(mask)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
self._sock.sendall(header + masked)
|
||||
|
||||
def _read_frame(self) -> tuple[int, bytes]:
|
||||
header = self._read_exact(2)
|
||||
opcode = header[0] & 0x0F
|
||||
length = header[1] & 0x7F
|
||||
masked = bool(header[1] & 0x80)
|
||||
if length == 126:
|
||||
length = int.from_bytes(self._read_exact(2), "big")
|
||||
elif length == 127:
|
||||
length = int.from_bytes(self._read_exact(8), "big")
|
||||
mask = self._read_exact(4) if masked else b""
|
||||
payload = self._read_exact(length)
|
||||
if masked:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
def _read_exact(self, size: int) -> bytes:
|
||||
while len(self._buf) < size:
|
||||
chunk = self._sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("CDP websocket closed")
|
||||
self._buf += chunk
|
||||
data, self._buf = self._buf[:size], self._buf[size:]
|
||||
return data
|
||||
|
||||
|
||||
class B44LayoutRegressionTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.chrome = chrome_bin()
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.http_thread.start()
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}"
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True)
|
||||
user_dir = cls.tmp.name
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(cls.chrome),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={user_dir}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(user_dir) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(
|
||||
f"http://127.0.0.1:{cls.debug_port}/json/list",
|
||||
timeout=5,
|
||||
).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "httpd", None):
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _metrics(self, width: int) -> dict:
|
||||
self.cdp.call("Emulation.setDeviceMetricsOverride", {
|
||||
"width": width,
|
||||
"height": 1100,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
})
|
||||
self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"})
|
||||
deadline = time.time() + 10
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": (
|
||||
"typeof window.measureB175 === 'function' "
|
||||
"? (window.measureB175(), document.getElementById('b175-metrics').textContent) "
|
||||
": null"
|
||||
),
|
||||
"returnByValue": True,
|
||||
},
|
||||
)
|
||||
last = result.get("result", {}).get("value")
|
||||
if last:
|
||||
return json.loads(last)
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"layout metrics not ready: {last}")
|
||||
|
||||
def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None:
|
||||
for width in VIEWPORTS:
|
||||
with self.subTest(width=width):
|
||||
metrics = self._metrics(width)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertIn("23,456,789,012,345.00", metrics["amountText"])
|
||||
self.assertIn("未决", metrics["unresolvedText"])
|
||||
self.assertIn("2026.07.31", metrics["cutoffText"])
|
||||
self.assertTrue(metrics["amountVisible"])
|
||||
self.assertTrue(metrics["unresolvedVisible"])
|
||||
self.assertTrue(metrics["cutoffVisible"])
|
||||
self.assertFalse(metrics["amountClipped"], metrics)
|
||||
self.assertFalse(metrics["unresolvedClipped"], metrics)
|
||||
self.assertFalse(metrics["cutoffClipped"], metrics)
|
||||
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics)
|
||||
self.assertFalse(metrics["drawerAmountClipped"], metrics)
|
||||
if 376 <= width <= 1179:
|
||||
self.assertTrue(metrics["fiveColVisible"], metrics)
|
||||
if 376 <= width <= 900:
|
||||
self.assertTrue(metrics["stackedResult"], metrics)
|
||||
if width <= 375:
|
||||
self.assertFalse(metrics["fiveColVisible"], metrics)
|
||||
|
||||
|
||||
LIVE_MEASURE = r"""
|
||||
(() => {
|
||||
const pair = document.querySelector('[data-view="pair"]');
|
||||
if (pair) pair.click();
|
||||
const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")];
|
||||
if (!rows.length) return null;
|
||||
const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0];
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const box = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
||||
};
|
||||
const overlap = (a, b) => {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx);
|
||||
};
|
||||
const clipped = (el) => el.scrollWidth > el.clientWidth + 1;
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
|
||||
directionUnresolvedOverlap: overlap(box(direction), unresolvedBox),
|
||||
amountCutoffOverlap: overlap(amountBox, box(cutoff)),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)),
|
||||
cutoffChevronOverlap: overlap(box(cutoff), box(chevron)),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none",
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
rowCount: rows.length,
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
COMPANY_MEASURE = r"""
|
||||
(() => {
|
||||
const balances = document.querySelector('[data-view="balances"]');
|
||||
if (balances) balances.click();
|
||||
const row = document.querySelector(".company-row.is-balance-counterparty");
|
||||
if (!row) return null;
|
||||
const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong");
|
||||
const direction = row.querySelector(".status");
|
||||
const cutoff = row.querySelector(".company-row-figure small");
|
||||
const box = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
||||
};
|
||||
const overlap = (a, b) => {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx);
|
||||
};
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0,
|
||||
visible: Boolean(amount && amount.getBoundingClientRect().width > 4),
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
class LiveAdminDirectoryLayoutTests(unittest.TestCase):
|
||||
"""Chromium against a real login/API page with B-168-scale 15-digit amounts."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from test_positions_api import IntercompanyApiTests
|
||||
|
||||
cls.api = IntercompanyApiTests("test_admin_balances_directory")
|
||||
cls.api.setUp()
|
||||
confirmed = cls.api._fresh_pair("123456789012345.00")
|
||||
cls.api._confirm(confirmed["ledger_event_id"])
|
||||
cls.api._fresh_pair("2150.00")
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True)
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(chrome_bin()),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={cls.tmp.name}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(
|
||||
f"http://127.0.0.1:{cls.debug_port}/json/list",
|
||||
timeout=5,
|
||||
).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
cls.cdp.call("Network.enable")
|
||||
token = cls.api.admin.cookies["cw_session"]
|
||||
cls.cdp.call(
|
||||
"Network.setCookie",
|
||||
{
|
||||
"name": "cw_session",
|
||||
"value": token,
|
||||
"url": f"http://127.0.0.1:{cls.api.port}/",
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "api", None):
|
||||
cls.api.tearDown()
|
||||
cls.api.doCleanups()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _eval(self, expression: str):
|
||||
deadline = time.time() + 12
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call(
|
||||
"Runtime.evaluate",
|
||||
{"expression": expression, "returnByValue": True},
|
||||
)
|
||||
last = result.get("result", {}).get("value")
|
||||
if last:
|
||||
return last
|
||||
time.sleep(0.15)
|
||||
raise TimeoutError(f"live page metrics not ready: {last}")
|
||||
|
||||
def _open(self, path: str, width: int) -> None:
|
||||
self.cdp.call("Emulation.setDeviceMetricsOverride", {
|
||||
"width": width,
|
||||
"height": 1100,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
})
|
||||
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"})
|
||||
time.sleep(0.4)
|
||||
|
||||
def test_live_admin_directory_viewports(self) -> None:
|
||||
for width in (720, 768, 800, 900, 375, 1024, 1440):
|
||||
with self.subTest(width=width):
|
||||
self._open("admin.html", width)
|
||||
metrics = self._eval(LIVE_MEASURE)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertGreaterEqual(metrics["rowCount"], 1, metrics)
|
||||
self.assertIn("123,456,789,012,345", metrics["amountText"], metrics)
|
||||
self.assertIn("未决", metrics["unresolvedText"], metrics)
|
||||
self.assertIn("2026.07.31", metrics["cutoffText"], metrics)
|
||||
self.assertFalse(metrics["amountClipped"], metrics)
|
||||
self.assertFalse(metrics["unresolvedClipped"], metrics)
|
||||
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
|
||||
if 376 <= width <= 900:
|
||||
self.assertTrue(metrics["stackedResult"], metrics)
|
||||
if width <= 375:
|
||||
self.assertFalse(metrics["fiveColVisible"], metrics)
|
||||
|
||||
def test_live_company_balance_rows(self) -> None:
|
||||
token = self.api.cashier_a.cookies["cw_session"]
|
||||
self.cdp.call(
|
||||
"Network.setCookie",
|
||||
{
|
||||
"name": "cw_session",
|
||||
"value": token,
|
||||
"url": f"http://127.0.0.1:{self.api.port}/",
|
||||
},
|
||||
)
|
||||
for width in (375, 1440):
|
||||
with self.subTest(width=width):
|
||||
self._open("company.html", width)
|
||||
metrics = self._eval(COMPANY_MEASURE)
|
||||
self.assertTrue(metrics["visible"], metrics)
|
||||
self.assertTrue(metrics["amountText"], metrics)
|
||||
self.assertTrue(metrics["directionText"], metrics)
|
||||
self.assertIn("截止", metrics["cutoffText"], metrics)
|
||||
self.assertEqual(0, metrics["amountDirectionOverlap"], metrics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
@@ -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, 6], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -82,19 +82,26 @@ class MigrationTests(PersistenceTestCase):
|
||||
"ledger_event_bank_sources",
|
||||
"ledger_event_manual_sources",
|
||||
"ledger_subject_suggestions",
|
||||
"system_settings",
|
||||
"system_setting_changes",
|
||||
"closed_periods",
|
||||
"opening_balance_revisions",
|
||||
"coverage_gaps",
|
||||
"no_business_attestations",
|
||||
"reminders_legacy_manual",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([9, 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, 6], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -102,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([6, 5], rollback(self.connection, 4))
|
||||
self.assertEqual([9, 8, 7, 6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Tests for automatic reminder detection, delivery, isolation and audit trail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bank_importer import auth, reminders
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
class ReminderTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.company_a = int(
|
||||
self.connection.execute("SELECT id FROM companies WHERE name = '甲公司'").fetchone()["id"]
|
||||
)
|
||||
self.company_b = int(
|
||||
self.connection.execute("SELECT id FROM companies WHERE name = '乙公司'").fetchone()["id"]
|
||||
)
|
||||
self.admin_id = auth.create_user(self.connection, "admin1", "AdminPass123", "admin")
|
||||
self.user_a = auth.create_user(
|
||||
self.connection, "cashier-a", "CashierA123", "company", company_id=self.company_a
|
||||
)
|
||||
self.user_b = auth.create_user(
|
||||
self.connection, "cashier-b", "CashierB123", "company", company_id=self.company_b
|
||||
)
|
||||
self.admin = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (self.admin_id,)
|
||||
).fetchone()
|
||||
self.actor_a = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (self.user_a,)
|
||||
).fetchone()
|
||||
|
||||
def _insert_batch(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
period_start: str,
|
||||
period_end: str,
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'f.xls', 1, '/tmp/f.xls', ?)
|
||||
""",
|
||||
(f"sha-{company_id}-{period_end}", now),
|
||||
)
|
||||
file_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
|
||||
VALUES (?, 'parsed', ?, ?, ?)
|
||||
""",
|
||||
(file_id, company_id, now, now),
|
||||
)
|
||||
batch_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||||
header_row, period_start, period_end, transaction_count, created_at
|
||||
) VALUES (?, 's1', '工行', 'tpl', 1, 1, ?, ?, 1, ?)
|
||||
""",
|
||||
(batch_id, period_start, period_end, now),
|
||||
)
|
||||
|
||||
def _insert_pending_account(self, company_id: int) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO bank_accounts (
|
||||
company_id, account_number, bank_name, status, created_at, updated_at
|
||||
) VALUES (?, ?, '工行', 'pending', ?, ?)
|
||||
""",
|
||||
(company_id, f"622{company_id:012d}", now, now),
|
||||
)
|
||||
|
||||
|
||||
class RuleDetectionTests(ReminderTestCase):
|
||||
def test_unsubmitted_detected_when_no_current_month_batch(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
keys = {item.company_id: item for item in findings if item.rule_key == reminders.RULE_UNSUBMITTED}
|
||||
self.assertIn(self.company_a, keys)
|
||||
self.assertIn(self.company_b, keys)
|
||||
|
||||
def test_unsubmitted_not_reported_when_batch_exists(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
period = f"{today.year:04d}-{today.month:02d}"
|
||||
self._insert_batch(self.company_a, period_start=f"{period}-01", period_end=f"{period}-15")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
for item in findings:
|
||||
if item.rule_key == reminders.RULE_UNSUBMITTED:
|
||||
self.assertNotEqual(self.company_a, item.company_id)
|
||||
|
||||
def test_gap_detected_after_threshold(self) -> None:
|
||||
old_end = (date.today() - timedelta(days=10)).isoformat()
|
||||
self._insert_batch(self.company_a, period_start="2026-01-01", period_end=old_end)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
|
||||
self.assertEqual(1, len(gap))
|
||||
|
||||
def test_gap_not_reported_for_recent_batch(self) -> None:
|
||||
recent = (date.today() - timedelta(days=1)).isoformat()
|
||||
self._insert_batch(self.company_a, period_start="2026-07-01", period_end=recent)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
|
||||
self.assertEqual(0, len(gap))
|
||||
|
||||
def test_pending_review_detected_with_pending_account(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
pending = [
|
||||
item for item in findings
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
]
|
||||
self.assertEqual(1, len(pending))
|
||||
self.assertGreater(pending[0].rule_params["pending_count"], 0)
|
||||
|
||||
def test_pending_review_not_reported_when_clean(self) -> None:
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
pending = [
|
||||
item for item in findings
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
]
|
||||
self.assertEqual(0, len(pending))
|
||||
|
||||
|
||||
class DeliveryAndDedupTests(ReminderTestCase):
|
||||
def test_send_creates_reminder_and_event(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
self.assertTrue(findings)
|
||||
key = findings[0].dedupe_key
|
||||
reminder_id = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
self.assertIsNotNone(reminder_id)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(1, row["send_count"])
|
||||
self.assertEqual("open", row["status"])
|
||||
events = self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
|
||||
).fetchall()
|
||||
self.assertEqual(["sent"], [item["event_type"] for item in events])
|
||||
|
||||
def test_repeat_send_increments_count_without_new_row(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
key = findings[0].dedupe_key
|
||||
first = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
second = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
self.assertEqual(first, second)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count FROM reminders WHERE dedupe_key = ?", (key,)
|
||||
).fetchone()
|
||||
self.assertEqual(2, row["send_count"])
|
||||
count = self.connection.execute("SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (key,)).fetchone()[0]
|
||||
self.assertEqual(1, count)
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) FROM reminder_events WHERE reminder_id = ?", (first,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(2, events)
|
||||
|
||||
def test_resolved_reminder_reopens_same_row(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
finding = next(
|
||||
item
|
||||
for item in reminders.scan_findings(self.connection)
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
)
|
||||
first = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
|
||||
self.assertIsNotNone(first)
|
||||
reminders.update_reminder_status(
|
||||
self.connection, first, "resolved", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
second = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
|
||||
self.assertEqual(first, second)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count, status, dedupe_key FROM reminders WHERE id = ?",
|
||||
(first,),
|
||||
).fetchone()
|
||||
self.assertEqual(2, row["send_count"])
|
||||
self.assertEqual("open", row["status"])
|
||||
count = self.connection.execute(
|
||||
"SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (finding.dedupe_key,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(1, count)
|
||||
events = [
|
||||
item["event_type"]
|
||||
for item in self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
|
||||
(first,),
|
||||
).fetchall()
|
||||
]
|
||||
self.assertEqual(["sent", "resolved", "sent"], events)
|
||||
|
||||
def test_deliver_many_continues_after_one_failure(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
finding = next(
|
||||
item
|
||||
for item in reminders.scan_findings(self.connection)
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
)
|
||||
original = reminders.deliver_finding
|
||||
|
||||
def flaky(connection, key, **kwargs):
|
||||
if key == "boom":
|
||||
raise sqlite3.IntegrityError("UNIQUE constraint failed: reminders.dedupe_key")
|
||||
return original(connection, key, **kwargs)
|
||||
|
||||
with patch.object(reminders, "deliver_finding", side_effect=flaky):
|
||||
sent = reminders.deliver_many(
|
||||
self.connection, ["boom", finding.dedupe_key], actor=self.admin
|
||||
)
|
||||
self.assertEqual(1, len(sent))
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.connection.execute("SELECT COUNT(*) FROM reminders").fetchone()[0],
|
||||
)
|
||||
|
||||
def test_manual_reminder_each_send_is_separate(self) -> None:
|
||||
first = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="其他",
|
||||
content="请尽快处理",
|
||||
deadline="2026-09-01",
|
||||
actor=self.admin,
|
||||
)
|
||||
second = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="其他",
|
||||
content="再次提醒",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
|
||||
class IsolationTests(ReminderTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="流水未提交",
|
||||
content="甲公司专属",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_b,
|
||||
display_type="流水未提交",
|
||||
content="乙公司专属",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
|
||||
def test_company_sees_only_own_reminders(self) -> None:
|
||||
items_a = reminders.list_company_reminders(self.connection, self.company_a)
|
||||
items_b = reminders.list_company_reminders(self.connection, self.company_b)
|
||||
self.assertEqual(1, len(items_a))
|
||||
self.assertEqual(1, len(items_b))
|
||||
self.assertIn("甲公司", items_a[0]["title"])
|
||||
self.assertIn("乙公司", items_b[0]["title"])
|
||||
|
||||
def test_company_cannot_update_other_company_reminder(self) -> None:
|
||||
other_id = reminders.list_company_reminders(self.connection, self.company_b)[0]["id"]
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, other_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class AuditTrailTests(ReminderTestCase):
|
||||
def test_scan_writes_audit_log(self) -> None:
|
||||
reminders.run_scan(self.connection, actor=self.admin, ip="127.0.0.1")
|
||||
row = self.connection.execute(
|
||||
"SELECT action, detail FROM audit_log WHERE action = 'reminder_scan'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
detail = json.loads(row["detail"])
|
||||
self.assertIn("total", detail)
|
||||
|
||||
def test_events_are_append_only(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="测试",
|
||||
content="内容",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_events SET detail = 'tampered' WHERE reminder_id = ?",
|
||||
(reminder_id,),
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"DELETE FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
|
||||
)
|
||||
|
||||
def test_reminders_cannot_be_deleted(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="测试",
|
||||
content="内容",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
||||
|
||||
|
||||
class StatusFlowTests(ReminderTestCase):
|
||||
def test_acknowledge_and_resolve(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="待确认",
|
||||
content="请处理",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, reminder_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
row = self.connection.execute(
|
||||
"SELECT status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
self.assertEqual("acknowledged", row["status"])
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, reminder_id, "resolved", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
events = self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
|
||||
(reminder_id,),
|
||||
).fetchall()
|
||||
self.assertEqual(
|
||||
["sent", "acknowledged", "resolved"],
|
||||
[item["event_type"] for item in events],
|
||||
)
|
||||
|
||||
|
||||
class SettingsTests(ReminderTestCase):
|
||||
def test_default_settings_and_update(self) -> None:
|
||||
settings = reminders.get_settings(self.connection)
|
||||
self.assertEqual("5", settings["monthly_start_day"])
|
||||
self.assertEqual("5", settings["gap_days"])
|
||||
updated = reminders.update_settings(
|
||||
self.connection, {"gap_days": "7", "monthly_start_day": "6"}
|
||||
)
|
||||
self.assertEqual("7", updated["gap_days"])
|
||||
self.assertEqual("6", updated["monthly_start_day"])
|
||||
|
||||
def test_unknown_setting_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"unknown_key": "1"})
|
||||
|
||||
def test_invalid_monthly_start_day_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"monthly_start_day": "0"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"monthly_start_day": "29"})
|
||||
self.assertEqual("5", reminders.get_settings(self.connection)["monthly_start_day"])
|
||||
|
||||
def test_invalid_gap_days_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"gap_days": "0"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"gap_days": "abc"})
|
||||
self.assertEqual("5", reminders.get_settings(self.connection)["gap_days"])
|
||||
|
||||
def test_invalid_scan_time_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"scan_time": "25:00"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"scan_time": "8:0"})
|
||||
self.assertEqual("08:00", reminders.get_settings(self.connection)["scan_time"])
|
||||
|
||||
def test_corrupt_settings_do_not_break_scan(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_settings SET value = 'not-a-number' WHERE key = 'monthly_start_day'"
|
||||
)
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_settings SET value = '0' WHERE key = 'gap_days'"
|
||||
)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
self.assertIsInstance(findings, list)
|
||||
|
||||
|
||||
class NoticeListDelegationTests(unittest.TestCase):
|
||||
def test_async_notice_go_handle_uses_event_delegation(self) -> None:
|
||||
source = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
init_body = source.split("function initNotifications()", 1)[1].split("\nfunction ", 1)[0]
|
||||
self.assertIn('list.addEventListener("click"', init_body)
|
||||
self.assertIn('closest("[data-view-link]")', init_body)
|
||||
self.assertIn("showView(viewLink.dataset.viewLink)", init_body)
|
||||
self.assertIn('document.addEventListener("click"', source)
|
||||
self.assertNotIn(
|
||||
'$$("[data-view-link]").forEach((button) => button.addEventListener("click"',
|
||||
source,
|
||||
)
|
||||
|
||||
def test_delegated_lookup_finds_button_inserted_after_init(self) -> None:
|
||||
"""Simulate #notice-list after async replaceChildren: click target is the new button."""
|
||||
list_root = {"id": "notice-list", "parent": None, "attrs": {}}
|
||||
side = {"id": "lr-side", "parent": list_root, "attrs": {}}
|
||||
button = {
|
||||
"id": "go",
|
||||
"parent": side,
|
||||
"attrs": {"data-view-link": "reconcile"},
|
||||
}
|
||||
list_root["children"] = [side]
|
||||
side["children"] = [button]
|
||||
|
||||
def closest(node, attr):
|
||||
current = node
|
||||
while current is not None:
|
||||
if attr in current.get("attrs", {}):
|
||||
return current
|
||||
current = current.get("parent")
|
||||
return None
|
||||
|
||||
clicked = closest(button, "data-view-link")
|
||||
self.assertIsNotNone(clicked)
|
||||
self.assertEqual("reconcile", clicked["attrs"]["data-view-link"])
|
||||
self.assertIs(list_root, clicked["parent"]["parent"])
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_v6_migration_applies_and_rolls_back(self) -> None:
|
||||
connection = connect(":memory:")
|
||||
self.addCleanup(connection.close)
|
||||
migrate(connection)
|
||||
versions = connection.execute(
|
||||
"SELECT version FROM schema_migrations ORDER BY version"
|
||||
).fetchall()
|
||||
self.assertEqual(9, versions[-1]["version"])
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 9")
|
||||
connection.executescript(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_update;
|
||||
DROP TABLE IF EXISTS reminder_events;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS reminder_settings;
|
||||
"""
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'reminders'"
|
||||
).fetchone()
|
||||
self.assertIsNone(row)
|
||||
@@ -287,6 +287,8 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
lambda: anon.get("/api/admin/users"),
|
||||
lambda: anon.get("/api/admin/companies"),
|
||||
lambda: anon.get("/api/admin/audit-log"),
|
||||
lambda: anon.get("/api/admin/reminders"),
|
||||
lambda: anon.get("/api/company/reminders"),
|
||||
lambda: anon.get("/api/me"),
|
||||
):
|
||||
status, _, data = method_check()
|
||||
@@ -387,6 +389,10 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
|
||||
lambda: self.cashier_a.get("/api/admin/audit-log"),
|
||||
lambda: self.cashier_a.get("/api/admin/reminders"),
|
||||
lambda: self.cashier_a.get("/api/admin/reminders/pending"),
|
||||
lambda: self.cashier_a.post_json("/api/admin/reminder-settings", {"gap_days": "3"}),
|
||||
lambda: self.cashier_a.post_json("/api/admin/reminders/send", {"dedupe_keys": ["x"]}),
|
||||
)
|
||||
for call in calls:
|
||||
status, _, data = call()
|
||||
@@ -557,6 +563,28 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
self.assertNotIn(password, row["detail"] or "")
|
||||
self.assertNotIn(password, row["target"] or "")
|
||||
|
||||
def test_invalid_reminder_settings_return_400_and_do_not_persist(self) -> None:
|
||||
cases = (
|
||||
{"monthly_start_day": "0"},
|
||||
{"gap_days": "0"},
|
||||
{"scan_time": "25:99"},
|
||||
)
|
||||
for payload in cases:
|
||||
with self.subTest(payload=payload):
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/reminder-settings", {"settings": payload}
|
||||
)
|
||||
self.assertEqual(400, status, data)
|
||||
self.assertEqual("error", as_json(data)["status"])
|
||||
status, _, data = self.admin.get("/api/admin/reminder-settings")
|
||||
self.assertEqual(200, status)
|
||||
settings = as_json(data)["settings"]
|
||||
self.assertEqual("5", settings["monthly_start_day"])
|
||||
self.assertEqual("5", settings["gap_days"])
|
||||
self.assertEqual("08:00", settings["scan_time"])
|
||||
status, _, data = self.admin.get("/api/admin/reminders/pending")
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""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_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()
|
||||
+967
-198
File diff suppressed because it is too large
Load Diff
+3340
-1674
File diff suppressed because it is too large
Load Diff
+959
-177
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;
|
||||
});
|
||||
-936
@@ -1,936 +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);
|
||||
--row-h-evidence: 55px;
|
||||
--z-drawer: 150;
|
||||
--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 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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; min-width: 0; }
|
||||
.company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; }
|
||||
.company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.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:not(.is-balances) summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
|
||||
.company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
|
||||
.ledger-breakdown { grid-template-columns: 1fr; }
|
||||
.ledger-breakdown 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:not(.is-balances) summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
|
||||
.company-ledger:not(.is-balances) summary > strong:nth-of-type(2), .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
|
||||
.company-name { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.company-name 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:not(.is-balance-counterparty) { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row:not(.is-balance-counterparty) .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; }
|
||||
}
|
||||
|
||||
/* ---- B-44 intercompany balances ---- */
|
||||
.status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); }
|
||||
.amount-neutral { color: var(--color-ink-muted); }
|
||||
.currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; }
|
||||
.amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.amount-with-currency.is-compact { font-size: 11px; }
|
||||
.account-cell { white-space: nowrap; }
|
||||
.summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; }
|
||||
.candidate-list li { padding: 4px 0; border-bottom: 1px dashed var(--color-line); }
|
||||
.candidate-list li:last-child { border-bottom: 0; }
|
||||
.reason-full { max-height: calc(1.6em * 6); overflow: auto; white-space: pre-wrap; }
|
||||
.decision-danger { margin-top: 8px; color: var(--color-danger); font-size: 12px; }
|
||||
.balance-currency-groups { display: grid; gap: 12px; }
|
||||
.balance-currency-groups .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; }
|
||||
|
||||
/* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(180px, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
|
||||
}
|
||||
.company-ledger.is-balances summary { min-height: 72px; }
|
||||
.company-ledger.is-balances summary .company-name { min-width: 96px; }
|
||||
.company-ledger.is-balances summary .ledger-cutoff { color: var(--color-ink-muted); font-size: 11px; white-space: nowrap; }
|
||||
.company-ledger.is-balances .ledger-result { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 0; }
|
||||
.company-ledger.is-balances .ledger-unresolved { font-size: 11px; white-space: nowrap; }
|
||||
.company-ledger.is-balances .ledger-unresolved.is-empty { color: var(--color-ink-muted); }
|
||||
.company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); }
|
||||
.company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.company-ledger.is-balances .subject-row small { white-space: nowrap; }
|
||||
@media (max-width: 1179px) {
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(120px, 1.2fr) minmax(136px, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
|
||||
}
|
||||
.ledger-head.is-balances .ledger-hide-md,
|
||||
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
|
||||
.company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; }
|
||||
}
|
||||
@media (min-width: 376px) and (max-width: 900px) {
|
||||
.ledger-head.is-balances { display: grid; }
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px;
|
||||
}
|
||||
.company-ledger.is-balances summary .ledger-result {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.company-ledger.is-balances .ledger-result b { font-size: 11px; }
|
||||
.company-ledger.is-balances .ledger-unresolved {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
.company-ledger.is-balances .ledger-unresolved .status {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
.ledger-head.is-balances { display: none; }
|
||||
.company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(88px, 1fr) minmax(0, max-content) 24px;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; min-width: 88px; }
|
||||
.company-ledger.is-balances summary .ledger-result {
|
||||
grid-column: 2; grid-row: 1; justify-self: end;
|
||||
flex-direction: column; align-items: flex-end; gap: 2px;
|
||||
}
|
||||
.company-ledger.is-balances summary .ledger-result .currency-code { display: none; }
|
||||
.company-ledger.is-balances summary .ledger-unresolved {
|
||||
grid-column: 1; grid-row: 2; min-width: 0; white-space: normal;
|
||||
}
|
||||
.company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; }
|
||||
.company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; }
|
||||
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
|
||||
}
|
||||
|
||||
/* Pair report six-cell: page may be six-across; drawer is always two rows of three */
|
||||
.pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); }
|
||||
.pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
|
||||
.pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
|
||||
.pair-balance-line .pair-unresolved b { color: var(--color-warning); }
|
||||
.pair-balance-line .pair-unresolved.is-empty b { color: var(--color-ink); }
|
||||
.pair-balance-line .pair-cutoff { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
|
||||
@media (max-width: 1180px) {
|
||||
.pair-balance-line.is-six { grid-template-columns: repeat(3, 1fr); }
|
||||
.pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
|
||||
}
|
||||
.amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.amount-with-currency.is-compact { font-size: 11px; }
|
||||
.amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; }
|
||||
|
||||
/* Evidence drawer: <768 full, 768–1179 480px, >=1180 640px */
|
||||
.drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); }
|
||||
.drawer-scrim.is-open { opacity: 1; pointer-events: auto; }
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); }
|
||||
.drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; }
|
||||
@media (max-width: 1179px) { .drawer { width: 480px; } }
|
||||
@media (max-width: 767px) { .drawer { width: 100%; } }
|
||||
.drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
|
||||
.drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; }
|
||||
.drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; }
|
||||
.drawer-header h2 { margin-top: 6px; font-size: 17px; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; }
|
||||
.drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; }
|
||||
.drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; }
|
||||
.drawer .pair-balance-line strong { overflow-wrap: anywhere; }
|
||||
.drawer .pair-balance-line .amount-with-currency.is-compact { white-space: normal; overflow-wrap: anywhere; }
|
||||
.drawer .pair-balance-line .pair-cutoff,
|
||||
.drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; }
|
||||
.drawer .pair-balance-line.is-six {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.drawer .pair-balance-line.is-six .pair-final { grid-column: auto; }
|
||||
.drawer .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.drawer .pair-balance-line.is-six div:nth-child(5),
|
||||
.drawer .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
|
||||
.drawer .event-table { min-width: 860px; }
|
||||
.drawer .event-table td { height: var(--row-h-evidence); }
|
||||
.drawer .event-row { cursor: pointer; }
|
||||
.drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; }
|
||||
.drawer .evidence-stack { display: flex; flex-direction: column; gap: 10px; }
|
||||
.drawer .evidence-midline { display: flex; align-items: center; gap: 10px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.drawer .evidence-midline::before, .drawer .evidence-midline::after { content: ""; flex: 1; height: 1px; background: var(--color-line); }
|
||||
.evidence-block.is-missing { border-color: rgba(102, 168, 255, 0.28); background: var(--color-info-wash); }
|
||||
.evidence-block .evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 14px; margin-top: 10px; }
|
||||
.evidence-block .evidence-grid div { display: flex; flex-direction: column; min-width: 0; }
|
||||
.evidence-block .evidence-grid dt { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.evidence-block .evidence-grid dd { color: var(--color-ink-soft); font-size: 12px; word-break: break-all; }
|
||||
.evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; }
|
||||
@media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.company-row.is-balance-counterparty { grid-template-columns: 34px minmax(0, 1fr) minmax(0, auto); }
|
||||
.company-row.is-balance-counterparty .company-row-figure {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0;
|
||||
}
|
||||
.company-row.is-balance-counterparty .company-row-figure strong { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.company-row.is-balance-counterparty {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
.company-row.is-balance-counterparty .company-row-figure {
|
||||
grid-column: 2;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading skeletons */
|
||||
.skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); }
|
||||
.skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); }
|
||||
.skeleton-row span:nth-child(1) { width: 22%; } .skeleton-row span:nth-child(2) { width: 14%; }
|
||||
.skeleton-row span:nth-child(3) { width: 14%; } .skeleton-row span:nth-child(4) { width: 18%; }
|
||||
.skeleton-row span:nth-child(5) { width: 12%; }
|
||||
.skeleton-block { animation: skeleton-breathe 1200ms ease-in-out infinite; }
|
||||
|
||||
/* Empty / error / no-permission states */
|
||||
.state-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; justify-content: center; min-height: 240px; padding: 28px 18px; text-align: center; color: var(--color-ink-muted); }
|
||||
.state-panel .state-icon { width: 40px; height: 40px; color: var(--color-ink-muted); opacity: 0.7; }
|
||||
.state-panel strong { color: var(--color-ink-soft); font-size: 14px; }
|
||||
.state-panel p { max-width: 420px; font-size: 12px; }
|
||||
.state-panel .button { margin-top: 6px; }
|
||||
.state-panel.is-error { border: 1px solid rgba(255, 98, 109, 0.3); border-radius: var(--radius-lg); background: var(--color-danger-wash); }
|
||||
.state-panel.is-error strong { color: var(--color-danger); }
|
||||
.state-panel.is-denied { border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); }
|
||||
|
||||
/* Company portal alert variant + direction chips */
|
||||
.balance-alert { border-color: rgba(255, 188, 82, 0.24); background: var(--color-warning-wash); }
|
||||
.balance-alert > svg { color: var(--color-warning); }
|
||||
.balance-alert .button { color: var(--color-warning); }
|
||||
.company-row .result-direction { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.subject-strip button { white-space: nowrap; }
|
||||
|
||||
/* Direction chips: text-first, arrows as affordance, never color-only */
|
||||
.direction-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 23px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); font-size: 10px; white-space: nowrap; }
|
||||
|
||||
@keyframes skeleton-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .skeleton-block { animation: none; } }
|
||||
Reference in New Issue
Block a user