Compare commits

...
Author SHA1 Message Date
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
18 changed files with 1829 additions and 370 deletions
+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()
+129 -7
View File
@@ -115,6 +115,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)))
@@ -277,6 +280,12 @@ 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": "接口不存在。"})
# ------------------------------------------------------------------
@@ -2195,6 +2204,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 +2220,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 +2243,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 +2251,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:
+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,)
+81
View File
@@ -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:
+210
View File
@@ -0,0 +1,210 @@
"""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=11", 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}")
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
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()
+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()
+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."""
+67 -42
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=6" />
<link rel="stylesheet" href="design-system.css?v=8" />
</head>
<body data-portal="admin">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -82,49 +82,74 @@
</div>
</div>
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
<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>
<div class="grid grid-480-1 dash-master-split">
<div class="dash-master-pane dash-master-pane--list">
<div class="card-head dash-master-head">
<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 dash-master-scroll">
<table class="ds-table ds-table--compact" id="dashCompanyTable">
<thead>
<tr>
<th>公司</th>
<th class="num-col">明细笔数</th>
<th class="num-col">期末净值</th>
<th id="dashStatusHead">状态</th>
</tr>
</thead>
<tbody id="dashCompanyRows">
<tr class="loading-row"><td colspan="4">加载中…</td></tr>
</tbody>
</table>
<div class="dash-company-list" id="dashCompanyRows" role="listbox" aria-label="公司往来合计">
<div class="dash-company-loading muted">加载中…</div>
</div>
</div>
<div class="dash-master-pane">
<div class="card-head dash-master-head">
<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 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 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>
@@ -403,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>
@@ -493,7 +518,7 @@
<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>
@@ -917,6 +942,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=6"></script>
<script src="app.js?v=9"></script>
</body>
</html>
+392 -138
View File
@@ -1145,49 +1145,48 @@ function netClass(value) {
return "";
}
function statusPill(label) {
if (!label || label === "—") return '<span class="meta">—</span>';
const map = {
"已完成": "pill-success",
"流水待补": "pill-warn",
"确认中": "pill-warn",
"未提交": "pill-danger",
};
return `<span class="pill ${map[label] || "pill-muted"}">${label}</span>`;
}
function updateDashAuditCard(audit) {
applyAuditCounts(audit, { fromApi: true });
}
function renderDashCompanyRows(companies, selectedId) {
const tbody = $("#dashCompanyRows");
if (!tbody) return;
const list = $("#dashCompanyRows");
if (!list) return;
if (!companies.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">暂无公司或尚无归集往来</td></tr>';
list.innerHTML = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
return;
}
tbody.replaceChildren(...companies.map((company) => {
const tr = document.createElement("tr");
tr.className = "clickable";
tr.dataset.companyId = company.id;
const monthLabel = state.dashPeriodMonth ? `${state.dashPeriodMonth}` : "";
list.replaceChildren(...companies.map((company) => {
const item = document.createElement("div");
item.className = "dash-company-item";
item.setAttribute("role", "option");
item.dataset.companyId = company.id;
item.tabIndex = 0;
if (String(company.id) === String(selectedId)) {
tr.classList.add("is-selected");
item.classList.add("is-selected");
item.setAttribute("aria-selected", "true");
} else {
item.setAttribute("aria-selected", "false");
}
const nameTd = document.createElement("td");
nameTd.className = "dash-company-name";
nameTd.title = company.name;
nameTd.textContent = company.name;
const countTd = document.createElement("td");
countTd.className = "num-col";
countTd.textContent = String(company.detail_count ?? 0);
const netTd = document.createElement("td");
netTd.className = `num-col ${netClass(company.net_wan)}`;
netTd.textContent = formatWan(company.net_wan);
const statusTd = document.createElement("td");
statusTd.innerHTML = statusPill(company.period_status_label);
tr.append(nameTd, countTd, netTd, statusTd);
return tr;
const main = document.createElement("div");
main.className = "dash-company-main";
const name = document.createElement("span");
name.className = "dash-company-name";
name.title = company.name;
name.textContent = company.name;
const meta = document.createElement("span");
meta.className = "dash-company-meta";
const statusLabel = company.period_status_label || "—";
meta.textContent = monthLabel
? `${company.detail_count ?? 0}笔 · ${monthLabel} ${statusLabel}`
: `${company.detail_count ?? 0}笔 · ${statusLabel}`;
main.append(name, meta);
const net = document.createElement("span");
net.className = `dash-company-net ${netClass(company.net_wan)}`;
net.textContent = formatWan(company.net_wan);
item.append(main, net);
return item;
}));
}
@@ -1199,22 +1198,94 @@ function chevronSvg(expanded) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m9 18 6-6-6-6"/></svg>';
}
function renderDashDetail(payload, { expandFirst = false } = {}) {
function formatPlainWan(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "0.00";
return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function updateDashDetailSummary(company, payload) {
const bar = $("#dashDetailSummary");
if (!bar) return;
if (!company) {
bar.hidden = true;
return;
}
bar.hidden = false;
const count = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.count || 0), 0)
: Number(company.detail_count || 0);
const debit = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.debit_wan || 0), 0)
: Number(company.debit_wan || 0);
const credit = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.credit_wan || 0), 0)
: Number(company.credit_wan || 0);
const ending = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.ending_wan || 0), 0)
: Number(company.net_wan || 0);
const setText = (id, text, className = "") => {
const el = $(id);
if (!el) return;
el.textContent = text;
el.className = `dash-summary-value num ${className}`.trim();
};
setText("#dashSumCount", String(count));
setText("#dashSumOpening", "—", "meta");
setText("#dashSumDebit", formatPlainWan(debit));
setText("#dashSumCredit", formatPlainWan(credit));
setText("#dashSumEnding", formatWan(ending), netClass(ending));
}
function showDashDetailEmpty(company) {
const wrap = $("#dashDetailWrap");
const empty = $("#dashDetailEmpty");
const desc = $("#dashDetailEmptyDesc");
if (wrap) wrap.hidden = true;
if (empty) empty.hidden = false;
if (desc) {
const cutoff = state.dashCutoff || "—";
// 方案 A 空态:两行克制说明;期初口径仍为不可用(—),不伪造 0.00
desc.textContent = `期初 — · 截至 ${cutoff} 无明细记录 · 导入流水并归集后在此展示`;
}
updateDashDetailSummary(null);
}
function showDashDetailTable() {
const wrap = $("#dashDetailWrap");
const empty = $("#dashDetailEmpty");
if (wrap) wrap.hidden = false;
if (empty) empty.hidden = true;
}
function renderDashDetail(payload, { expandFirst = false, company = null } = {}) {
const tbody = $("#dashDetailRows");
const title = $("#dashDetailTitle");
if (!tbody) return;
const selected = company || (state.dashCompanies || []).find(
(c) => String(c.id) === String(payload?.company_id || state.dashSelectedId)
) || null;
if (!payload) {
if (title) title.textContent = "请选择左侧公司";
showDashDetailTable();
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
updateDashDetailSummary(null);
return;
}
if (title) {
title.innerHTML = `公司往来明细 · ${payload.company_name}<span class="sub" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 期初待 B-45</span>`;
}
if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细";
const sub = $("#dashDetailSub");
if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列";
const groups = payload.groups || [];
if (!groups.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">该公司区间内暂无已归集往来</td></tr>';
showDashDetailEmpty(selected || { detail_count: 0, debit_wan: 0, credit_wan: 0, net_wan: 0 });
tbody.replaceChildren();
return;
}
showDashDetailTable();
updateDashDetailSummary(selected, payload);
const rows = [];
groups.forEach((group, index) => {
const expanded = expandFirst && index === 0;
@@ -1268,7 +1339,12 @@ async function resolveDashStartDate() {
async function loadDashCompanyDetail(companyId) {
if (!companyId) return;
state.dashSelectedId = companyId;
const company = (state.dashCompanies || []).find((c) => String(c.id) === String(companyId)) || null;
renderDashCompanyRows(state.dashCompanies || [], companyId);
const title = $("#dashDetailTitle");
if (title && company) title.textContent = company.name;
updateDashDetailSummary(company, null);
showDashDetailTable();
const tbody = $("#dashDetailRows");
if (tbody) tbody.innerHTML = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
const from = await resolveDashStartDate();
@@ -1278,7 +1354,7 @@ async function loadDashCompanyDetail(companyId) {
return;
}
const result = await response.json().catch(() => null);
renderDashDetail(result);
renderDashDetail(result, { company });
}
async function loadAdminDashboard() {
@@ -1286,22 +1362,30 @@ async function loadAdminDashboard() {
const from = await resolveDashStartDate();
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
if (!response?.ok) {
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
return;
}
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok") {
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
return;
}
state.dashCompanies = data.companies || [];
state.dashFrom = data.from_date;
state.dashCutoff = data.cutoff;
state.dashPeriodMonth = data.period_month;
updateDashAuditCard(data.audit);
const statusHead = $("#dashStatusHead");
if (statusHead && data.period_month) statusHead.textContent = `${data.period_month}月状态`;
const listSub = $("#dashListSub");
if (listSub) listSub.textContent = `${data.from_date}${data.cutoff} · 单位:万元`;
if (listSub) {
listSub.textContent = `${data.from_date}${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`;
}
const meta = $("#dashMasterMeta");
if (meta) {
const totals = data.totals || {};
const companyCount = totals.company_count ?? state.dashCompanies.length;
const detailCount = totals.detail_count ?? 0;
meta.textContent = `${companyCount} 家公司 · 明细 ${detailCount}`;
}
const selected = state.dashSelectedId || state.dashCompanies[0]?.id;
renderDashCompanyRows(state.dashCompanies, selected);
if (selected) await loadDashCompanyDetail(selected);
@@ -1309,10 +1393,21 @@ async function loadAdminDashboard() {
function initDashboard() {
if (!$("#companyMasterDetail")) return;
const selectCompany = (companyId) => {
if (!companyId) return;
loadDashCompanyDetail(companyId);
};
$("#dashCompanyRows")?.addEventListener("click", (event) => {
const tr = event.target.closest("tr[data-company-id]");
if (!tr) return;
loadDashCompanyDetail(tr.dataset.companyId);
const item = event.target.closest(".dash-company-item[data-company-id]");
if (!item) return;
selectCompany(item.dataset.companyId);
});
$("#dashCompanyRows")?.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const item = event.target.closest(".dash-company-item[data-company-id]");
if (!item) return;
event.preventDefault();
selectCompany(item.dataset.companyId);
});
$("#dashDetailRows")?.addEventListener("click", (event) => {
const tr = event.target.closest("tr[data-peer-group]");
@@ -1330,8 +1425,8 @@ function initDashboard() {
});
$("#company-search")?.addEventListener("input", function () {
const q = this.value.trim();
$$("#dashCompanyRows tr[data-company-id]").forEach((tr) => {
tr.style.display = (!q || tr.textContent.includes(q)) ? "" : "none";
$$("#dashCompanyRows .dash-company-item[data-company-id]").forEach((item) => {
item.style.display = (!q || item.textContent.includes(q)) ? "" : "none";
});
});
loadAdminDashboard();
@@ -1649,7 +1744,7 @@ function initAdmin() {
const timeline = $("#timelineSub");
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
const flows = $("#flowsRangeMeta");
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账7 月顺延至 08-29`;
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账`;
const opening = $("#openingSub");
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
}
@@ -2453,100 +2548,257 @@ async function loadImportBatches() {
if (foot) foot.textContent = batches.length ? `${batches.length} 个批次` : "暂无批次";
}
function refreshWorkspacePending() {
const card = $("#workspaceTodos");
if (!card) return;
const count = $$(".list-row", card).length;
const status = $("#workspacePendingStatus");
if (status) status.textContent = `${count} 项待处理`;
function formatWorkspaceAmount(amount, currency) {
const n = Number(amount);
const text = Number.isFinite(n)
? n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: String(amount ?? "—");
return `${currency || "CNY"} ${text}`;
}
function initReconcile() {
const matchCards = $$("[data-match-card]");
const subjectRows = $$("[data-subject-row]");
if (!matchCards.length && !subjectRows.length) return;
function applyCompanyWorkspace(payload) {
const events = Array.isArray(payload?.unilateral_events) ? payload.unilateral_events : [];
const pending = Number(payload?.pending_unilateral ?? events.length) || 0;
const total = Number(payload?.pending_total ?? pending) || 0;
state.workspace = { pending_unilateral: pending, pending_total: total, unilateral_events: events };
const cta = $("#workspaceUnilateralCta");
if (cta) {
cta.textContent = pending ? `去确认单边流水 (${pending})` : "单边流水已全部确认";
cta.classList.toggle("btn-primary", pending > 0);
}
const status = $("#workspacePendingStatus");
if (status) {
status.textContent = total ? `${total} 项待处理` : "已完成";
status.className = `pill ${total ? "pill-warn" : "pill-success"}`;
}
const sub = $("#workspaceTodoSub");
if (sub) sub.textContent = total ? `权威待确认单边流水 ${pending}` : "本月单边流水待办已清空";
const list = $("#workspaceTodoList");
if (list) {
list.replaceChildren();
if (!events.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.style.padding = "18px 16px";
empty.innerHTML = '<div class="e-title">暂无待确认单边流水</div><div>刷新或重登后仍以服务端权威状态为准</div>';
list.append(empty);
} else {
events.forEach((event) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.taskType = "match";
row.dataset.eventId = String(event.event_id);
row.innerHTML = `
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">确认单边流水 · ${formatWorkspaceAmount(event.amount, event.currency)}</div>
<div class="lr-sub">${event.counterparty_company_name || "对方待指定"} · ${event.effective_at || "—"}</div>
</div>
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>`;
list.append(row);
});
}
}
const foot = $("#workspaceTodoFoot");
if (foot) {
foot.innerHTML = total
? `<span>处理完 ${total} 笔单边流水后,工作台数字与列表将同步归零</span>`
: "<span>单边流水待办已完成</span>";
}
const flowState = $("#workspaceConfirmState");
if (flowState) flowState.textContent = pending ? `待处理 ${pending}` : "已完成";
const flowMeta = $("#workspaceConfirmMeta");
if (flowMeta) {
flowMeta.textContent = pending
? `单边流水 ${pending} 笔待确认`
: "已全部确认,等待集团结账";
}
// 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing
const flowStep = flowState?.closest(".flow-step");
if (flowStep) {
flowStep.classList.toggle("doing", pending > 0);
flowStep.classList.toggle("done", pending === 0);
}
const flowSub = $("#workspaceFlowSub");
if (flowSub) {
flowSub.textContent = pending
? "当前停在第 3 步「往来确认」,完成后即可等待集团结账"
: "第 3 步「往来确认」已完成,等待集团复核与结账";
}
const countMatch = $("#count-match");
if (countMatch) countMatch.textContent = String(pending);
const noticeTitle = $("#notice-title");
const noticeBody = $("#notice-body");
const notice = $("#blocking-notice");
const matchedSummary = $("#matched-summary");
const matchedList = $("#matched-list");
const matchStack = $("#match-stack");
let pendingMatch = matchCards.length;
let pendingSubject = subjectRows.length;
function refreshCounts() {
const countMatch = $("#count-match");
const countSubject = $("#count-subject");
if (countMatch) countMatch.textContent = pendingMatch;
if (countSubject) countSubject.textContent = pendingSubject;
const total = pendingMatch + pendingSubject;
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
if (badge) {
badge.textContent = total;
badge.style.display = total ? "" : "none";
}
if (noticeTitle) {
noticeTitle.textContent = total ? `${total} 项待确认,是 7 月结账的阻断项` : "全部确认完成";
}
if (noticeBody) {
noticeBody.textContent = total
? `含单边流水匹配 ${pendingMatch} 项、科目确认 ${pendingSubject} 项。请于 2026-08-29(7 月顺延结账日)前处理完毕,否则集团无法对贵公司执行 7 月结账。`
: "本公司 2026-07 账期已具备结账条件,集团将于 08-29 统一执行结账。";
}
if (notice) {
notice.classList.toggle("warn", total > 0);
notice.classList.toggle("success", total === 0);
}
if (pendingMatch === 0) $('[data-task-type="match"]', $("#workspaceTodos"))?.remove();
const matchRow = $('[data-task-type="match"]', $("#workspaceTodos"));
if (matchRow) {
const title = $(".lr-title", matchRow);
if (title) title.textContent = `处理 ${pendingMatch} 笔单边流水确认`;
}
const subjectRow = $('[data-task-type="subject"]', $("#workspaceTodos"));
if (subjectRow) {
const title = $(".lr-title", subjectRow);
if (title) title.textContent = `确认 ${pendingSubject} 笔其他应收科目`;
}
const flowState = $("#workspaceConfirmState");
if (flowState) flowState.textContent = total ? `待处理 ${total}` : "已完成";
const flowMeta = $("#workspaceConfirmMeta");
if (flowMeta) flowMeta.textContent = total ? `单边流水 ${pendingMatch} 笔 · 科目确认 ${pendingSubject}` : "已全部确认,等待集团结账";
refreshWorkspacePending();
if (noticeTitle) {
noticeTitle.textContent = pending
? `${pending} 项单边流水待确认,是结账阻断项`
: "单边流水已全部确认完成";
}
if (noticeBody) {
noticeBody.textContent = pending
? `工作台「去确认单边流水」与「本月待办」均读取同一权威集合(${pending} 笔)。确认成功后立即同步减一。`
: "本公司单边流水待办已清空;刷新或重新登录后仍为 0。";
}
if (notice) {
notice.classList.toggle("warn", pending > 0);
notice.classList.toggle("success", pending === 0);
}
matchCards.forEach((card) => {
const confirmButton = $("[data-match-confirm]", card);
const radios = $$('input[type="radio"]', card);
radios.forEach((radio) => radio.addEventListener("change", () => { if (confirmButton) confirmButton.disabled = false; }));
confirmButton?.addEventListener("click", () => {
const summaryText = confirmButton.dataset.matchSummary || "已确认匹配";
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
if (badge) {
badge.textContent = pending;
badge.style.display = pending ? "" : "none";
}
}
async function loadCompanyWorkspace() {
const response = await fetch("/api/company/workspace").catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return null;
}
const result = await response?.json().catch(() => null);
if (!response?.ok || !result || result.status !== "ok") {
applyCompanyWorkspace({ pending_unilateral: 0, pending_total: 0, unilateral_events: [] });
showToast("工作台待办读取失败", result?.message || "请稍后重试", "danger");
return null;
}
applyCompanyWorkspace(result);
return result;
}
function renderUnilateralMatchCard(event, peers) {
const card = document.createElement("div");
card.className = "card";
card.dataset.matchCard = "true";
card.dataset.eventId = String(event.event_id);
card.dataset.revision = String(event.revision ?? "");
const amountText = formatWorkspaceAmount(event.amount, event.currency);
const peerName = event.counterparty_company_name || "对方待指定";
const options = peers
.filter((p) => Number(p.id) !== Number(event.own_company_id))
.map((p) => {
const selected = Number(p.id) === Number(event.counterparty_company_id) ? " selected" : "";
return `<option value="${p.id}"${selected}>${p.name}</option>`;
})
.join("");
card.innerHTML = `
<div class="card-head">
<span class="card-title">${event.effective_at || "—"} · <span class="num">${amountText}</span>
<span class="sub">对方:${peerName}</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>事件编号</dt><dd class="num">${event.event_id}</dd>
<dt>状态</dt><dd>${event.status || event.classification || "待确认"}</dd>
<dt>金额</dt><dd class="num">${amountText}</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>确认对方公司</label>
<select class="select" data-counterparty-select>
<option value="">请选择对方公司</option>
${options}
</select>
<span class="hint">确认后按现有单边确认规则锁定对方参与方;工作台数字以服务端权威状态重拉。</span>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled>确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>`;
const select = $("[data-counterparty-select]", card);
const button = $("[data-match-confirm]", card);
const syncEnabled = () => {
if (button) button.disabled = !select?.value;
};
select?.addEventListener("change", syncEnabled);
syncEnabled();
button?.addEventListener("click", async () => {
if (!select?.value || button.dataset.busy === "1") return;
button.dataset.busy = "1";
button.disabled = true;
const requestKey = `company-confirm-${event.event_id}-${event.revision}-${select.value}`;
const response = await fetch(`/api/company/transfer-events/${event.event_id}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
expected_revision: event.revision,
request_key: requestKey,
counterparty_company_id: Number(select.value),
reason: "公司端确认单边流水",
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
button.dataset.busy = "0";
syncEnabled();
showToast("确认失败", result?.message || "请刷新后重试", "danger");
// 失败不得本地误减:重拉权威状态
await loadCompanyWorkspace();
await renderReconcileMatchStack();
return;
}
const matchedList = $("#matched-list");
const matchedSummary = $("#matched-summary");
if (matchedList) {
const row = document.createElement("div");
row.className = "list-row";
const main = document.createElement("div");
main.className = "lr-main";
const title = document.createElement("div");
title.className = "lr-title";
title.textContent = summaryText;
main.append(title);
const pill = document.createElement("span");
pill.className = "pill pill-success lr-side";
pill.textContent = "已匹配";
row.append(main, pill);
row.innerHTML = `<div class="lr-main"><div class="lr-title">${amountText} · 已确认对方 ${select.selectedOptions[0]?.textContent || ""}</div></div><span class="pill pill-success lr-side">已匹配</span>`;
matchedList.append(row);
matchedSummary.style.display = "";
card.remove();
pendingMatch -= 1;
if (pendingMatch === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果将同步至集团审核中心复核</div>';
matchStack.append(empty);
}
refreshCounts();
showToast("匹配已确认", "待办状态、操作人、时间和依据已同步更新", "success");
});
if (matchedSummary) matchedSummary.style.display = "";
}
if (result.workspace) applyCompanyWorkspace(result.workspace);
else await loadCompanyWorkspace();
await renderReconcileMatchStack();
showToast("匹配已确认", "工作台待办已按权威状态同步", "success");
});
return card;
}
async function loadCompanyPeerOptions() {
if (Array.isArray(state.companyPeers) && state.companyPeers.length) return state.companyPeers;
const response = await fetch("/api/company/companies").catch(() => null);
const result = await response?.json().catch(() => null);
const peers = Array.isArray(result?.companies) ? result.companies : [];
state.companyPeers = peers;
return peers;
}
async function renderReconcileMatchStack() {
const stack = $("#match-stack");
if (!stack) return;
const events = state.workspace?.unilateral_events || [];
const peers = await loadCompanyPeerOptions();
stack.replaceChildren();
if (!events.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果已同步至工作台权威待办</div>';
stack.append(empty);
return;
}
events.forEach((event) => stack.append(renderUnilateralMatchCard(event, peers)));
}
function initReconcile() {
const subjectRows = $$("[data-subject-row]");
let pendingSubject = subjectRows.length;
const countSubject = $("#count-subject");
if (countSubject) countSubject.textContent = pendingSubject;
$$(".subject-confirm-btn").forEach((button) => {
button.addEventListener("click", () => {
@@ -2559,8 +2811,8 @@ function initReconcile() {
const statusCell = $(".subject-status", row);
statusCell.innerHTML = `<span class="pill pill-success">已确认 · ${subject}</span>`;
pendingSubject -= 1;
refreshCounts();
showToast("科目已确认", "待办状态、操作人、时间和依据已同步更新", "success");
if (countSubject) countSubject.textContent = pendingSubject;
showToast("科目已确认", "科目确认仍为页面演示,不计入工作台权威待办", "success");
});
});
@@ -2578,8 +2830,6 @@ function initReconcile() {
}
tabMatch?.addEventListener("click", () => switchTab("match"));
tabSubject?.addEventListener("click", () => switchTab("subject"));
refreshCounts();
}
function initNotifications() {
@@ -2821,8 +3071,12 @@ function initCompany() {
showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success");
});
// ── 往来确认 ──
// ── 往来确认 + 工作台权威待办 ──
initReconcile();
(async () => {
await loadCompanyWorkspace();
await renderReconcileMatchStack();
})();
// ── 通知 ──
initNotifications();
+14 -157
View File
@@ -57,13 +57,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 +79,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>
@@ -99,43 +99,12 @@
<div class="stack">
<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>
@@ -463,130 +432,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">
@@ -957,6 +814,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=6"></script>
<script src="app.js?v=11"></script>
</body>
</html>
+234 -14
View File
@@ -216,23 +216,179 @@ p { margin: 0; }
/* 管理端首页主从:左栏约 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;
height: 492px;
/* 少数据自适应;整卡(含共享头)目标约 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-master-head {
padding: 10px 14px 8px;
margin-bottom: 0;
gap: 8px;
.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;
@@ -241,6 +397,32 @@ p { margin: 0; }
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;
}
@@ -248,18 +430,16 @@ p { margin: 0; }
.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);
}
.dash-company-name {
white-space: nowrap;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
}
.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; }
@@ -271,15 +451,55 @@ 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-480-1 { grid-template-columns: minmax(0, 1fr); }
.dash-master-split { height: auto; max-height: none; }
.dash-master-pane--list { border-right: 0; border-bottom: 1px solid var(--border); }
.dash-master-pane { max-height: 420px; }
.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);
}
}
/* ─── 数据表 ────────────────────────────────────────────────────── */