Compare commits

...
Author SHA1 Message Date
总工andmultica-agent d6f39e018d HEL-208: 整合 b49892e 的 formatWan 拆分修复到 2fb1f2b(丢弃其对 HEL-206 方向修复与测试的误回退)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 11:49:23 +00:00
2fb1f2b462 HEL-206: 修正公司间期末余额方向为转出=应收增加
翻转 _pair_net_change 符号,与往来层/页面口径一致;同步修正旧断言并新增期初200+转出100=期末300的双边守恒与浏览器方向冒烟。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:49:56 +00:00
fe6b4e59ec HEL-203: 修复原因弹窗作用域、首屏断档与表单 reset
将 openModal/closeModal 提升为顶层唯一定义,公司端启动即加载断档提醒,
并在 await 前提取表单引用;补充 Playwright 两端冒烟与源码契约测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:29:07 +00:00
27f2b0b69a HEL-202: 基于 b27016d 返工起算日/期初/断档并补齐公司端
以 deploy/hel178 为基线整合 HEL-200 内核:迁移改为 0008 复用
system_settings;修复确认批次 list>int、截止日当天漏算与持久化断言;
公司端余额完整/降级口径与断档说明、管理端审核界面接线。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 16:38:36 +00:00
b27016d71d HEL-177: 公司端按方案 A 实现转账往来四层页面
工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:09:01 +00:00
32 changed files with 9250 additions and 650 deletions
+5
View File
@@ -1,6 +1,7 @@
__pycache__/
*.py[cod]
.venv/
.chrome-libs/
.tmp-*/
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
@@ -22,3 +23,7 @@ nul
vendor_pkgs/
node_modules/
package-lock.json
# local vendor for agent test env (not shipped)
.vendor/
vendor_wheels/
+2
View File
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
(默认未设置 = 保持限流,正式环境不得开启该变量)。
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
+21
View File
@@ -0,0 +1,21 @@
# 总账项目手册
最后核对:2026-08-23。核对依据是当前 `main` 最新代码(`5f21527`)、数据库迁移、接口、测试和近期提交记录。
这是接手本项目的第一站。阅读顺序如下:
1. [项目需求](项目需求.md):先知道系统要解决什么问题,以及哪些规则绝不能碰。
2. [最新进度](最新进度.md):再确认哪些能力已经真实完成,哪些仍未完成。
3. [任务清单](任务清单.md):最后挑选下一项工作,避免重复做已经完成的事。
4. 需要技术细节时,再查看 `decisions/` 中的技术决定和 `issues/` 中保留的历史任务规格。
## 维护规矩
- 每新增、开始、完成、取消一项任务,负责的智能体都要在同一次提交里更新 `最新进度.md``任务清单.md`
- 新功能改变产品规则时,同时更新 `项目需求.md`;技术方案有变化时,补充 `decisions/` 的决定记录。
- 写进手册的“已完成”必须能在代码、测试或已合并提交中找到依据;只有计划、页面演示或口头讨论的内容,一律写为“未完成”。
- 不删除历史文档。旧内容与当前代码不一致时,在旧文件开头加“已过时,仅留档备查”提示,并在本目录写新版说明。
## 旧交接说明
`HANDOFF.md` 是 2026-08-06 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
+3
View File
@@ -26,6 +26,9 @@
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
且在窗口内不再记录新尝试,行为确定、可测试。
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
「5 次失败锁 10 分钟」的生产安全策略不变。
## 角色与公司绑定
+29
View File
@@ -0,0 +1,29 @@
# 任务清单
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
## 正在做
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
## 已做完
- 六类银行流水模板识别与解析。
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
- 动态公司、用户、银行账户、别名与账户审核流程。
- 导入接口加固、逐工作表确认、失败诊断和导出。
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
- 登录页视觉融合改版。
## 还没安排
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
- 月结、重开、调整/冲销审批与审计报告。
- 服务端流水查询、筛选、分页和可追溯导出。
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`
+24
View File
@@ -0,0 +1,24 @@
# 最新进度
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
## 已经真实完成
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
## 仍未完成或不能当成已完成
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
## 最近验证
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
+26
View File
@@ -0,0 +1,26 @@
# 项目需求
## 这个系统做什么
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
系统有两个独立入口:
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
## 必须做到的业务规则
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
## 当前技术边界
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls``.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""HEL-174: 360 / 820 / 1440 截图——待确认黄 vs 已完成绿。"""
from __future__ import annotations
import re
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = Path(__file__).resolve().parent.parent / "hel174-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [
(1440, 900, "1440"),
(820, 900, "820"),
(360, 800, "360"),
]
def extract_fn(source: str, name: str) -> str:
marker = f"function {name}("
start = source.index(marker)
depth = 0
for i, ch in enumerate(source[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise RuntimeError(name)
def main() -> None:
app_js = (WEB / "app.js").read_text(encoding="utf-8")
inject = "\n".join(
[
"const state = {};",
"function $(sel, root) { return (root || document).querySelector(sel); }",
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
extract_fn(app_js, "formatWorkspaceAmount"),
extract_fn(app_js, "applyCompanyWorkspace"),
]
)
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
html = f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="{base}/design-system.css"/>
<title>HEL-174 往来确认状态色</title>
<style>
body {{ margin: 0; background: var(--bg); font-family: var(--font-body); color: var(--fg); }}
.shot-wrap {{ padding: 16px; max-width: 1100px; margin: 0 auto; }}
.shot-label {{ font-size: 13px; color: var(--muted); margin: 0 0 10px; }}
</style>
</head>
<body data-portal="company">
<div class="shot-wrap">
<p class="shot-label" id="shotLabel">状态预览</p>
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
<div class="card" id="workspaceTodos" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div>
<div id="workspaceTodoList"></div>
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
</div>
<div class="card" style="margin-top:12px;">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">…</span></span>
</div>
<div class="flow">
<a class="flow-step part" href="#upload">
<div class="fs-top"><span class="fs-idx">01</span><span class="fs-dot"></span><span class="fs-name">流水导入</span></div>
<div class="fs-state">部分完成</div>
</a>
<a class="flow-step done" href="#manual">
<div class="fs-top"><span class="fs-idx">02</span><span class="fs-dot"></span><span class="fs-name">手工补录</span></div>
<div class="fs-state">已完成</div>
</a>
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
</a>
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
<div class="fs-state">等待集团</div>
</div>
</div>
</div>
<div class="notice warn" id="blocking-notice" style="margin-top:12px;">
<div>
<div class="n-title" id="notice-title">…</div>
<div class="n-body" id="notice-body">…</div>
</div>
</div>
<div style="margin-top:12px;">
<span class="pill pill-warn">待确认</span>
<span class="pill pill-success" style="margin-left:8px;">已确认</span>
</div>
<span class="tab-count" id="count-match" hidden>0</span>
<nav class="side-nav" hidden><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
</div>
</body></html>"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page()
for width, height, tag in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.set_content(html, wait_until="domcontentloaded")
page.add_script_tag(content=inject)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '待确认(应黄)';
applyCompanyWorkspace({
pending_unilateral: 3, pending_total: 3,
unilateral_events: [
{event_id:1, amount:'100000.00', currency:'CNY',
counterparty_company_name:'金牛贸易', effective_at:'2026-07-03'},
{event_id:2, amount:'200000.00', currency:'CNY',
counterparty_company_name:'金牛物流', effective_at:'2026-07-11'},
{event_id:3, amount:'300000.00', currency:'CNY',
counterparty_company_name:'金牛置业', effective_at:'2026-07-24'}
]
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-pending-{tag}.png"), full_page=True)
page.evaluate(
"""() => {
document.getElementById('shotLabel').textContent = '已完成(应绿)';
applyCompanyWorkspace({
pending_unilateral: 0, pending_total: 0, unilateral_events: []
});
}"""
)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"hel174-done-{tag}.png"), full_page=True)
colors = page.evaluate(
"""() => {
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
const state = document.getElementById('workspaceConfirmState');
const pill = document.getElementById('workspacePendingStatus');
return {
stepClass: step.className,
stateText: state.textContent,
stateColor: getComputedStyle(state).color,
pillClass: pill.className,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
}"""
)
assert "done" in colors["stepClass"], colors
assert "pill-success" in colors["pillClass"], colors
assert colors["stateText"] == "已完成", colors
assert not colors["overflowX"], colors
print(tag, colors)
browser.close()
httpd.shutdown()
print("shots:", sorted(p.name for p in OUT.glob("*.png")))
if __name__ == "__main__":
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""HEL-177: 转账往来页 360/820/1440 冒烟截图(mock 接口,不依赖登录库)。"""
from __future__ import annotations
import json
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = ROOT / "hel177-shots"
OUT.mkdir(parents=True, exist_ok=True)
VIEWPORTS = [(1440, 900, "1440"), (820, 900, "820"), (360, 800, "360")]
SUMMARY = {
"status": "ok",
"own_company": {"id": 3, "name": "河南金牛煤业有限公司"},
"window": {
"start": "2026-01-01",
"end": "2026-08-20",
"has_opening": False,
"opening": None,
"ending": None,
},
"confirmed": {
"outflow_total": "123860000.00",
"outflow_count": 48,
"inflow_total": "184205000.00",
"inflow_count": 77,
"net_change": "-60345000.00",
"net_direction": "payable",
},
"pending": {"count": 3, "amount_total": "3200000.00"},
"counterparties": [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "52000000.00",
"confirmed_inflow": "86000000.00",
"net": "-34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "10000000.00",
"confirmed_inflow": "12605000.00",
"net": "-2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "61860000.00",
"confirmed_inflow": "85600000.00",
"net": "-23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
],
}
# 修正 mock:按 HEL-169 净变动=转出-转入,正数应收
SUMMARY["confirmed"] = {
"outflow_total": "184205000.00",
"outflow_count": 80,
"inflow_total": "123860000.00",
"inflow_count": 48,
"net_change": "60345000.00",
"net_direction": "receivable",
}
SUMMARY["counterparties"] = [
{
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"confirmed_outflow": "86000000.00",
"confirmed_inflow": "52000000.00",
"net": "34000000.00",
"pending_count": 2,
"last_effective_at": "2026-08-18",
},
{
"company_id": 6,
"company_name": "河南金牛贸易有限公司",
"confirmed_outflow": "12605000.00",
"confirmed_inflow": "10000000.00",
"net": "2605000.00",
"pending_count": 1,
"last_effective_at": "2026-08-10",
},
{
"company_id": 7,
"company_name": "河南金牛农业科技发展有限公司",
"confirmed_outflow": "85600000.00",
"confirmed_inflow": "61860000.00",
"net": "23740000.00",
"pending_count": 0,
"last_effective_at": "2026-07-30",
},
]
EVENTS = {
"status": "ok",
"window": {"start": "2026-01-01", "end": "2026-08-20"},
"events": [
{
"event_id": 101,
"direction": "in",
"state": "confirmed",
"pairing": "paired",
"locked": False,
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"summary": "周转资金调拨",
"status": "matched",
},
{
"event_id": 102,
"direction": "out",
"state": "pending",
"pairing": "unilateral",
"locked": False,
"amount": "1500000.00",
"currency": "CNY",
"effective_at": "2026-08-12",
"summary": "工程款结算(待确认)",
"status": "unresolved",
},
],
"next_cursor": None,
"has_more": False,
}
DETAIL = {
"status": "ok",
"event": {
"event_id": 101,
"classification": "intercompany",
"pairing": "paired",
"status": "matched",
"amount": "12000000.00",
"currency": "CNY",
"effective_at": "2026-08-18",
"mode": "auto",
"reason": "双边匹配",
"counterparty": {
"company_id": 5,
"company_name": "河南金牛置业有限公司",
"account_number_masked": "****8821",
},
"observations": [
{
"source_row_id": 1,
"role": "payee",
"source_row": 148,
"sheet_name": "流水",
"transaction_at": "2026-08-18 10:22:00",
"income": "12000000.00",
"expense": None,
"import_batch_id": 19,
"original_filename": "工行3305_202608.xls",
"own_account_masked": "****3305",
"counterparty_account_masked": "****8821",
"counterparty_name": "河南金牛置业有限公司",
"summary": "周转资金调拨",
"reference": "JS20260818-00317",
}
],
},
}
def main() -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
mock_js = f"""
window.__HEL177_MOCK__ = true;
const SUMMARY = {json.dumps(SUMMARY, ensure_ascii=False)};
const EVENTS = {json.dumps(EVENTS, ensure_ascii=False)};
const DETAIL = {json.dumps(DETAIL, ensure_ascii=False)};
const _fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {{
const url = String(input);
if (url.includes('/api/me') || url.includes('/api/session')) {{
return new Response(JSON.stringify({{role:'company', company_id:3, username:'牛女士', company_name:'河南金牛煤业有限公司'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/workspace')) {{
return new Response(JSON.stringify({{status:'ok', pending_unilateral:0, pending_total:0, unilateral_events:[]}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/summary')) {{
return new Response(JSON.stringify(SUMMARY), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/intercompany/events')) {{
return new Response(JSON.stringify(EVENTS), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/transfer-events/')) {{
return new Response(JSON.stringify(DETAIL), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
if (url.includes('/api/company/')) {{
return new Response(JSON.stringify({{status:'ok'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}});
}}
return _fetch(input, init);
}};
"""
console_errors: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("pageerror", lambda exc: console_errors.append(f"pageerror:{exc}"))
page.on("console", lambda msg: console_errors.append(f"console:{msg.type}:{msg.text}") if msg.type == "error" else None)
for width, height, label in VIEWPORTS:
page.set_viewport_size({"width": width, "height": height})
page.add_init_script(mock_js)
# bypass auth guard
page.add_init_script(
"""
const orig = window.fetch;
// initAuthGuard 读 /api/me;已在 mock 中处理
"""
)
page.goto(f"{base}/company.html", wait_until="domcontentloaded")
page.evaluate(
"""() => {
// 若鉴权把页面踢走,强制停留
if (!document.body || document.body.dataset.portal !== 'company') return;
}"""
)
# 等 summary 渲染
page.wait_for_timeout(600)
# 切到转账往来
page.evaluate("() => { if (typeof showView === 'function') showView('transfers'); }")
page.wait_for_selector("#transfersData:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow, f"{label} overview overflow"
# 文案:期间净变动,无期末余额误用
net_title = page.inner_text("#tfStatNetTitle")
assert "期间净变动" in net_title, net_title
page.screenshot(path=str(OUT / f"overview-{label}.png"), full_page=True)
# 下钻明细
page.click("#transfersCpBody tr.clickable")
page.wait_for_selector("#transfersDetailLayer:not([hidden])", timeout=5000)
page.wait_for_timeout(300)
overflow2 = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
assert not overflow2, f"{label} detail overflow"
page.screenshot(path=str(OUT / f"detail-{label}.png"), full_page=True)
# 打开抽屉
page.click("[data-transfer-evidence]")
page.wait_for_selector("#transferEvidenceDrawer.is-open", timeout=5000)
page.wait_for_timeout(200)
page.screenshot(path=str(OUT / f"drawer-{label}.png"), full_page=True)
page.click("[data-close-transfer-evidence]")
browser.close()
hard = [e for e in console_errors if "Failed to load resource" not in e and "favicon" not in e]
print("shots:", list(OUT.glob("*.png")))
print("console_errors:", hard)
if hard:
raise SystemExit(1)
print("OK")
if __name__ == "__main__":
main()
+710 -11
View File
@@ -11,8 +11,9 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, dashboard, importing, ledger_events, manual_records, master_data, matching,
multipart, personal_transit, positions, settings, subjects,
auth, calculation, company_transfers, dashboard, importing, ledger_events,
manual_records, master_data, matching, multipart, personal_transit, positions,
settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -77,6 +78,30 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/accounts":
self._handle_company_accounts()
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_get()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_get()
return
if path == "/api/admin/coverage-gaps":
self._handle_admin_coverage_gaps(query)
return
if path == "/api/admin/calculation-changes":
self._handle_admin_calculation_changes()
return
if path == "/api/admin/balances/pair":
self._handle_admin_pair_balance(query)
return
if path == "/api/company/coverage-gaps":
self._handle_company_coverage_gaps()
return
if path == "/api/company/balances":
self._handle_company_balances(query)
return
if path == "/api/company/balances/pair":
self._handle_company_pair_balance(query)
return
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
@@ -115,6 +140,9 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/match-exceptions":
self._handle_company_match_exceptions(query)
return
if path == "/api/company/workspace":
self._handle_company_workspace()
return
company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path)
if company_event_match:
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
@@ -152,6 +180,14 @@ class AppHandler(SimpleHTTPRequestHandler):
self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
return
# Company transfer-summary (HEL-169/HEL-175/HEL-176)
if path == "/api/company/intercompany/summary":
self._handle_company_intercompany_summary(query)
return
if path == "/api/company/intercompany/export.csv":
self._handle_company_intercompany_export_csv(query)
return
# B-44 intercompany positions (company, own-company scope)
if path == "/api/company/intercompany/balances":
self._handle_company_intercompany_balances(query)
@@ -161,7 +197,12 @@ class AppHandler(SimpleHTTPRequestHandler):
self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
return
if path == "/api/company/intercompany/events":
self._handle_company_intercompany_events(query)
# HEL-176 transfer-detail list shares this path with B-44 ledger
# events; dispatch by distinctive query params (to/direction/…).
if company_transfers.is_transfer_summary_events_query(query):
self._handle_company_transfer_summary_events(query)
else:
self._handle_company_intercompany_events(query)
return
if path == "/api/company/manual-records":
self._handle_company_manual_records(query)
@@ -251,6 +292,36 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/settings":
self._handle_admin_update_settings()
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_post()
return
opening_confirm = re.fullmatch(r"/api/admin/opening-balances/(\d+)/confirm", path)
if opening_confirm:
self._handle_admin_opening_balance_confirm(int(opening_confirm.group(1)))
return
opening_revise = re.fullmatch(r"/api/admin/opening-balances/(\d+)/revisions", path)
if opening_revise:
self._handle_admin_opening_balance_revise(int(opening_revise.group(1)))
return
opening_void = re.fullmatch(r"/api/admin/opening-balances/(\d+)/void", path)
if opening_void:
self._handle_admin_opening_balance_void(int(opening_void.group(1)))
return
if path == "/api/admin/coverage-gaps/recalculate":
self._handle_admin_recalculate_gaps()
return
attestation_review = re.fullmatch(
r"/api/admin/no-business-attestations/(\d+)/review", path
)
if attestation_review:
self._handle_admin_review_attestation(int(attestation_review.group(1)))
return
if path == "/api/company/no-business-attestations":
self._handle_company_submit_attestation()
return
if path == "/api/admin/reminders/send":
self._handle_admin_send_reminders()
return
@@ -277,6 +348,19 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/manual-records":
self._handle_company_manual_records_submit()
return
company_confirm = re.fullmatch(
r"/api/company/transfer-events/(\d+)/confirm", path
)
if company_confirm:
self._handle_company_transfer_confirm(int(company_confirm.group(1)))
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
def do_PUT(self) -> None:
path = urlparse(self.path).path
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------
@@ -774,6 +858,8 @@ class AppHandler(SimpleHTTPRequestHandler):
}
if outcome.get("matching") is not None:
payload["matching"] = outcome["matching"]
if decision == "confirm" and len(outcome.get("updated") or []) > 0:
calculation.recalculate_coverage_gaps(connection)
self._send_json(200, payload)
finally:
connection.close()
@@ -1430,6 +1516,366 @@ class AppHandler(SimpleHTTPRequestHandler):
# System settings (admin read/write, persisted + audited)
# ------------------------------------------------------------------
def _handle_admin_calculation_start_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = calculation.start_date_payload(connection)
payload["status"] = "ok"
payload["summary"] = calculation.opening_coverage_summary(connection)
self._send_json(200, payload)
finally:
connection.close()
def _handle_admin_calculation_start_put(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
result = calculation.set_calculation_start_date(
connection,
str(data.get("calculation_start_date") or ""),
str(data.get("reason") or ""),
admin,
)
except calculation.LockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **result})
finally:
connection.close()
def _handle_admin_opening_balances_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_opening_balances(connection)
self._send_json(
200,
{
"status": "ok",
"items": items,
"summary": calculation.opening_coverage_summary(connection),
},
)
finally:
connection.close()
def _handle_admin_opening_balances_post(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_a = int(data.get("company_id_low") or data.get("from_company_id"))
company_b = int(data.get("company_id_high") or data.get("to_company_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定有效的公司。"})
return
try:
item = calculation.create_opening_balance(
connection,
company_a,
company_b,
data.get("amount"),
str(data.get("reason") or ""),
admin,
viewer_company_id=company_a,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_confirm(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.confirm_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_revise(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
item = calculation.revise_opening_balance(
connection,
revision_id,
data.get("amount"),
str(data.get("reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_void(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.void_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_coverage_gaps(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
status = (query.get("status") or [None])[0]
items = calculation.list_coverage_gaps(connection, status=status)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_admin_recalculate_gaps(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
rebuilt = calculation.recalculate_coverage_gaps(connection)
self._send_json(200, {"status": "ok", "rebuilt": rebuilt})
finally:
connection.close()
def _handle_admin_calculation_changes(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_change_log(connection)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _resolve_company_id(
self, connection, query: dict[str, list[str]], field: str
) -> int | None:
raw = (query.get(field) or [None])[0]
if raw is None:
return None
try:
return int(raw)
except ValueError:
self._send_json(400, {"status": "error", "message": f"{field} 参数无效。"})
return -1
def _handle_admin_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
from_id = self._resolve_company_id(connection, query, "from_company_id")
to_id = self._resolve_company_id(connection, query, "to_company_id")
if from_id == -1 or to_id == -1:
return
if from_id is None or to_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 from_company_id 与 to_company_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, from_id, to_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_coverage_gaps(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
items = calculation.list_coverage_gaps(
connection, company_id=user["company_id"]
)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_company_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
payload = calculation.compute_company_balances(
connection, user["company_id"], cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
def _handle_company_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
counterparty_id = self._resolve_company_id(connection, query, "counterparty_id")
if counterparty_id == -1:
return
if counterparty_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 counterparty_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, user["company_id"], counterparty_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_submit_attestation(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
data = self._read_json_body()
if data is None:
return
try:
bank_account_id = int(data.get("bank_account_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定 bank_account_id。"})
return
try:
item = calculation.submit_no_business_attestation(
connection,
company_id=user["company_id"],
bank_account_id=bank_account_id,
gap_start=str(data.get("gap_start") or ""),
gap_end=str(data.get("gap_end") or ""),
reason=str(data.get("reason") or ""),
evidence=str(data.get("evidence") or "") or None,
actor=user,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_review_attestation(self, attestation_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
decision = str(data.get("decision") or "")
try:
item = calculation.review_no_business_attestation(
connection,
attestation_id,
decision,
str(data.get("review_reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_settings(self) -> None:
connection = connect(DB_PATH)
try:
@@ -2195,6 +2641,8 @@ class AppHandler(SimpleHTTPRequestHandler):
)
return {
"event_id": row["event_id"],
"decision_id": row["decision_id"],
"revision": row["revision"],
"classification": row["classification"],
"pairing": row["pairing"],
"status": matching.exposed_status(row),
@@ -2209,6 +2657,20 @@ class AppHandler(SimpleHTTPRequestHandler):
"evidence_count": row["evidence_count"],
}
def _handle_company_workspace(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
payload = matching.company_workspace_payload(connection, user["company_id"])
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_match_exceptions(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
@@ -2218,13 +2680,7 @@ class AppHandler(SimpleHTTPRequestHandler):
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
params: list[object] = [user["company_id"], user["company_id"]]
rows = connection.execute(
self._event_base_sql(True)
+ "AND d.classification IN ('unresolved', 'needs_review') "
"ORDER BY d.id DESC LIMIT 500",
params,
).fetchall()
rows = matching.company_pending_unilaterals(connection, user["company_id"])
items = [
self._company_event_list_item(row, user["company_id"]) for row in rows
]
@@ -2232,6 +2688,109 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
def _handle_company_transfer_confirm(self, event_id: int) -> None:
"""Company confirms a pending unilateral using existing assign_participant rules."""
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
company_id = int(user["company_id"])
data = self._read_json_body()
if data is None:
return
request_key = str(data.get("request_key") or "").strip() or None
# Idempotent replay: same request_key returns current workspace without re-decrement.
if request_key:
existing = connection.execute(
"""
SELECT id, actor_username
FROM transfer_match_decisions
WHERE idempotency_key = ? AND event_id = ?
""",
(request_key, event_id),
).fetchone()
if existing is not None:
if existing["actor_username"] != user["username"]:
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
return
workspace = matching.company_workspace_payload(connection, company_id)
payload = matching._decision_payload(connection, event_id, existing["id"])
self._send_json(
200,
{"status": "ok", "decision": payload, "workspace": workspace},
)
return
pending = matching.company_pending_unilaterals(connection, company_id)
target = next((row for row in pending if int(row["event_id"]) == event_id), None)
if target is None:
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
return
try:
expected_revision = (
int(data["expected_revision"])
if data.get("expected_revision") is not None
else None
)
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"})
return
try:
counterparty_company_id = int(data["counterparty_company_id"])
except (KeyError, TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "counterparty_company_id 必须是有效的公司 id。"}
)
return
if counterparty_company_id == company_id:
self._send_json(400, {"status": "error", "message": "对方公司不能是本公司。"})
return
# Own side is already on the decision; assign the opposite role.
own_is_payer = target["payer_company_id"] == company_id
role = "payee" if own_is_payer else "payer"
if target["payer_company_id"] is None and target["payee_company_id"] is None:
self._send_json(400, {"status": "error", "message": "单边流水缺少本方参与方,无法确认。"})
return
if not own_is_payer and target["payee_company_id"] != company_id:
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
return
reason = str(data.get("reason") or "").strip() or "公司端确认单边流水"
try:
payload = matching.apply_manual_decision(
connection,
event_id,
"assign_participant",
reason=reason,
expected_revision=expected_revision,
request_key=request_key,
actor=user,
participant={
"role": role,
"company_id": counterparty_company_id,
},
)
except matching.MatchConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except matching.MatchInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
workspace = matching.company_workspace_payload(connection, company_id)
self._send_json(
200,
{"status": "ok", "decision": payload, "workspace": workspace},
)
finally:
connection.close()
def _handle_company_transfer_event_detail(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
@@ -2304,10 +2863,12 @@ class AppHandler(SimpleHTTPRequestHandler):
SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
r.own_account, r.own_name, r.counterparty_account,
r.counterparty_name, r.summary, r.reference,
s.sheet_name, b.company_id AS batch_company_id
s.sheet_name, b.id AS import_batch_id,
f.original_filename, b.company_id AS batch_company_id
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_batch_id
JOIN source_files f ON f.id = b.source_file_id
WHERE r.id = ?
""",
(observation["source_row_id"],),
@@ -2324,9 +2885,16 @@ class AppHandler(SimpleHTTPRequestHandler):
"income": row["income"],
"expense": row["expense"],
"batch_company_id": row["batch_company_id"],
"import_batch_id": row["import_batch_id"],
"original_filename": row["original_filename"],
"own_account_masked": master_data.mask_account_number(row["own_account"])
if row["own_account"]
else None,
"counterparty_account_masked": master_data.mask_account_number(
row["counterparty_account"]
)
if row["counterparty_account"]
else None,
"counterparty_name": row["counterparty_name"],
"summary": row["summary"],
"reference": row["reference"],
@@ -2736,6 +3304,137 @@ class AppHandler(SimpleHTTPRequestHandler):
return None, None
return user, user["company_id"]
def _handle_company_intercompany_summary(self, query: dict[str, list[str]]) -> None:
"""Own-company transfer summary; company_id is session-only (HEL-175)."""
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
# Front-end must never supply company_id; reject even matching values.
if (query.get("company_id") or [None])[0] is not None:
self._send_json(
400,
{"status": "error", "message": "不允许传入 company_id 参数。"},
)
return
as_of = (query.get("as_of") or [None])[0]
try:
payload = company_transfers.company_intercompany_summary(
connection, company_id=int(company_id), as_of=as_of
)
except company_transfers.TransferSummaryInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _reject_forged_company_id(self, query: dict[str, list[str]]) -> bool:
"""Return True when the handler already sent a 400 for company_id."""
if (query.get("company_id") or [None])[0] is not None:
self._send_json(
400,
{"status": "error", "message": "不允许传入 company_id 参数。"},
)
return True
return False
def _handle_company_transfer_summary_events(
self, query: dict[str, list[str]]
) -> None:
"""HEL-176 filtered transfer-detail list (keyset pagination)."""
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
if self._reject_forged_company_id(query):
return
try:
payload = company_transfers.company_intercompany_events(
connection,
company_id=int(company_id),
from_=(query.get("from") or [None])[0],
to=(query.get("to") or [None])[0],
counterparty_id=(query.get("counterparty_id") or [None])[0],
direction=(query.get("direction") or [None])[0],
state=(query.get("state") or [None])[0],
limit=(query.get("limit") or [None])[0],
cursor=(query.get("cursor") or [None])[0],
)
except company_transfers.TransferSummaryInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_intercompany_export_csv(
self, query: dict[str, list[str]]
) -> None:
"""Confirmed-only CSV export; session company scope + audit (HEL-176)."""
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
if self._reject_forged_company_id(query):
return
# Pending must never leave via export, even if a client sends state=.
if (query.get("state") or [None])[0] not in (None, "", "confirmed"):
self._send_json(
400,
{
"status": "error",
"message": "导出仅支持已确认明细,不允许导出待确认。",
},
)
return
try:
items, meta = company_transfers.company_intercompany_export_rows(
connection,
company_id=int(company_id),
from_=(query.get("from") or [None])[0],
to=(query.get("to") or [None])[0],
counterparty_id=(query.get("counterparty_id") or [None])[0],
direction=(query.get("direction") or [None])[0],
)
except company_transfers.TransferSummaryInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
content = company_transfers.render_intercompany_export_csv(items)
detail_parts = [
f"rows:{meta['row_count']}",
f"from:{meta['start']}",
f"to:{meta['end']}",
"state:confirmed",
]
if meta["counterparty_id"] is not None:
detail_parts.append(f"counterparty_id:{meta['counterparty_id']}")
if meta["direction"] is not None:
detail_parts.append(f"direction:{meta['direction']}")
auth.audit(
connection,
"export_intercompany_csv",
actor=user,
target=f"company:{company_id}",
detail=";".join(detail_parts),
ip=self._client_ip,
)
self.send_response(200)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header(
"Content-Disposition",
'attachment; filename="intercompany-export.csv"',
)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(content)
finally:
connection.close()
def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
+25 -12
View File
@@ -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,)
+960
View File
@@ -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}
+754
View File
@@ -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
+15 -2
View File
@@ -42,8 +42,11 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
Priority mapping (aligned with current admin audit UI semantics):
- high: unresolved / needs_review match exceptions
- medium: pending bank-account registrations
- 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(
"""
@@ -55,9 +58,19 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
AND d.classification IN ('unresolved', 'needs_review')
"""
).fetchone()["n"]
medium = connection.execute(
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),
+165
View File
@@ -846,6 +846,171 @@ MIGRATIONS: tuple[Migration, ...] = (
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;
""",
),
)
+82 -1
View File
@@ -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
# ---------------------------------------------------------------------------
+35
View File
@@ -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:
+593
View File
@@ -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()
+221
View File
@@ -0,0 +1,221 @@
"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
sync_playwright = None
class ConfirmStatusSourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
self.assertIn('notice.classList.toggle("success", pending === 0)', js)
# 文案:完成=已完成,不把待确认一并改绿
self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("--success:", css)
self.assertIn("--warn:", css)
self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
self.assertIn(".pill-warn { background: var(--warn-soft);", css)
self.assertIn(".notice.success { background: var(--success-soft);", css)
self.assertIn(".notice.warn { background: var(--warn-soft);", css)
def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('id="workspaceConfirmState"', html)
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=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()
+500
View File
@@ -0,0 +1,500 @@
"""HTTP tests for company intercompany events list + CSV export (HEL-176).
Covers combined filters, keyset pagination, empty state, lateral access,
forged company_id, ID guessing, export isolation and audit logging.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class CompanyIntercompanyEventsTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.initial_passwords: dict[str, str] = {}
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a")
self.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str, client: Client) -> int:
status, _, data = client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{
"decision": "approve",
"reason": "测试启用",
"effective_from": "2026-01-01",
},
)
assert status == 200, data
return account_id
def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _seed_mixed(self) -> None:
"""Paired A→B 100, B→A 40, locked A→B 25, pending A→B 7, B→C 200."""
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
self._lock_single("25.00", "2026-02-01 10:00:00")
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
def _events(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/events?{query}")
return status, as_json(data) if data else {}
def _export(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"):
return client.get(f"/api/company/intercompany/export.csv?{query}")
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._events(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id_on_events_and_export(self) -> None:
status, payload = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_b}",
)
self.assertEqual(400, status, payload)
status, _, data = self._export(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&company_id={self.company_a}",
)
self.assertEqual(400, status, data)
def test_rejects_bad_filters(self) -> None:
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=sideways"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=maybe"
)
self.assertEqual(400, status, payload)
status, payload = self._events(
self.cashier_a, "from=2026-13-40&to=2026-12-31"
)
self.assertEqual(400, status, payload)
def test_empty_window(self) -> None:
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual([], payload["events"])
self.assertFalse(payload["has_more"])
self.assertIsNone(payload["next_cursor"])
# ------------------------------------------------------------------
# Combined filters + confirmed/pending separation
# ------------------------------------------------------------------
def test_combined_filters_and_state_split(self) -> None:
self._seed_mixed()
status, payload = self._events(self.cashier_a)
self.assertEqual(200, status, payload)
events = payload["events"]
# A sees: out 100, in 40, locked out 25, pending out 7 — not B↔C 200
self.assertEqual(4, len(events))
amounts = {e["amount"] for e in events}
self.assertEqual({"100.00", "40.00", "25.00", "7.00"}, amounts)
for event in events:
self.assertNotEqual(self.company_c, event["counterparty_company_id"])
self.assertIn(event["state"], ("confirmed", "pending"))
self.assertIn(event["direction"], ("out", "in"))
status, confirmed = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed"
)
self.assertEqual(200, status, confirmed)
self.assertEqual(3, len(confirmed["events"]))
self.assertTrue(all(e["state"] == "confirmed" for e in confirmed["events"]))
self.assertNotIn("7.00", {e["amount"] for e in confirmed["events"]})
status, pending = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(200, status, pending)
self.assertEqual(1, len(pending["events"]))
self.assertEqual("7.00", pending["events"][0]["amount"])
self.assertEqual("pending", pending["events"][0]["state"])
status, outs = self._events(
self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=out&state=confirmed"
)
self.assertEqual(200, status, outs)
self.assertEqual({"100.00", "25.00"}, {e["amount"] for e in outs["events"]})
self.assertTrue(all(e["direction"] == "out" for e in outs["events"]))
status, by_cp = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_b}&state=confirmed",
)
self.assertEqual(200, status, by_cp)
self.assertEqual(3, len(by_cp["events"]))
# Date window excludes Feb locked/pending
status, jan = self._events(
self.cashier_a, "from=2026-01-01&to=2026-01-31&state=confirmed"
)
self.assertEqual(200, status, jan)
self.assertEqual({"100.00", "40.00"}, {e["amount"] for e in jan["events"]})
def test_keyset_pagination_no_dup_no_gap(self) -> None:
# Three confirmed A→B outs on distinct days
for i, amount in enumerate(("11.00", "12.00", "13.00", "14.00", "15.00")):
day = 5 + i
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, f"2026-01-{day:02d} 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, amount, f"2026-01-{day:02d} 11:00:00")],
)
status, page1 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out&limit=2",
)
self.assertEqual(200, status, page1)
self.assertEqual(2, len(page1["events"]))
self.assertTrue(page1["has_more"])
self.assertIsNotNone(page1["next_cursor"])
status, page2 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page1['next_cursor']}",
)
self.assertEqual(200, status, page2)
self.assertEqual(2, len(page2["events"]))
self.assertTrue(page2["has_more"])
status, page3 = self._events(
self.cashier_a,
"from=2026-01-01&to=2026-12-31&state=confirmed&direction=out"
f"&limit=2&cursor={page2['next_cursor']}",
)
self.assertEqual(200, status, page3)
self.assertEqual(1, len(page3["events"]))
self.assertFalse(page3["has_more"])
self.assertIsNone(page3["next_cursor"])
ids = [e["event_id"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(5, len(ids))
self.assertEqual(len(ids), len(set(ids)))
# Descending by effective_at then event_id
amounts = [e["amount"] for e in page1["events"] + page2["events"] + page3["events"]]
self.assertEqual(["15.00", "14.00", "13.00", "12.00", "11.00"], amounts)
# ------------------------------------------------------------------
# Isolation / ID guess / detail reuse
# ------------------------------------------------------------------
def test_lateral_isolation_and_id_guess_404(self) -> None:
self._seed_mixed()
status, payload_a = self._events(self.cashier_a)
self.assertEqual(200, status, payload_a)
a_ids = {e["event_id"] for e in payload_a["events"]}
status, payload_c = self._events(self.cashier_c)
self.assertEqual(200, status, payload_c)
# C only participates in B↔C 200
self.assertTrue(payload_c["events"])
for event in payload_c["events"]:
self.assertEqual("200.00", event["amount"])
self.assertNotIn(event["event_id"], a_ids)
# C guessing A's event id via transfer-events detail → 404
a_event_id = next(iter(a_ids))
status, _, data = self.cashier_c.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(404, status, data)
# A can open own event; counterparty account masked; only own observations
status, _, data = self.cashier_a.get(
f"/api/company/transfer-events/{a_event_id}"
)
self.assertEqual(200, status, data)
detail = as_json(data)["event"]
if detail.get("counterparty") and "account_number_masked" in detail["counterparty"]:
masked = detail["counterparty"]["account_number_masked"]
self.assertTrue(str(masked).startswith("****"))
self.assertNotIn(ACCOUNT_B, masked)
for obs in detail["observations"]:
self.assertEqual(self.company_a, obs["batch_company_id"])
self.assertNotIn(ACCOUNT_B, json.dumps(obs, ensure_ascii=False))
# Filtering by counterparty C still cannot leak B↔C into A's list
status, filtered = self._events(
self.cashier_a,
f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_c}",
)
self.assertEqual(200, status, filtered)
self.assertEqual([], filtered["events"])
def test_b44_events_path_still_works_with_cutoff(self) -> None:
# Without HEL-176 discriminators, /events stays on B-44 ledger list.
status, _, data = self.cashier_a.get(
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertIn("items", payload)
self.assertNotIn("events", payload)
# ------------------------------------------------------------------
# Export: confirmed only + audit + isolation
# ------------------------------------------------------------------
def test_export_confirmed_only_isolated_and_audited(self) -> None:
self._seed_mixed()
status, headers, data = self._export(self.cashier_a)
self.assertEqual(200, status, data)
self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
text = data.decode("utf-8-sig")
lines = [line for line in text.splitlines() if line]
self.assertGreaterEqual(len(lines), 2)
body = "\n".join(lines[1:])
self.assertIn("100.00", body)
self.assertIn("40.00", body)
self.assertIn("25.00", body)
self.assertNotIn("7.00", body) # pending excluded
self.assertNotIn("200.00", body) # B↔C excluded
# Explicit pending state rejected
status, _, data = self._export(
self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending"
)
self.assertEqual(400, status, data)
# C export must not contain A's amounts
status, _, data = self._export(self.cashier_c)
self.assertEqual(200, status, data)
text_c = data.decode("utf-8-sig")
self.assertNotIn("100.00", text_c)
self.assertNotIn("25.00", text_c)
self.assertIn("200.00", text_c)
status, _, data = self.admin.get("/api/admin/audit-log?limit=50")
self.assertEqual(200, status, data)
actions = [row["action"] for row in as_json(data)["entries"]]
self.assertIn("export_intercompany_csv", actions)
export_rows = [
row
for row in as_json(data)["entries"]
if row["action"] == "export_intercompany_csv"
]
self.assertTrue(export_rows)
self.assertTrue(
any(f"company:{self.company_a}" == row.get("target") for row in export_rows)
)
self.assertTrue(any("state:confirmed" in (row.get("detail") or "") for row in export_rows))
if __name__ == "__main__":
unittest.main()
+362
View File
@@ -0,0 +1,362 @@
"""HTTP tests for GET /api/company/intercompany/summary (HEL-175).
Covers session-scoped company_id, forged company_id rejection, confirmed vs
pending separation, Decimal net math, dual-company isolation and empty data.
"""
from __future__ import annotations
import io
import json
import os
from decimal import Decimal
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000000001"
ACCOUNT_B = "6222000000000002"
ACCOUNT_C = "6222000000000003"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""]
class CompanyIntercompanySummaryTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.db_path = root / "app.db"
self.storage = root / "files"
self._old_db_path = server.DB_PATH
self._old_storage = server.STORAGE_DIR
server.DB_PATH = self.db_path
server.STORAGE_DIR = self.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(self.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
self.port = self.httpd.server_address[1]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.admin = Client("127.0.0.1", self.port)
status, _, data = self.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = self.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
self.initial_passwords: dict[str, str] = {}
self.company_a = self._create_company("甲公司", "cashier-a")
self.company_b = self._create_company("乙公司", "cashier-b")
self.company_c = self._create_company("丙公司", "cashier-c")
self.cashier_a = self._login_company("cashier-a")
self.cashier_b = self._login_company("cashier-b")
self.cashier_c = self._login_company("cashier-c")
self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a)
self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b)
self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c)
def tearDown(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()
server.DB_PATH = self._old_db_path
server.STORAGE_DIR = self._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
def _create_company(self, name: str, username: str) -> int:
status, _, data = self.admin.post_json(
"/api/admin/companies", {"name": name, "username": username}
)
assert status == 200, data
self.initial_passwords[username] = as_json(data)["initial_password"]
return as_json(data)["company_id"]
def _login_company(self, username: str) -> Client:
client = Client("127.0.0.1", self.port)
initial = self.initial_passwords[username]
status, _, data = client.post_json(
"/api/login", {"username": username, "password": initial, "portal": "company"}
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
def _approve_account(self, company_id: int, number: str, client: Client) -> int:
status, _, data = client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{
"decision": "approve",
"reason": "测试启用",
"effective_from": "2026-01-01",
},
)
assert status == 200, data
return account_id
def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int:
content = workbook_bytes(rows)
status, _, data = self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
)
assert status == 200, data
batch_id = as_json(data)["batch_id"]
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = client.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
assert status == 200, data
return batch_id
def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict:
"""A-side only upload → admin locks as intercompany single."""
self._upload_and_confirm(
self.cashier_a,
self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
)
status, _, data = self.admin.get("/api/admin/transfer-events")
self.assertEqual(200, status, data)
single = next(
e
for e in as_json(data)["events"]
if e["status"] == "internal_single" and e["amount"] == amount
)
status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}")
self.assertEqual(200, status, data)
revision = as_json(data)["event"]["revision"]
status, _, data = self.admin.post_json(
f"/api/admin/transfer-events/{single['event_id']}/decisions",
{
"action": "assign_participant",
"reason": "函证确认",
"expected_revision": revision,
"request_key": f"lock-{amount}-{at}",
"participant": {"role": "payee", "company_id": self.company_b},
},
)
self.assertEqual(200, status, data)
return as_json(data)["decision"]
def _summary(self, client: Client, query: str = "as_of=2026-12-31"):
status, _, data = client.get(f"/api/company/intercompany/summary?{query}")
return status, as_json(data) if data else {}
# ------------------------------------------------------------------
# Auth / parameter guards
# ------------------------------------------------------------------
def test_requires_company_role(self) -> None:
status, payload = self._summary(self.admin)
self.assertEqual(403, status, payload)
def test_rejects_forged_company_id(self) -> None:
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_b}"
)
self.assertEqual(400, status, payload)
self.assertIn("company_id", payload.get("message", ""))
def test_rejects_own_company_id_param(self) -> None:
# Even matching the session company is forbidden.
status, payload = self._summary(
self.cashier_a, f"as_of=2026-12-31&company_id={self.company_a}"
)
self.assertEqual(400, status, payload)
def test_rejects_bad_as_of(self) -> None:
status, payload = self._summary(self.cashier_a, "as_of=2026-13-40")
self.assertEqual(400, status, payload)
def test_anonymous_is_unauthorized(self) -> None:
anon = Client("127.0.0.1", self.port)
status, _, data = anon.get("/api/company/intercompany/summary?as_of=2026-12-31")
self.assertIn(status, (401, 403))
# ------------------------------------------------------------------
# Empty / confirmed math / pending isolation
# ------------------------------------------------------------------
def test_empty_window_returns_zeros(self) -> None:
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
self.assertEqual(self.company_a, payload["own_company"]["id"])
self.assertFalse(payload["window"]["has_opening"])
self.assertIsNone(payload["window"]["opening"])
self.assertEqual("0.00", payload["confirmed"]["outflow_total"])
self.assertEqual("0.00", payload["confirmed"]["inflow_total"])
self.assertEqual("0.00", payload["confirmed"]["net_change"])
self.assertEqual(0, payload["pending"]["count"])
self.assertEqual([], payload["counterparties"])
# Amounts must be strings, never floats.
self.assertIsInstance(payload["confirmed"]["net_change"], str)
self.assertNotIsInstance(payload["confirmed"]["net_change"], float)
def test_paired_locked_pending_math_and_isolation(self) -> None:
# Paired A→B 100 + B→A 40 → A net outflow 60
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")],
)
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")],
)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")],
)
# Locked single A→B 25 (confirmed)
self._lock_single("25.00", "2026-02-01 10:00:00")
# Pending unilateral A→B 7 (internal_single, not confirmed)
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")],
)
# B↔C paired 200 — must not appear in A's summary
self._upload_and_confirm(
self.cashier_b, self.company_b,
[outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")],
)
self._upload_and_confirm(
self.cashier_c, self.company_c,
[incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")],
)
status, payload = self._summary(self.cashier_a)
self.assertEqual(200, status, payload)
confirmed = payload["confirmed"]
self.assertEqual("125.00", confirmed["outflow_total"]) # 100 + 25
self.assertEqual(2, confirmed["outflow_count"])
self.assertEqual("40.00", confirmed["inflow_total"])
self.assertEqual(1, confirmed["inflow_count"])
self.assertEqual("85.00", confirmed["net_change"]) # 125 - 40
self.assertEqual("receivable", confirmed["net_direction"])
# Pending tip only — never folded into confirmed totals
self.assertEqual(1, payload["pending"]["count"])
self.assertEqual("7.00", payload["pending"]["amount_total"])
# Decimal identity: net = outflow - inflow, no float drift
net = Decimal(confirmed["net_change"])
self.assertEqual(
Decimal(confirmed["outflow_total"]) - Decimal(confirmed["inflow_total"]),
net,
)
counterparties = {row["company_id"]: row for row in payload["counterparties"]}
self.assertIn(self.company_b, counterparties)
self.assertNotIn(self.company_c, counterparties)
row_b = counterparties[self.company_b]
self.assertEqual("125.00", row_b["confirmed_outflow"])
self.assertEqual("40.00", row_b["confirmed_inflow"])
self.assertEqual("85.00", row_b["net"])
self.assertEqual(1, row_b["pending_count"])
# B must not see C-only? B sees C; A must not see B's C totals via forgery
status_b, payload_b = self._summary(self.cashier_b)
self.assertEqual(200, status_b, payload_b)
cps_b = {row["company_id"] for row in payload_b["counterparties"]}
self.assertIn(self.company_c, cps_b)
# A's view still excludes C
self.assertNotIn(self.company_c, counterparties)
# Same event counted once: eligible event count equals outflow+inflow counts
self.assertEqual(
confirmed["outflow_count"] + confirmed["inflow_count"],
3,
)
def test_company_b_cannot_see_a_only_pending(self) -> None:
self._upload_and_confirm(
self.cashier_a, self.company_a,
[outgoing(ACCOUNT_A, ACCOUNT_B, "9.00", "2026-04-01 10:00:00")],
)
status_a, payload_a = self._summary(self.cashier_a)
self.assertEqual(200, status_a, payload_a)
self.assertEqual(1, payload_a["pending"]["count"])
status_c, payload_c = self._summary(self.cashier_c)
self.assertEqual(200, status_c, payload_c)
self.assertEqual(0, payload_c["pending"]["count"])
self.assertEqual("0.00", payload_c["confirmed"]["outflow_total"])
self.assertEqual([], payload_c["counterparties"])
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
"""HEL-177: 公司端转账往来页面(方案 A)结构与接口契约。"""
from __future__ import annotations
import re
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
class TransfersPageSourceContractTests(unittest.TestCase):
def test_company_html_has_nav_overview_and_layers(self) -> None:
html = (WEB / "company.html").read_text(encoding="utf-8")
self.assertIn('data-view="transfers"', html)
self.assertIn("转账往来", html)
self.assertIn('id="workspaceTransfersCard"', html)
self.assertIn("转账往来概览", html)
self.assertIn('data-page="transfers"', html)
self.assertIn('id="transfersOverviewLayer"', html)
self.assertIn('id="transfersDetailLayer"', html)
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=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()
+313
View File
@@ -0,0 +1,313 @@
"""HEL-168: company workspace pending unilateral counts sync after confirm."""
from __future__ import annotations
import io
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "Cashier123"
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
ACCOUNT_A = "6222000000001001"
ACCOUNT_B = "6222000000001002"
def workbook_bytes(rows) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "正常流水"
sheet.append(CCB_HEADER)
for row in rows:
sheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str) -> list:
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""]
class CompanyWorkspaceTodoTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
from bank_importer.db import connect, migrate
connection = connect(cls.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
cls.initial_passwords: dict[str, str] = {}
cls.company_a = cls._create_company("甲公司", "ws-cashier-a")
cls.company_b = cls._create_company("乙公司", "ws-cashier-b")
cls.cashier_a = cls._login_company("ws-cashier-a")
cls.cashier_b = cls._login_company("ws-cashier-b")
cls._approve_account(cls.cashier_a, ACCOUNT_A)
cls._approve_account(cls.cashier_b, ACCOUNT_B)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
@classmethod
def _create_company(cls, name: str, username: str) -> int:
status, _, data = cls.admin.post_json(
"/api/admin/companies",
{"name": name, "username": username},
)
assert status == 200, data
payload = as_json(data)
cls.initial_passwords[username] = payload["initial_password"]
return payload["company_id"]
@classmethod
def _login_company(cls, username: str) -> Client:
client = Client("127.0.0.1", cls.port)
initial = cls.initial_passwords[username]
status, _, data = client.post_json(
"/api/login",
{"username": username, "password": initial, "portal": "company"},
)
assert status == 200, data
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": CASHIER_PASSWORD},
)
assert status == 200, data
return client
@classmethod
def _approve_account(cls, company_client: Client, number: str) -> int:
status, _, data = company_client.post_json(
"/api/company/accounts",
{
"bank_name": "中信银行",
"account_type": "基本户",
"account_number": number,
"start_date": "2026-01-01",
},
)
assert status == 200, data
account_id = as_json(data)["account"]["id"]
status, _, data = cls.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
)
assert status == 200, data
return account_id
def _seed_unilateral(self, amount: str, at: str, cp_account: str = "9999999999999999") -> dict:
status, _, data = self.admin.post_multipart(
"/api/parse",
{"company_id": str(self.company_a)},
f"单边-{amount}.xlsx",
workbook_bytes([outgoing(ACCOUNT_A, cp_account, amount, at)]),
)
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
self.assertEqual(200, status, data)
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/confirm", {"sheets": names}
)
self.assertEqual(200, status, data)
status, _, data = self.cashier_a.get("/api/company/workspace")
self.assertEqual(200, status, data)
events = [
e for e in as_json(data)["unilateral_events"] if e["amount"] == amount
]
self.assertEqual(1, len(events), data)
return events[0]
def _workspace(self, client: Client) -> dict:
status, _, data = client.get("/api/company/workspace")
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("ok", payload["status"])
return payload
def test_seed_three_confirm_sync_and_isolation(self) -> None:
# Seed 3 pending unilaterals for company A.
e1 = self._seed_unilateral("11.00", "2026-03-01 10:00:00")
e2 = self._seed_unilateral("22.00", "2026-03-02 10:00:00")
e3 = self._seed_unilateral("33.00", "2026-03-03 10:00:00")
seeded_ids = {e1["event_id"], e2["event_id"], e3["event_id"]}
ws = self._workspace(self.cashier_a)
own = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
self.assertEqual(3, len(own))
self.assertEqual(ws["pending_unilateral"], ws["pending_total"])
self.assertGreaterEqual(ws["pending_unilateral"], 3)
# 两处数字与列表同一权威口径
self.assertEqual(ws["pending_unilateral"], len(ws["unilateral_events"]))
# 另一公司看不到甲公司这 3 笔
ws_b = self._workspace(self.cashier_b)
self.assertEqual(
[],
[e for e in ws_b["unilateral_events"] if e["event_id"] in seeded_ids],
)
# match-exceptions 与 workspace 同集合
status, _, data = self.cashier_a.get("/api/company/match-exceptions")
self.assertEqual(200, status, data)
exceptions = as_json(data)["exceptions"]
exception_ids = {e["event_id"] for e in exceptions}
self.assertTrue(seeded_ids.issubset(exception_ids))
remaining = [e1, e2, e3]
for expected in (2, 1, 0):
event = remaining.pop(0)
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{event['event_id']}/confirm",
{
"expected_revision": event["revision"],
"request_key": f"hel168-{event['event_id']}-ok",
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(200, status, data)
body = as_json(data)
self.assertIn("workspace", body)
left = [
e
for e in body["workspace"]["unilateral_events"]
if e["event_id"] in seeded_ids
]
self.assertEqual(expected, len(left))
self.assertEqual(body["workspace"]["pending_unilateral"], len(body["workspace"]["unilateral_events"]))
# 刷新等价:重新 GET 仍为同步后的数字
ws = self._workspace(self.cashier_a)
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
self.assertEqual(expected, len(left))
# 重复确认:事件已离开待确认集合且 request_key 不同 → 404,数字不得误减
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{e1['event_id']}/confirm",
{
"expected_revision": e1["revision"],
"request_key": f"hel168-{e1['event_id']}-again",
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(404, status, data)
ws = self._workspace(self.cashier_a)
left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids]
self.assertEqual(0, len(left))
# 同一 request_key 重放:幂等成功,数字不误减
e4 = self._seed_unilateral("44.00", "2026-03-04 10:00:00")
key = f"hel168-{e4['event_id']}-replay"
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{e4['event_id']}/confirm",
{
"expected_revision": e4["revision"],
"request_key": key,
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(200, status, data)
after_first = self._workspace(self.cashier_a)["pending_unilateral"]
status, _, data = self.cashier_a.post_json(
f"/api/company/transfer-events/{e4['event_id']}/confirm",
{
"expected_revision": e4["revision"],
"request_key": key,
"counterparty_company_id": self.company_b,
"reason": "公司端确认单边流水",
},
)
self.assertEqual(200, status, data)
self.assertEqual(after_first, as_json(data)["workspace"]["pending_unilateral"])
self.assertEqual(after_first, self._workspace(self.cashier_a)["pending_unilateral"])
# 失败请求不得误减:用不存在的事件
before = self._workspace(self.cashier_a)["pending_unilateral"]
status, _, data = self.cashier_a.post_json(
"/api/company/transfer-events/999999/confirm",
{
"expected_revision": 1,
"request_key": "hel168-fail",
"counterparty_company_id": self.company_b,
},
)
self.assertEqual(404, status, data)
self.assertEqual(before, self._workspace(self.cashier_a)["pending_unilateral"])
# 乙公司不能确认甲公司事件(即使猜到 id)
status, _, data = self.cashier_b.post_json(
f"/api/company/transfer-events/{e2['event_id']}/confirm",
{
"expected_revision": e2["revision"],
"request_key": "hel168-cross",
"counterparty_company_id": self.company_a,
},
)
self.assertEqual(404, status, data)
def test_admin_forbidden_on_company_workspace(self) -> None:
status, _, data = self.admin.get("/api/company/workspace")
self.assertEqual(403, status, data)
if __name__ == "__main__":
unittest.main()
+294
View File
@@ -57,6 +57,96 @@ class DashboardUnitTests(unittest.TestCase):
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(
@@ -180,6 +270,210 @@ class DashboardApiTests(unittest.TestCase):
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"
+518
View File
@@ -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()
+417
View File
@@ -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()
+9 -5
View File
@@ -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, 7], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -85,19 +85,23 @@ class MigrationTests(PersistenceTestCase):
"system_settings",
"system_setting_changes",
"reminders",
"closed_periods",
"opening_balance_revisions",
"coverage_gaps",
"no_business_attestations",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -105,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+38
View File
@@ -211,6 +211,44 @@ class SettingsAndRemindersTests(unittest.TestCase):
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."""
+114 -129
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团管理端" />
<title>管理端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=5" />
<link rel="stylesheet" href="design-system.css?v=8" />
</head>
<body data-portal="admin">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -20,7 +20,7 @@
<div class="nav-group">日常</div>
<a class="active" data-view="dashboard" href="#dashboard"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></svg><span class="nav-label">管理总览</span></a>
<a data-view="pair" href="#pair"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M7 8h13l-3.5-3.5M17 16H4l3.5 3.5"/></svg><span class="nav-label">往来查询</span></a>
<a data-view="audit" href="#audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">审核中心</span><span class="nav-badge">6</span></a>
<a data-view="audit" href="#audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">审核中心</span><span class="nav-badge" id="auditNavBadge" style="display: none;">0</span></a>
<a data-view="flows" href="#flows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="nav-label">流水管理</span></a>
<div class="nav-group">基础与结账</div>
<a data-view="companies" href="#companies"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 21V8l8-5 8 5v13"/><path d="M9 21v-6h6v6"/></svg><span class="nav-label">公司与账号</span></a>
@@ -82,64 +82,74 @@
</div>
</div>
<div class="grid grid-2" style="margin-top: 14px;">
<div class="card">
<div class="card-head" style="margin-bottom: 10px;">
<span class="card-title">各公司往来分布<span class="sub">期末净值 · 单位:万元</span></span>
<div class="card dash-master" id="companyMasterDetail">
<div class="card-head dash-master-head">
<span class="card-title" style="min-width: 0;">
公司往来合计 · 往来明细
<span class="sub" id="dashListSub">单位:万元 · 左侧选择公司,右侧查看其往来明细</span>
</span>
<div class="dash-master-head-actions">
<span class="meta" id="dashMasterMeta"></span>
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索公司…" />
</div>
<div id="dashBarChart" style="height: 220px; min-width: 0;" aria-label="各公司往来分布柱状图"></div>
</div>
<div class="card">
<div class="card-head" style="margin-bottom: 10px;">
<span class="card-title">本周资金流入流出<span class="sub">集团内往来 · 单位:万元</span></span>
</div>
<div id="dashLineChart" style="height: 220px; min-width: 0;" aria-label="本周资金流入流出折线图"></div>
</div>
</div>
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
<div class="grid grid-1-2" style="gap: 0; align-items: stretch; height: 492px;">
<div style="border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; min-height: 0;">
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0; gap: 8px;">
<span class="card-title" style="min-width: 0;">公司往来合计<span class="sub" id="dashListSub" style="display: inline; margin-left: 6px;">单位:万元</span></span>
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索…" style="width: 120px; min-height: 28px; padding: 4px 8px; flex: none;" />
</div>
<div class="table-wrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
<table class="ds-table" id="dashCompanyTable" style="min-width: 0;">
<thead>
<tr>
<th style="padding: 6px 12px;">公司</th>
<th class="num-col" style="padding: 6px 12px;">明细笔数</th>
<th class="num-col" style="padding: 6px 12px;">期末净值</th>
<th id="dashStatusHead" style="padding: 6px 12px;">状态</th>
</tr>
</thead>
<tbody id="dashCompanyRows">
<tr class="loading-row"><td colspan="4">加载中…</td></tr>
</tbody>
</table>
<div class="grid grid-480-1 dash-master-split">
<div class="dash-master-pane dash-master-pane--list">
<div class="dash-company-list" id="dashCompanyRows" role="listbox" aria-label="公司往来合计">
<div class="dash-company-loading muted">加载中…</div>
</div>
</div>
<div style="display: flex; flex-direction: column; min-width: 0; min-height: 0;">
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0;">
<span class="card-title" id="dashDetailTitle">公司往来明细<span class="sub" id="dashDetailSub">选择左侧公司查看会计式分级明细</span></span>
<div class="dash-master-pane dash-master-pane--detail">
<div class="dash-detail-toolbar">
<div class="dash-detail-heading">
<span class="dash-detail-name" id="dashDetailTitle">请选择左侧公司</span>
<span class="meta" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列</span>
</div>
</div>
<div class="table-wrap" id="dashDetailWrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
<table class="ds-table" id="dashDetailTable" style="min-width: 0;">
<thead>
<tr>
<th style="padding: 6px 12px;">对方 / 摘要</th>
<th class="num-col" style="padding: 6px 12px;">笔数</th>
<th class="num-col" style="padding: 6px 12px;">期初</th>
<th class="num-col" style="padding: 6px 12px;">本期借方</th>
<th class="num-col" style="padding: 6px 12px;">本期贷方</th>
<th class="num-col" style="padding: 6px 12px;">期末</th>
</tr>
</thead>
<tbody id="dashDetailRows">
<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>
</tbody>
</table>
<div class="dash-detail-summary" id="dashDetailSummary" hidden>
<div class="dash-summary-item">
<span class="dash-summary-label">笔数</span>
<span class="dash-summary-value num" id="dashSumCount">0</span>
</div>
<div class="dash-summary-item">
<span class="dash-summary-label">期初</span>
<span class="dash-summary-value num" id="dashSumOpening"></span>
</div>
<div class="dash-summary-item">
<span class="dash-summary-label">本期借方</span>
<span class="dash-summary-value num" id="dashSumDebit">0.00</span>
</div>
<div class="dash-summary-item">
<span class="dash-summary-label">本期贷方</span>
<span class="dash-summary-value num" id="dashSumCredit">0.00</span>
</div>
<div class="dash-summary-item">
<span class="dash-summary-label">期末</span>
<span class="dash-summary-value num" id="dashSumEnding">0.00</span>
</div>
</div>
<div class="dash-detail-body" id="dashDetailBody">
<div class="table-wrap dash-master-scroll" id="dashDetailWrap">
<table class="ds-table ds-table--compact" id="dashDetailTable">
<thead>
<tr>
<th>对方 / 摘要</th>
<th class="num-col">笔数</th>
<th class="num-col">期初</th>
<th class="num-col">本期借方</th>
<th class="num-col">本期贷方</th>
<th class="num-col">期末</th>
</tr>
</thead>
<tbody id="dashDetailRows">
<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>
</tbody>
</table>
</div>
<div class="dash-detail-empty" id="dashDetailEmpty" hidden>
<div class="e-title">该公司期间内暂无已归集往来</div>
<div class="e-desc" id="dashDetailEmptyDesc">期初 — · 导入流水并归集后在此展示</div>
</div>
</div>
</div>
</div>
@@ -347,51 +357,6 @@
</tr>
</thead>
<tbody id="auditRows">
<tr data-audit-type="断档" data-company="金牛置业">
<td><span class="pill pill-danger"></span></td>
<td><span class="cell-main">金牛置业 · 中行尾号 8821</span><span class="cell-sub">07-06 至 07-16 无流水</span></td>
<td>流水断档</td>
<td>2026-07</td>
<td class="wrap">11 天无流水,与金牛煤业 320 万煤炭采购款往来无法归集,阻断 7 月结账</td>
<td><span class="pill pill-info">待审核</span></td>
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
</tr>
<tr data-audit-type="单边" data-company="金牛新能源">
<td><span class="pill pill-warn"></span></td>
<td><span class="cell-main">金牛新能源 ↔ 金牛贸易</span><span class="cell-sub">3 笔单边合计 486 万</span></td>
<td>单边匹配</td>
<td>2026-07</td>
<td class="wrap">单边支付无对方对应流水,往来差异挂账,待对方选择流水佐证</td>
<td><span class="pill pill-info">待审核</span></td>
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
</tr>
<tr data-audit-type="科目" data-company="金牛煤业">
<td><span class="pill pill-warn"></span></td>
<td><span class="cell-main">金牛煤业 · 手工记录</span><span class="cell-sub">场地押金 42 万</span></td>
<td>科目确认</td>
<td>2026-07</td>
<td class="wrap">「应收 / 其他应收」科目待判定,影响科目汇总口径</td>
<td><span class="pill pill-info">待审核</span></td>
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
</tr>
<tr data-audit-type="起算" data-company="金牛物流">
<td><span class="pill pill-danger"></span></td>
<td><span class="cell-main">金牛物流 · 工行基本户</span><span class="cell-sub">期初与上年结转差 28.6 万</span></td>
<td>起算区间校准</td>
<td>2026-01 起算</td>
<td class="wrap">期初余额与上年结转不衔接,全年累计数失真,需校准起算区间</td>
<td><span class="pill pill-info">待审核</span></td>
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
</tr>
<tr data-audit-type="手工" data-company="金牛贸易">
<td><span class="pill pill-warn"></span></td>
<td><span class="cell-main">金牛贸易 · 手工补录</span><span class="cell-sub">现金缴存 64 万</span></td>
<td>公司手工记录</td>
<td>2026-07</td>
<td class="wrap">无银行回单附件,需人工核实后方可入账</td>
<td><span class="pill pill-info">待审核</span></td>
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
</tr>
</tbody>
</table>
<div class="table-foot">
@@ -463,7 +428,7 @@
</table>
<div class="table-foot">
<span id="flowCount">共 0 笔</span>
<span class="meta" id="flowsRangeMeta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
<span class="meta" id="flowsRangeMeta">数据范围:加载中…</span>
</div>
</div>
</section>
@@ -547,13 +512,14 @@
<form id="systemSettings" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label for="cs-start">全局起算日</label>
<input class="input num-input" type="date" name="startDate" id="cs-start" value="2026-01-01" />
<span class="hint">期初余额以此日前一日的期末数为准</span>
<input class="input num-input" type="date" name="startDate" id="cs-start" />
<span class="hint" id="cs-start-hint">期初余额以此日前一日的期末数为准</span>
<span class="pill pill-muted" id="cs-start-locked" style="display: none; margin-top: 8px;">已锁定</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="cs-day">每月结账日</label>
<input class="input num-input" type="number" name="closingDay" id="cs-day" min="1" max="28" value="5" />
<span class="hint">每月几号执行上月结账 · 2026-07 因故顺延至 08-29</span>
<span class="hint">每月几号执行上月结账</span>
</div>
<div class="field" style="margin-bottom: 16px;">
<label>自动提醒</label>
@@ -582,15 +548,30 @@
<div class="card">
<div class="card-head">
<span class="card-title">期初余额管理<span class="sub" id="openingSub">2026-01-01 起算的公司间往来期初数</span></span>
<span class="card-title">期初余额管理<span class="sub" id="openingSub">公司间往来期初数</span></span>
<button class="btn btn-sm" id="openOpeningDialog">新增</button>
</div>
<div class="notice info" id="openingSummary" style="margin: 0 0 14px; display: none;"></div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr><th>本方公司</th><th>对方公司</th><th>方向</th><th class="num-col">金额</th><th>录入人</th><th>录入时间</th><th>状态</th><th></th></tr>
</thead>
<tbody id="openingRows"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">变更记录<span class="sub">起算日与期初余额改动留痕</span></span>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr><th>本方公司</th><th>对方公司</th><th>科目</th><th>方向</th><th class="num-col">金额(万元)</th><th>生效日</th><th>状态</th></tr>
<tr><th>时间</th><th>操作人</th><th>对象</th><th>变更</th><th>原因</th></tr>
</thead>
<tbody id="openingRows"></tbody>
<tbody id="calculationChangeRows"><tr><td colspan="5" class="empty">暂无变更记录</td></tr></tbody>
</table>
</div>
</div>
@@ -797,31 +778,15 @@
<select class="select" name="to" id="ob-to" required></select>
</div>
<div class="field">
<label for="ob-subject">科目</label>
<select class="select" name="subject" id="ob-subject" required>
<option>应收</option>
<option>应付</option>
<option>其他应收</option>
<option>其他应付</option>
</select>
<label for="ob-amount">金额</label>
<input class="input num-input" name="amount" id="ob-amount" type="number" step="0.01" required />
</div>
<div class="field">
<label for="ob-direction">方向</label>
<select class="select" name="direction" id="ob-direction" required>
<option>借方</option>
<option>贷方</option>
</select>
</div>
<div class="field">
<label for="ob-amount">金额(万元)</label>
<input class="input num-input" name="amount" id="ob-amount" type="number" min="0" step="0.01" required />
</div>
<div class="field">
<label for="ob-effective">生效日</label>
<input class="input" name="effectiveDate" id="ob-effective" type="date" value="2026-01-01" required />
<label for="ob-reason">录入原因</label>
<input class="input" name="reason" id="ob-reason" required placeholder="必填,将写入变更记录" />
</div>
</div>
<p class="hint" style="margin-top: 10px;">提交后进入复核,不能直接修改已确认期初</p>
<p class="hint" style="margin-top: 10px;">正数表示本方对对方为应收;提交后进入待确认,确认后才计入公司端余额</p>
<div class="modal-actions">
<button type="button" class="btn" data-close-opening>取消</button>
<button type="submit" class="btn btn-primary">提交复核</button>
@@ -830,6 +795,26 @@
</div>
</div>
<div class="modal-backdrop" id="reasonDialog">
<div class="modal">
<div class="modal-head">
<span class="modal-title" id="reasonDialogTitle">填写原因</span>
<button type="button" class="modal-close" data-close-reason aria-label="关闭">×</button>
</div>
<p class="modal-sub" id="reasonDialogSub">该操作必须填写原因并留痕。</p>
<form id="reasonForm" novalidate>
<div class="field">
<label for="reasonInput">原因</label>
<textarea class="input" id="reasonInput" name="reason" rows="3" required placeholder="至少 2 个字"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close-reason>取消</button>
<button type="submit" class="btn btn-primary" id="reasonSubmit">确认</button>
</div>
</form>
</div>
</div>
<!-- 审核通过弹窗 -->
<div class="modal-backdrop" id="modal-approve">
<div class="modal">
@@ -977,6 +962,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=5"></script>
<script src="app.js?v=13"></script>
</body>
</html>
+1718 -319
View File
File diff suppressed because it is too large Load Diff
+321 -170
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团公司业务端" />
<title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=5" />
<link rel="stylesheet" href="design-system.css?v=6" />
</head>
<body data-portal="company">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -22,6 +22,7 @@
<a data-view="upload" href="#upload"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 16V4m0 0l-4 4m4-4l4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg><span class="nav-label">流水导入</span></a>
<a data-view="manual" href="#manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg><span class="nav-label">手工记录</span></a>
<a data-view="flows" href="#flows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="nav-label">流水管理</span></a>
<a data-view="transfers" href="#transfers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M7 7h13v3l-4-2.5L20 5V8"/><path d="M17 17H4v-3l4 2.5L4 19v-3"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg><span class="nav-label">转账往来</span><span class="nav-badge" id="transfersNavBadge" hidden>0</span></a>
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
<div class="nav-group">账户与消息</div>
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
@@ -57,13 +58,13 @@
</div>
<div class="page-actions">
<button class="btn" data-open-upload>上传流水</button>
<button class="btn btn-primary" data-view-link="reconcile">去确认单边流水 (3)</button>
<button class="btn btn-primary" id="workspaceUnilateralCta" data-view-link="reconcile">去确认单边流水 (0)</button>
</div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
<span class="pill pill-warn">结账日顺延至 08-29</span>
</div>
<div class="flow">
@@ -79,8 +80,8 @@
</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">待处理 5 笔</div>
<div class="fs-meta" id="workspaceConfirmMeta">单边流水 3 笔 · 科目确认 2 笔</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>
@@ -97,45 +98,25 @@
<div class="grid grid-3-2" style="margin-top: 14px;">
<div class="stack">
<div class="notice danger" id="companyCoverageNotice" style="display: none; margin-bottom: 14px;">
<div>
<div class="n-title" id="companyCoverageTitle">流水断档提醒</div>
<div class="n-body" id="companyCoverageBody"></div>
</div>
<div class="row" style="margin-top: 10px; gap: 8px; flex-wrap: wrap;">
<button type="button" class="btn btn-sm btn-primary" data-view-link="upload">去上传</button>
<button type="button" class="btn btn-sm" id="openAttestationFromWorkspace">提交无业务说明</button>
</div>
</div>
<div class="card" id="workspaceTodos">
<div class="card-head">
<span class="card-title">本月待办<span class="sub">本月任务 13/18 · 按处理顺序排列,前 3 项阻断结账</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">4 项待处理</span>
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">按权威待确认单边流水同步</span></span>
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
</div>
<div class="list-row" data-task-type="match" data-detail="task-match">
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">处理 3 笔单边流水确认</div>
<div class="lr-sub">选择对方银行流水作为证据后提交确认</div>
</div>
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>
</div>
<div class="list-row" data-task-type="subject" data-detail="task-subject">
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">确认 2 笔其他应收科目</div>
<div class="lr-sub">核对后改判或维持原科目</div>
</div>
<button class="btn btn-sm" data-view-link="manual">去处理</button>
</div>
<div class="list-row" data-detail="task-upload">
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">补传交行尾号 7710 账户 7 月流水</div>
<div class="lr-sub">账户已登记,待总行审核通过后即可导入</div>
</div>
<button class="btn btn-sm" data-view-link="accounts">查看账户</button>
</div>
<div class="list-row" data-detail="task-reconcile">
<span class="pill pill-muted">一般</span>
<div class="lr-main">
<div class="lr-title">核对与金牛置业 320 万往来</div>
<div class="lr-sub">置业 8821 账户 7 月流水断档,需人工核对</div>
</div>
<button class="btn btn-sm" data-view-link="flows">去核对</button>
</div>
<div class="table-foot">
<span>完成以上 4 项后,7 月账期即可提交集团结账</span>
<div id="workspaceTodoList"></div>
<div class="table-foot" id="workspaceTodoFoot">
<span>正在读取待办…</span>
</div>
</div>
@@ -172,29 +153,29 @@
</div>
<div class="stack">
<div class="card">
<div class="card" id="workspaceTransfersCard">
<div class="card-head">
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
<span class="card-title">转账往来概览<span class="sub" id="workspaceTransfersSub">加载中…</span></span>
</div>
<div class="mini-stats">
<div class="mini-stats" id="workspaceTransfersStats">
<div class="mini-stat">
<div class="ms-label">借方合计</div>
<div class="ms-value">18,420.50<span class="unit">万元</span></div>
<div class="ms-label">转入合计</div>
<div class="ms-value pos" id="wsTfIn"></div>
</div>
<div class="mini-stat">
<div class="ms-label">贷方合计</div>
<div class="ms-value">12,386.00<span class="unit">万元</span></div>
<div class="ms-label">转出合计</div>
<div class="ms-value neg" id="wsTfOut"></div>
</div>
<div class="mini-stat">
<div class="ms-label">明细笔数</div>
<div class="ms-value">128<span class="unit"></span></div>
<div class="ms-label" id="wsTfNetLabel">期间净变动</div>
<div class="ms-value" id="wsTfNet"></div>
</div>
<div class="mini-stat">
<div class="ms-label">期末净往来 · 应收方向</div>
<div class="ms-value pos">+6,034.50<span class="unit">万元</span></div>
<div class="ms-label">待确认 · 不计入合计</div>
<div class="ms-value" id="wsTfPending" style="color: var(--warn);"></div>
</div>
</div>
<button class="btn btn-ghost" data-view-link="flows" style="width: 100%; margin-top: 12px;">查看本公司逐笔流水</button>
<button class="btn btn-ghost" data-view-link="transfers" style="width: 100%; margin-top: 12px;">查看转账往来明细</button>
</div>
<div class="card">
@@ -387,6 +368,16 @@
</section>
<section class="app-view" data-page="flows">
<div class="notice danger" id="flowsCoverageNotice" style="display: none; margin-bottom: 14px;">
<div>
<div class="n-title">流水断档提醒</div>
<div class="n-body" id="flowsCoverageBody"></div>
</div>
<div class="row" style="margin-top: 10px; gap: 8px;">
<button type="button" class="btn btn-sm btn-primary" data-view-link="upload">去上传</button>
<button type="button" class="btn btn-sm" id="openAttestationFromFlows">提交无业务说明</button>
</div>
</div>
<div class="page-head">
<div>
<h1>流水管理</h1>
@@ -452,6 +443,224 @@
</div>
</section>
<section class="app-view" data-page="transfers">
<div id="transfersOverviewLayer">
<div class="page-head">
<div>
<h1>转账往来</h1>
<p class="page-sub" id="transfersPageSub">加载本公司与集团内其他公司的转账往来…</p>
</div>
<div class="page-actions">
<button class="btn" type="button" id="transfersExportBtn">导出台账</button>
<button class="btn btn-primary" type="button" id="transfersGoConfirm" data-view-link="reconcile" hidden>去确认待确认</button>
</div>
</div>
<div id="transfersLoading" class="card" hidden>
<div class="grid grid-4" style="margin-bottom: 14px;">
<div class="skeleton" style="height: 88px;"></div>
<div class="skeleton" style="height: 88px;"></div>
<div class="skeleton" style="height: 88px;"></div>
<div class="skeleton" style="height: 88px;"></div>
</div>
<div class="skeleton skeleton-line" style="width: 70%;"></div>
<div class="skeleton skeleton-line" style="width: 92%;"></div>
<div class="skeleton skeleton-line" style="width: 55%; margin-bottom: 0;"></div>
<div class="loading-inline" style="margin-top: 16px;">加载中,正在计算往来余额…</div>
</div>
<div id="transfersError" class="notice danger" hidden>
<div>
<div class="n-title">转账往来数据加载失败</div>
<div class="n-body" id="transfersErrorBody">服务器连接异常。为避免误读,本页不展示任何金额。</div>
</div>
<div class="row" style="margin-top: 12px; gap: 8px;">
<button class="btn btn-primary" type="button" id="transfersRetryBtn">重新加载</button>
</div>
</div>
<div id="transfersEmpty" class="card" hidden>
<div class="grid grid-4" id="transfersEmptyStats"></div>
<div class="empty" style="margin-top: 14px;">
<div class="e-title">该期间暂无转账往来</div>
<div>可能尚未导入流水,或集团内往来确认尚未完成。导入本公司银行流水后,系统会自动计算公司间往来。</div>
<div class="row" style="justify-content: center; margin-top: 14px; gap: 8px;">
<button class="btn btn-primary" type="button" data-view-link="upload">去导入流水</button>
<button class="btn" type="button" data-view-link="accounts">查看银行账户</button>
</div>
</div>
</div>
<div id="transfersData" hidden>
<div class="grid grid-4" id="transfersStatGrid">
<div class="card stat-card" id="tfStatOpeningCard" hidden>
<div class="stat-label"><span class="stat-dot muted"></span>期初余额</div>
<div class="stat-value" id="tfStatOpening"></div>
<div class="stat-foot">起算日前一日结转</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot info"></span>往来公司数</div>
<div class="stat-value" id="tfStatCompanies"><span class="unit"></span></div>
<div class="stat-foot" id="tfStatCompaniesFoot">统计窗口内有往来的对方公司</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot success"></span>本期转入 · 流入</div>
<div class="stat-value amt-in" id="tfStatIn"></div>
<div class="stat-foot">已确认转入合计 · 待确认不计入</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot danger"></span>本期转出 · 流出</div>
<div class="stat-value amt-out" id="tfStatOut"></div>
<div class="stat-foot">已确认转出合计 · 待确认不计入</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot info"></span><span id="tfStatNetTitle">期间净变动</span></div>
<div class="stat-value" id="tfStatNet"></div>
<div class="stat-foot" id="tfStatNetFoot">正数=应收方向 · 负数=应付方向</div>
</div>
<div class="card stat-card" id="tfStatEndingCard" hidden>
<div class="stat-label"><span class="stat-dot success"></span>期末余额</div>
<div class="stat-value" id="tfStatEnding"></div>
<div class="stat-foot">期初 + 本期变动</div>
</div>
</div>
<div class="xfer-split" id="transfersSplit" style="margin-top: 14px;">
<div class="xfer-split-pane confirmed">
<div class="xfer-split-head">
<span class="pill pill-success">已确认</span>
<span class="meta" id="tfConfirmedCount">0 笔</span>
</div>
<div class="xfer-split-value num" id="tfConfirmedNet"></div>
<div class="xfer-split-note">计入上方合计与期间净变动</div>
</div>
<div class="xfer-split-pane pending">
<div class="xfer-split-head">
<span class="pill pill-warn">待确认</span>
<span class="meta" id="tfPendingCount">0 笔</span>
</div>
<div class="xfer-split-value num" id="tfPendingAmount"></div>
<div class="xfer-split-note">单列展示,不计入已确认合计</div>
</div>
</div>
<div class="card" style="margin-top: 14px;">
<div class="card-head">
<span class="card-title">按对方公司查看<span class="sub">点击行进入该公司往来明细</span></span>
<span class="pill pill-info">仅本公司数据</span>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table" id="transfersCpTable">
<thead>
<tr>
<th>对方公司</th>
<th class="num-col">已确认转入</th>
<th class="num-col">已确认转出</th>
<th class="num-col">净往来</th>
<th>方向</th>
<th>待确认</th>
<th>最近往来日</th>
</tr>
</thead>
<tbody id="transfersCpBody"></tbody>
<tfoot id="transfersCpFoot"></tfoot>
</table>
</div>
</div>
<div class="notice info" style="margin-top: 14px;">
<div class="n-title">口径说明</div>
<div class="n-body">仅含集团内公司间转账(HEL-169)。已确认与待确认严格分开;待确认不计入合计。起算日/期初未就绪时展示「期间净变动」,不得当作期末余额。银行原始流水只读,调整须走冲销/调整单留痕。</div>
</div>
</div>
</div>
<div id="transfersDetailLayer" hidden>
<div class="page-head">
<div>
<button class="btn btn-ghost" type="button" id="transfersBackBtn" style="padding-left: 0; margin-bottom: 4px;">← 返回总览</button>
<h1 id="transfersDetailTitle">往来明细</h1>
<p class="page-sub" id="transfersDetailSub">筛选后加载更多 · 单位:万元</p>
</div>
<div class="page-actions">
<button class="btn" type="button" id="transfersDetailExportBtn">导出台账</button>
</div>
</div>
<div class="grid grid-3" id="transfersDetailStats" style="margin-bottom: 14px;">
<div class="card stat-card">
<div class="stat-label">往来笔数</div>
<div class="stat-value" id="tfDetailCount"></div>
<div class="stat-foot" id="tfDetailCountFoot">当前筛选</div>
</div>
<div class="card stat-card">
<div class="stat-label">转入 · 流入</div>
<div class="stat-value amt-in" id="tfDetailIn"></div>
</div>
<div class="card stat-card">
<div class="stat-label">转出 · 流出</div>
<div class="stat-value amt-out" id="tfDetailOut"></div>
</div>
</div>
<form class="filters" id="transfersFilterForm" novalidate>
<div class="field">
<label for="tfFilterDirection">方向</label>
<select class="select" id="tfFilterDirection" name="direction">
<option value="">全部</option>
<option value="in">转入</option>
<option value="out">转出</option>
</select>
</div>
<div class="field">
<label for="tfFilterState">确认状态</label>
<select class="select" id="tfFilterState" name="state">
<option value="">全部</option>
<option value="confirmed">已确认</option>
<option value="pending">待确认</option>
</select>
</div>
<div class="field">
<label for="tfFilterFrom">日期起</label>
<input class="input num-input" id="tfFilterFrom" name="from" type="date" />
</div>
<div class="field">
<label for="tfFilterTo">日期止</label>
<input class="input num-input" id="tfFilterTo" name="to" type="date" />
</div>
<button class="btn" type="button" id="tfFilterReset">清空筛选</button>
<button class="btn btn-primary" type="submit">查询</button>
</form>
<div class="table-wrap">
<table class="ds-table" id="transfersEventTable">
<thead>
<tr>
<th>日期</th>
<th class="wrap">摘要</th>
<th>方向</th>
<th class="num-col">金额(万元)</th>
<th>状态</th>
<th></th>
</tr>
</thead>
<tbody id="transfersEventBody"></tbody>
</table>
<div class="empty" id="transfersEventEmpty" hidden>
<div class="e-title">当前筛选条件下没有流水</div>
<div>可清空筛选后重试,或返回总览查看其他对方公司。</div>
<div class="row" style="justify-content: center; margin-top: 12px;">
<button class="btn" type="button" id="tfEmptyClear">清空筛选</button>
</div>
</div>
<div class="table-foot">
<span id="transfersEventFoot">共 0 笔</span>
<button class="btn btn-sm" type="button" id="transfersLoadMore" hidden>加载更多</button>
</div>
</div>
</div>
</section>
<section class="app-view" data-page="reconcile">
<div class="page-head">
<div>
@@ -463,130 +672,18 @@
<div class="notice warn" id="blocking-notice" style="margin-bottom: 16px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 18px; height: 18px; flex: none; margin-top: 1px;"><path d="M12 8v5M12 16.5v.01"/><path d="M10.3 4.1L2.8 17a2 2 0 0 0 1.7 3h15a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0z"/></svg>
<div>
<div class="n-title" id="notice-title">5 项待确认,是 7 月结账的阻断项</div>
<div class="n-body" id="notice-body">含单边流水匹配 3 项、科目确认 2 项。请于 2026-08-29(7 月顺延结账日)前处理完毕,否则集团无法对贵公司执行 7 月结账</div>
<div class="n-title" id="notice-title">正在读取待确认单边流水…</div>
<div class="n-body" id="notice-body">工作台与往来确认共用同一权威待确认集合</div>
</div>
</div>
<div class="tabs">
<button type="button" class="active" id="tab-match" aria-pressed="true">单边流水匹配<span class="tab-count" id="count-match">3</span></button>
<button type="button" class="active" id="tab-match" aria-pressed="true">单边流水匹配<span class="tab-count" id="count-match">0</span></button>
<button type="button" id="tab-subject" aria-pressed="false">科目确认<span class="tab-count" id="count-subject">2</span></button>
</div>
<section id="panel-match">
<div class="stack" id="match-stack">
<div class="card" data-match-card>
<div class="card-head">
<span class="card-title">2026-07-14 付 <span class="num amt-out">¥3,200,000.00</span> 给金牛置业<span class="sub">摘要:煤炭采购款</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>本方流水号</dt><dd>MY-ICBC-3305-20260714-018</dd>
<dt>本方账户</dt><dd>工商银行基本户 · 尾号 3305</dd>
<dt>对方户名</dt><dd>河南金牛置业有限公司</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>候选对方银行证据(选择 1 条进行匹配)</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-1" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛置业 · 建设银行一般户 · 尾号 6642</div>
<div class="lr-sub num">2026-07-14 收 ¥3,200,000.00 · 对方流水号 ZY-CCB-6642-20260714-006</div>
</div>
<span class="pill pill-success lr-side">匹配度 高</span>
</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-1" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛置业 · 中国银行基本户 · 尾号 8821</div>
<div class="lr-sub num">2026-07-15 收 ¥3,200,000.00 · 对方流水号 ZY-BOC-8821-20260715-011</div>
</div>
<span class="pill pill-warn lr-side">匹配度 中</span>
</label>
<span class="hint">提示:置业中行尾号 8821 账户 07-06 至 07-16 流水断档,其 07-15 记录为对方后补手工记录,匹配度较低,请优先核对建行 6642。</span>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled data-match-summary="2026-07-14 付 ¥3,200,000.00 给金牛置业 · 煤炭采购款 · 已匹配置业建行 6642 收款记录">确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>
</div>
<div class="card" data-match-card>
<div class="card-head">
<span class="card-title">2026-07-22 付 <span class="num amt-out">¥950,000.00</span> 给金牛物流<span class="sub">摘要:7 月矿区运输费</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>本方流水号</dt><dd>MY-ICBC-3305-20260722-031</dd>
<dt>本方账户</dt><dd>工商银行基本户 · 尾号 3305</dd>
<dt>对方户名</dt><dd>河南金牛物流有限公司</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>候选对方银行证据(选择 1 条进行匹配)</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-2" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛物流 · 农业银行基本户 · 尾号 5508</div>
<div class="lr-sub num">2026-07-22 收 ¥950,000.00 · 对方流水号 WL-ABC-5508-20260722-014</div>
</div>
<span class="pill pill-success lr-side">匹配度 高</span>
</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-2" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛物流 · 工商银行一般户 · 尾号 2201</div>
<div class="lr-sub num">2026-07-23 收 ¥950,000.00 · 对方流水号 WL-ICBC-2201-20260723-002</div>
</div>
<span class="pill pill-warn lr-side">匹配度 中</span>
</label>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled data-match-summary="2026-07-22 付 ¥950,000.00 给金牛物流 · 7 月矿区运输费 · 已匹配物流农行 5508 收款记录">确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>
</div>
<div class="card" data-match-card>
<div class="card-head">
<span class="card-title">2026-07-09 收 <span class="num amt-in">¥1,860,000.00</span> 自金牛贸易<span class="sub">摘要:精煤销售款</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>本方流水号</dt><dd>MY-BOC-9916-20260709-007</dd>
<dt>本方账户</dt><dd>中国银行一般户 · 尾号 9916</dd>
<dt>对方户名</dt><dd>河南金牛贸易有限公司</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>候选对方银行证据(选择 1 条进行匹配)</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-3" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛贸易 · 交通银行基本户 · 尾号 8077</div>
<div class="lr-sub num">2026-07-09 付 ¥1,860,000.00 · 对方流水号 MY-BCM-8077-20260709-019</div>
</div>
<span class="pill pill-success lr-side">匹配度 高</span>
</label>
<label class="list-row" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="ev-3" style="width: auto;" />
<div class="lr-main">
<div class="lr-title">金牛贸易 · 中国银行一般户 · 尾号 3340</div>
<div class="lr-sub num">2026-07-08 付 ¥1,860,000.00 · 对方流水号 MY-BOC-3340-20260708-021</div>
</div>
<span class="pill pill-warn lr-side">匹配度 中</span>
</label>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled data-match-summary="2026-07-09 收 ¥1,860,000.00 自金牛贸易 · 精煤销售款 · 已匹配贸易交行 8077 付款记录">确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>
</div>
</div>
<div class="stack" id="match-stack"></div>
<div class="card" style="margin-top: 14px; display: none;" id="matched-summary">
<div class="card-head">
@@ -804,6 +901,37 @@
</div>
<!-- 导入批次详情弹窗 -->
<div class="modal-backdrop" id="attestationDialog">
<div class="modal">
<div class="modal-head">
<span class="modal-title">提交无业务说明</span>
<button type="button" class="modal-close" data-close-attestation aria-label="关闭">×</button>
</div>
<p class="modal-sub">说明经管理员审核通过后仅关闭断档提醒,不会生成银行流水。</p>
<form id="attestationForm" novalidate>
<input type="hidden" id="att-gap-start" name="gap_start" />
<input type="hidden" id="att-gap-end" name="gap_end" />
<input type="hidden" id="att-account-id" name="bank_account_id" />
<div class="field">
<label>断档区间</label>
<div class="meta num" id="att-gap-label"></div>
</div>
<div class="field">
<label for="att-reason">说明(至少 5 个字)</label>
<textarea class="input" id="att-reason" name="reason" rows="3" required></textarea>
</div>
<div class="field">
<label for="att-evidence">佐证(可选)</label>
<input class="input" id="att-evidence" name="evidence" placeholder="如:节假日无业务、账户停用说明编号" />
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close-attestation>取消</button>
<button type="submit" class="btn btn-primary">提交审核</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" id="modal-batch">
<div class="modal">
<div class="modal-head">
@@ -956,7 +1084,30 @@
</form>
</div>
</div>
<aside class="drawer" id="transferEvidenceDrawer" aria-label="银行原始流水" aria-hidden="true">
<div class="drawer-head">
<div>
<span class="pill pill-info" id="tfEvTag">银行原始流水 · 只读</span>
<h2 class="d-title" id="tfEvTitle">原始流水</h2>
<p class="d-desc" id="tfEvDesc"></p>
</div>
<button type="button" class="icon-button" data-close-transfer-evidence aria-label="关闭" title="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="drawer-body">
<dl class="kv" id="tfEvFields"></dl>
</div>
<div class="drawer-tip">
<strong>铁律提示</strong>
银行原始流水永远只读。金额或归属有误时,须由管理员通过冲销/调整单留痕处理,本页不得直接改数。
</div>
<div class="drawer-foot">
<button type="button" class="btn" data-close-transfer-evidence>关闭</button>
</div>
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=5"></script>
<script src="app.js?v=13"></script>
</body>
</html>
+366 -1
View File
@@ -213,6 +213,233 @@ p { margin: 0; }
.grid-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; }
.grid-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: start; }
.grid-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
/* 管理端首页主从:左栏约 480px(定稿 HEL-153),右侧明细更宽 */
.grid-480-1 { grid-template-columns: minmax(0, 480px) minmax(0, 1fr); align-items: stretch; }
/* 方案 A:一体卡片 · 分栏主从(HEL-162) */
.dash-master {
margin-top: 14px;
padding: 0;
overflow: hidden;
}
.dash-master-head {
padding: 12px 16px 10px;
margin-bottom: 0;
gap: 10px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.dash-master-head .card-title .sub {
display: inline;
margin-left: 6px;
}
.dash-master-head-actions {
display: flex;
align-items: center;
gap: 10px;
flex: none;
margin-left: auto;
}
.dash-master-head-actions .input {
width: 132px;
min-height: 28px;
padding: 4px 8px;
flex: none;
}
.dash-master-split {
gap: 0;
/* 少数据自适应;整卡(含共享头)目标约 340px;多数据模块内滚动 */
max-height: 300px;
/* max-height alone 无法约束隐式行高;补可收缩行后 pane 内 overflow:auto 才会生效 */
grid-template-rows: minmax(0, 1fr);
overflow: hidden;
}
.dash-master-pane {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.dash-master-pane--list {
border-right: 1px solid var(--border);
}
.dash-company-list {
flex: 1;
min-height: 0;
overflow: auto;
}
.dash-company-loading,
.dash-company-empty {
padding: 16px;
font-size: 13px;
}
.dash-company-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px 8px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s ease;
}
.dash-company-item:last-child {
border-bottom: 0;
}
.dash-company-item:hover {
background: var(--fg-soft);
}
.dash-company-item.is-selected {
background: var(--accent-soft);
box-shadow: inset 3px 0 0 var(--accent);
padding-left: 13px;
}
.dash-company-item.is-selected:hover {
background: var(--accent-soft);
}
.dash-company-main {
min-width: 0;
flex: 1;
}
.dash-company-name {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13.5px;
font-weight: 550;
color: var(--fg);
line-height: 1.35;
}
.dash-company-meta {
display: block;
margin-top: 2px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-company-net {
flex: none;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 13px;
font-weight: 600;
text-align: right;
min-width: 4.5em;
}
.dash-detail-toolbar {
padding: 10px 14px 0;
flex: none;
}
.dash-detail-heading {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dash-detail-name {
font-size: 14px;
font-weight: 650;
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-detail-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
margin: 10px 14px 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
flex: none;
}
.dash-summary-item {
padding: 8px 10px;
border-right: 1px solid var(--border);
min-width: 0;
}
.dash-summary-item:last-child {
border-right: 0;
}
.dash-summary-label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--muted);
margin-bottom: 2px;
}
.dash-summary-value {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-detail-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
margin-top: 8px;
}
.dash-master-scroll {
border: 0;
border-radius: 0;
flex: 1;
min-height: 0;
overflow: auto;
}
.dash-detail-empty {
flex: 1;
min-height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px 24px;
color: var(--muted);
font-size: 13px;
}
.dash-detail-empty .e-title {
font-weight: 600;
color: var(--fg);
margin-bottom: 6px;
}
.dash-detail-empty .e-desc {
max-width: 360px;
line-height: 1.55;
}
.dash-master-scroll[hidden],
.dash-detail-empty[hidden],
.dash-detail-summary[hidden] {
display: none !important;
}
.ds-table.ds-table--compact {
min-width: 0;
}
.ds-table.ds-table--compact th,
.ds-table.ds-table--compact td {
padding: 6px 12px;
}
.ds-table.ds-table--compact th {
font-size: 11px;
white-space: nowrap;
}
.ds-table tbody tr.is-selected {
background: var(--accent-soft);
}
.ds-table tbody tr.is-selected:hover {
background: var(--accent-soft);
}
.row { display: flex; align-items: center; gap: 10px; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.stack { display: flex; flex-direction: column; gap: 14px; }
@@ -223,13 +450,56 @@ p { margin: 0; }
@media (max-width: 1100px) {
.grid-4, .grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2 { grid-template-columns: minmax(0, 1fr); }
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2, .grid-480-1 { grid-template-columns: minmax(0, 1fr); }
.dash-master-split {
max-height: none;
grid-template-rows: none;
overflow: visible;
}
.dash-master-pane--list {
border-right: 0;
border-bottom: 1px solid var(--border);
max-height: 280px;
}
.dash-master-pane--detail {
max-height: 420px;
}
.dash-detail-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dash-summary-item:nth-child(3) {
border-right: 0;
}
.dash-summary-item:nth-child(n+4) {
border-top: 1px solid var(--border);
}
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; }
.grid-2 { grid-template-columns: 1fr; }
.content { padding: 16px 16px 48px; }
.dash-master-head-actions {
width: 100%;
margin-left: 0;
justify-content: space-between;
}
.dash-master-head-actions .input {
flex: 1;
width: auto;
}
.dash-detail-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dash-summary-item:nth-child(2n) {
border-right: 0;
}
.dash-summary-item:nth-child(3) {
border-right: 1px solid var(--border);
}
.dash-summary-item:nth-child(n+3) {
border-top: 1px solid var(--border);
}
}
/* ─── 数据表 ────────────────────────────────────────────────────── */
@@ -718,3 +988,98 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
.notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); }
.notice.success .n-title { color: color-mix(in oklch, var(--success) 82%, black); }
/* ─── 公司端 · 转账往来(方案 A,HEL-177)───────────────────────────
新模块范围内用 --info 品蓝;不改全站 accent。取值一律既有 token。 */
body[data-portal="company"] .side-nav a[data-view="transfers"].active {
background: var(--info-soft);
color: var(--fg);
}
body[data-portal="company"] .side-nav a[data-view="transfers"].active svg {
color: var(--info);
}
[data-page="transfers"] .btn-primary {
background: var(--info);
border-color: var(--info);
color: var(--surface);
}
[data-page="transfers"] .btn-primary:hover {
background: color-mix(in oklch, var(--info) 88%, black);
border-color: color-mix(in oklch, var(--info) 88%, black);
}
[data-page="transfers"] .loading-inline::before {
border-top-color: var(--info);
}
[data-page="transfers"] .stat-card .stat-value.amt-in { color: var(--success); }
[data-page="transfers"] .stat-card .stat-value.amt-out { color: var(--danger); }
[data-page="transfers"] .ds-table tbody tr.is-pending {
background: color-mix(in oklch, var(--warn) 8%, transparent);
}
[data-page="transfers"] .ds-table tbody tr.is-pending:hover {
background: color-mix(in oklch, var(--warn) 14%, transparent);
}
.xfer-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface);
}
.xfer-split-pane {
padding: 16px 18px;
}
.xfer-split-pane.confirmed {
background: color-mix(in oklch, var(--success) 6%, var(--surface));
border-right: 1px solid var(--border);
}
.xfer-split-pane.pending {
background: color-mix(in oklch, var(--warn) 8%, var(--surface));
}
.xfer-split-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.xfer-split-value {
font-size: 22px;
font-weight: 650;
letter-spacing: -0.02em;
}
.xfer-split-pane.confirmed .xfer-split-value { color: var(--success); }
.xfer-split-pane.pending .xfer-split-value { color: color-mix(in oklch, var(--warn) 80%, black); }
.xfer-split-note {
margin-top: 6px;
font-size: 12px;
color: var(--muted);
}
.xfer-dir-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.xfer-dir-tag.recv {
background: var(--success-soft);
color: var(--success);
}
.xfer-dir-tag.pay {
background: var(--danger-soft);
color: var(--danger);
}
.xfer-dir-tag.flat {
background: var(--fg-soft);
color: var(--muted);
}
@media (max-width: 820px) {
.xfer-split { grid-template-columns: 1fr; }
.xfer-split-pane.confirmed { border-right: 0; border-bottom: 1px solid var(--border); }
}