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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 03:32:47 +00:00
leeferandmultica-agent ece3e53472 HEL-144 返工A:四件事(改名/登录端标识/结账设置真保存/提醒回改)+ 迁移升为 7 + 移除废弃 B-44 前端样式测试
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 18:41:01 +08:00
leeferandmultica-agent 5816e8aa71 baseline: 测试环境现行树(b44 后端 + main web)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 18:35:41 +08:00
34 changed files with 7895 additions and 3911 deletions
+7
View File
@@ -19,3 +19,10 @@ nul
.agent_context/
.kimi/
vendor_pkgs/
node_modules/
package-lock.json
# local vendor for agent test env (not shipped)
.vendor/
vendor_wheels/
+2
View File
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
内网测试环境可设 `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()
+396 -11
View File
@@ -11,8 +11,8 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, importing, ledger_events, manual_records, master_data, matching,
multipart, personal_transit, positions, subjects,
auth, dashboard, importing, ledger_events, manual_records, master_data, matching,
multipart, personal_transit, positions, settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -80,12 +80,28 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
if path == "/api/admin/settings":
self._handle_admin_settings()
return
if path == "/api/admin/reminders":
self._handle_admin_reminders(query)
return
if path == "/api/admin/reminders/pending":
self._handle_admin_reminder_pending(query)
return
if path == "/api/admin/transfer-events":
self._handle_admin_transfer_events(query)
return
if path == "/api/admin/match-exceptions":
self._handle_admin_match_exceptions(query)
return
if path == "/api/admin/dashboard":
self._handle_admin_dashboard(query)
return
company_dash = re.fullmatch(r"/api/admin/dashboard/companies/(\d+)", path)
if company_dash:
self._handle_admin_dashboard_company(int(company_dash.group(1)), query)
return
if path == "/api/admin/personal-transit-mappings":
self._handle_admin_personal_mappings(query)
return
@@ -99,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)))
@@ -232,6 +251,12 @@ class AppHandler(SimpleHTTPRequestHandler):
if mapping_review:
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
return
if path == "/api/admin/settings":
self._handle_admin_update_settings()
return
if path == "/api/admin/reminders/send":
self._handle_admin_send_reminders()
return
# B-44 intercompany positions (admin writes)
subject_decision = re.fullmatch(
@@ -255,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": "接口不存在。"})
# ------------------------------------------------------------------
@@ -297,7 +328,7 @@ class AppHandler(SimpleHTTPRequestHandler):
if user is None:
return None
if user["role"] != "admin":
self._send_json(403, {"status": "error", "message": "该操作仅限总账管理员。"})
self._send_json(403, {"status": "error", "message": "该操作仅限管理员。"})
return None
return user
@@ -354,7 +385,7 @@ class AppHandler(SimpleHTTPRequestHandler):
return
if reason == "disabled":
self._send_json(
403, {"status": "error", "message": "账号已停用,请联系总账管理员。"}
403, {"status": "error", "message": "账号已停用,请联系管理员。"}
)
return
if user is None:
@@ -1404,6 +1435,204 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
# ------------------------------------------------------------------
# System settings (admin read/write, persisted + audited)
# ------------------------------------------------------------------
def _handle_admin_settings(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
values = settings.get_settings(connection)
self._send_json(200, {"status": "ok", "settings": values})
finally:
connection.close()
def _handle_admin_update_settings(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
before = settings.get_settings(connection)
try:
updated = settings.update_settings(connection, data, user)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
changed = {
key: updated[key]
for key in updated
if before.get(key) != updated[key]
}
auth.audit(
connection,
"settings_update",
actor=user,
target="system_settings",
detail=";".join(
f"{key}:{before.get(key)}->{updated[key]}" for key in changed
),
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "settings": updated})
finally:
connection.close()
# ------------------------------------------------------------------
# Reminder management (admin)
# ------------------------------------------------------------------
@staticmethod
def _reminder_payload(row) -> dict[str, object]:
payload = {
"id": row["id"],
"company_id": row["company_id"],
"company_name": row["company_name"],
"kind": row["kind"],
"content": row["content"],
"deadline": row["deadline"],
"source": row["source"],
"status": row["status"],
"actor_username": row["actor_username"],
"created_at": row["created_at"],
}
return payload
def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
conditions: list[str] = []
params: list[object] = []
raw_company = (query.get("company_id") or [None])[0]
if raw_company:
try:
params.append(int(raw_company))
except ValueError:
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
conditions.append("r.company_id = ?")
raw_limit = (query.get("limit") or ["200"])[0]
try:
limit = max(1, min(int(raw_limit), 500))
except ValueError:
limit = 200
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
rows = connection.execute(
f"""
SELECT r.id, r.company_id, c.name AS company_name, r.kind,
r.content, r.deadline, r.source, r.status,
r.actor_username, r.created_at
FROM reminders r
JOIN companies c ON c.id = r.company_id
{where}
ORDER BY r.id DESC
LIMIT ?
""",
(*params, limit),
).fetchall()
self._send_json(
200,
{"status": "ok",
"reminders": [self._reminder_payload(row) for row in rows]},
)
finally:
connection.close()
def _handle_admin_reminder_pending(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
raw_company = (query.get("company_id") or [None])[0]
try:
company_id = int(raw_company)
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
items = settings.pending_items(connection, company_id)
self._send_json(
200,
{
"status": "ok",
"company_id": company_id,
"company_name": company["name"],
"items": items,
},
)
finally:
connection.close()
def _handle_admin_send_reminders(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_id = int(str(data.get("company_id")))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
try:
created, deadline = settings.send_reminders(
connection, company_id, user
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
if not created:
self._send_json(
400,
{"status": "error",
"message": "该公司当前没有待提醒事项,无需发送。"},
)
return
auth.audit(
connection,
"reminder_send",
actor=user,
target=f"company:{company_id}",
detail=f"items:{len(created)}",
ip=self._client_ip,
)
self._send_json(
200,
{
"status": "ok",
"company_id": company_id,
"company_name": company["name"],
"deadline": deadline,
"reminders": created,
},
)
finally:
connection.close()
# ------------------------------------------------------------------
# Canonical transfer events (admin)
# ------------------------------------------------------------------
@@ -1653,6 +1882,49 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
def _handle_admin_dashboard(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
cutoff = (query.get("cutoff") or [None])[0] or None
try:
payload = dashboard.build_dashboard(
connection, from_date=from_date, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_dashboard_company(
self, company_id: int, query: dict[str, list[str]]
) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
cutoff = (query.get("cutoff") or [dashboard.today_shanghai()])[0]
try:
payload = dashboard.company_peer_groups(
connection, company_id, from_date=from_date, cutoff=cutoff
)
except KeyError:
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_reconcile(self) -> None:
connection = connect(DB_PATH)
try:
@@ -1932,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),
@@ -1946,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:
@@ -1955,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
]
@@ -1969,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,)
+356
View File
@@ -0,0 +1,356 @@
"""Admin dashboard aggregates over existing B-43 eligible events and review queues.
No fabricated demo amounts. Opening balances are unavailable until B-45; peer
group ``opening`` is always null and ``ending`` equals period net change.
Period closing status is unknown without the monthly-close module — returned
as null so the UI shows an em dash rather than a guessed label.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
import sqlite3
WAN = Decimal("10000")
ZERO = Decimal("0")
TWOPLACES = Decimal("0.01")
def today_shanghai() -> str:
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
def _q2(value: Decimal) -> str:
return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP))
def _to_wan(amount: Decimal) -> str:
return _q2(amount / WAN)
def _parse_day(iso_ts: str | None) -> str | None:
if not iso_ts:
return None
text = str(iso_ts)
return text[:10] if len(text) >= 10 else None
def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
"""Derive pending review counts from real queues only.
Priority mapping (aligned with current admin audit UI semantics):
- high: unresolved / needs_review match exceptions
- medium: pending bank-account registrations + pending manual records
- low: reserved for future low-risk queues (currently always 0)
Homepage card, sidebar badge and audit-center heading must all read this
same payload (via ``/api/admin/dashboard`` → ``audit``).
"""
high = connection.execute(
"""
SELECT COUNT(*) AS n
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN canonical_transfer_events e ON e.id = c.event_id
WHERE e.lifecycle = 'active'
AND d.classification IN ('unresolved', 'needs_review')
"""
).fetchone()["n"]
medium_accounts = connection.execute(
"SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'"
).fetchone()["n"]
medium_manuals = connection.execute(
"""
SELECT COUNT(*) AS n
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE d.state = 'pending'
"""
).fetchone()["n"]
medium = int(medium_accounts) + int(medium_manuals)
low = 0
return {
"total": int(high) + int(medium) + int(low),
"high": int(high),
"medium": int(medium),
"low": int(low),
}
def _load_eligible(
connection: sqlite3.Connection, *, from_date: str, cutoff: str
) -> list[sqlite3.Row]:
return connection.execute(
"""
SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency,
e.payer_company_id, e.payee_company_id, e.pairing,
cpayer.name AS payer_name, cpayee.name AS payee_name
FROM eligible_intercompany_events e
JOIN companies cpayer ON cpayer.id = e.payer_company_id
JOIN companies cpayee ON cpayee.id = e.payee_company_id
WHERE date(e.effective_at) >= date(?)
AND date(e.effective_at) <= date(?)
ORDER BY e.effective_at, e.event_id
""",
(from_date, cutoff),
).fetchall()
def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
return connection.execute(
"""
SELECT id, name FROM companies
WHERE status != 'disabled'
ORDER BY id
"""
).fetchall()
def company_summaries(
connection: sqlite3.Connection, *, from_date: str, cutoff: str
) -> tuple[list[dict[str, object]], dict[str, object]]:
companies = _company_rows(connection)
events = _load_eligible(connection, from_date=from_date, cutoff=cutoff)
debit_total = ZERO
credit_total = ZERO
by_id: dict[int, dict[str, object]] = {}
for company in companies:
by_id[int(company["id"])] = {
"id": int(company["id"]),
"name": company["name"],
"detail_count": 0,
"debit": ZERO,
"credit": ZERO,
"period_status": None,
"period_status_label": "",
}
for event in events:
amount = Decimal(str(event["amount"]))
debit_total += amount
credit_total += amount
payer_id = int(event["payer_company_id"])
payee_id = int(event["payee_company_id"])
if payer_id in by_id:
row = by_id[payer_id]
row["detail_count"] = int(row["detail_count"]) + 1
row["debit"] = Decimal(row["debit"]) + amount
if payee_id in by_id:
row = by_id[payee_id]
row["detail_count"] = int(row["detail_count"]) + 1
row["credit"] = Decimal(row["credit"]) + amount
items: list[dict[str, object]] = []
for company in companies:
row = by_id[int(company["id"])]
debit = Decimal(row["debit"])
credit = Decimal(row["credit"])
# Payee inflow payer outflow = signed net from this company's view.
net = credit - debit
items.append(
{
"id": row["id"],
"name": row["name"],
"detail_count": row["detail_count"],
"debit_wan": _to_wan(debit),
"credit_wan": _to_wan(credit),
"net_wan": _to_wan(net),
"period_status": row["period_status"],
"period_status_label": row["period_status_label"],
}
)
totals = {
"company_count": len(items),
"detail_count": len(events),
"debit_wan": _to_wan(debit_total),
"credit_wan": _to_wan(credit_total),
"net_wan": _to_wan(debit_total - credit_total),
}
return items, totals
def company_peer_groups(
connection: sqlite3.Connection,
company_id: int,
*,
from_date: str,
cutoff: str,
) -> dict[str, object]:
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
raise KeyError(company_id)
events = connection.execute(
"""
SELECT e.event_id, e.effective_at, e.amount, e.currency,
e.payer_company_id, e.payee_company_id,
cpayer.name AS payer_name, cpayee.name AS payee_name
FROM eligible_intercompany_events e
JOIN companies cpayer ON cpayer.id = e.payer_company_id
JOIN companies cpayee ON cpayee.id = e.payee_company_id
WHERE (e.payer_company_id = ? OR e.payee_company_id = ?)
AND date(e.effective_at) >= date(?)
AND date(e.effective_at) <= date(?)
ORDER BY e.effective_at, e.event_id
""",
(company_id, company_id, from_date, cutoff),
).fetchall()
groups: dict[int, dict[str, object]] = {}
for event in events:
amount = Decimal(str(event["amount"]))
payer_id = int(event["payer_company_id"])
payee_id = int(event["payee_company_id"])
if payer_id == company_id:
peer_id = payee_id
peer_name = event["payee_name"]
direction = "debit"
summary = f"付往 {peer_name}"
else:
peer_id = payer_id
peer_name = event["payer_name"]
direction = "credit"
summary = f"收自 {peer_name}"
bucket = groups.get(peer_id)
if bucket is None:
bucket = {
"peer_id": peer_id,
"peer_name": peer_name,
"count": 0,
"opening": None,
"opening_status": "unavailable",
"debit": ZERO,
"credit": ZERO,
"lines": [],
}
groups[peer_id] = bucket
bucket["count"] = int(bucket["count"]) + 1
if direction == "debit":
bucket["debit"] = Decimal(bucket["debit"]) + amount
else:
bucket["credit"] = Decimal(bucket["credit"]) + amount
day = _parse_day(event["effective_at"]) or ""
bucket["lines"].append(
{
"event_id": int(event["event_id"]),
"date": day,
"direction": direction,
"summary": summary,
"amount_wan": _to_wan(amount),
"currency": event["currency"] or "CNY",
}
)
result_groups: list[dict[str, object]] = []
for peer_id in sorted(groups.keys(), key=lambda i: groups[i]["peer_name"]):
bucket = groups[peer_id]
debit = Decimal(bucket["debit"])
credit = Decimal(bucket["credit"])
ending = credit - debit
result_groups.append(
{
"peer_id": bucket["peer_id"],
"peer_name": bucket["peer_name"],
"count": bucket["count"],
"opening": None,
"opening_status": "unavailable",
"debit_wan": _to_wan(debit),
"credit_wan": _to_wan(credit),
"ending_wan": _to_wan(ending),
"result_kind": "period_net_change",
"lines": bucket["lines"],
}
)
return {
"company_id": int(company["id"]),
"company_name": company["name"],
"from_date": from_date,
"cutoff": cutoff,
"groups": result_groups,
}
def weekly_flow(
connection: sqlite3.Connection, *, cutoff: str, days: int = 7
) -> dict[str, object]:
end = datetime.strptime(cutoff, "%Y-%m-%d").date()
start = end - timedelta(days=days - 1)
labels: list[str] = []
inflow = [ZERO] * days
outflow = [ZERO] * days
index: dict[str, int] = {}
for offset in range(days):
day = start + timedelta(days=offset)
key = day.isoformat()
index[key] = offset
labels.append(f"{day.month:02d}-{day.day:02d}")
rows = connection.execute(
"""
SELECT date(e.effective_at) AS day, e.amount
FROM eligible_intercompany_events e
WHERE date(e.effective_at) >= date(?)
AND date(e.effective_at) <= date(?)
""",
(start.isoformat(), cutoff),
).fetchall()
for row in rows:
day = row["day"]
if day not in index:
continue
amount = Decimal(str(row["amount"]))
# Group-level flow: every eligible transfer is both an outflow (payer)
# and an inflow (payee); plot both series with the same absolute amount.
inflow[index[day]] += amount
outflow[index[day]] += amount
return {
"labels": labels,
"inflow_wan": [_to_wan(v) for v in inflow],
"outflow_wan": [_to_wan(v) for v in outflow],
}
def build_dashboard(
connection: sqlite3.Connection,
*,
from_date: str = "2026-01-01",
cutoff: str | None = None,
) -> dict[str, object]:
cutoff_date = cutoff or today_shanghai()
try:
datetime.strptime(from_date, "%Y-%m-%d")
datetime.strptime(cutoff_date, "%Y-%m-%d")
except ValueError as exc:
raise ValueError("日期必须是 YYYY-MM-DD") from exc
if from_date > cutoff_date:
raise ValueError("from 不能晚于 cutoff")
period = datetime.strptime(cutoff_date, "%Y-%m-%d")
# Display month for the status column header: use the calendar month of cutoff.
period_month = period.month
companies, totals = company_summaries(
connection, from_date=from_date, cutoff=cutoff_date
)
return {
"from_date": from_date,
"cutoff": cutoff_date,
"period_month": period_month,
"period_label": f"{period.year}-{period.month:02d}",
"audit": audit_counts(connection),
"totals": totals,
"companies": companies,
"weekly_flow": weekly_flow(connection, cutoff=cutoff_date),
"opening_status": "unavailable",
}
+48
View File
@@ -798,6 +798,54 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS manual_records;
""",
),
Migration(
version=7,
name="0007_system_settings_and_reminders",
# System settings (closing day, global start date, auto-reminder) are
# persisted as a key/value table with an append-only change history
# (operator + before/after) for the audit requirement. Reminders are a
# separate append-only table so reminder history survives re-sends.
up="""
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_by INTEGER REFERENCES users (id),
updated_at TEXT NOT NULL
);
CREATE TABLE system_setting_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
before_value TEXT,
after_value TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
kind TEXT NOT NULL,
content TEXT NOT NULL,
deadline TEXT,
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
status TEXT NOT NULL DEFAULT 'unread'
CHECK (status IN ('unread', 'done')),
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
down="""
DROP INDEX IF EXISTS idx_reminders_company;
DROP TABLE IF EXISTS reminders;
DROP TABLE IF EXISTS system_setting_changes;
DROP TABLE IF EXISTS system_settings;
""",
),
)
+1 -1
View File
@@ -262,7 +262,7 @@ def submit_bank_account(
if existing["company_id"] == company_id:
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
def review_bank_account(
+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
# ---------------------------------------------------------------------------
+309
View File
@@ -0,0 +1,309 @@
"""System settings and reminder item generation.
System-wide parameters (closing day, global start date, auto-reminder toggle
and lead days) are persisted in ``system_settings`` with an append-only
``system_setting_changes`` trail. Reminder pending items are derived from real
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
rather than hard-coded rosters.
"""
from __future__ import annotations
import re
import sqlite3
from datetime import datetime, timezone
from .db import utc_now
# Defaults are applied when a key is absent; the value type is always string.
DEFAULT_SETTINGS: dict[str, str] = {
"closing_day": "5",
"start_date": "2026-01-01",
"auto_remind": "1",
"remind_days": "3",
}
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _valid_date(value: str) -> bool:
if not _DATE_RE.match(value):
return False
try:
datetime.strptime(value, "%Y-%m-%d")
except ValueError:
return False
return True
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
settings = dict(DEFAULT_SETTINGS)
rows = connection.execute(
"SELECT key, value FROM system_settings"
).fetchall()
for row in rows:
settings[row["key"]] = row["value"]
return settings
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
cleaned: dict[str, str] = {}
if "closing_day" in values:
raw = str(values["closing_day"]).strip()
try:
day = int(raw)
except ValueError:
return cleaned, "每月结账日须为 1-28 之间的整数。"
if day < 1 or day > 28:
return cleaned, "每月结账日须为 1-28 之间的整数。"
cleaned["closing_day"] = str(day)
if "start_date" in values:
raw = str(values["start_date"]).strip()
if not _valid_date(raw):
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
cleaned["start_date"] = raw
if "auto_remind" in values:
raw = str(values["auto_remind"]).strip()
if raw not in {"0", "1"}:
return cleaned, "自动提醒开关须为 0 或 1。"
cleaned["auto_remind"] = raw
if "remind_days" in values:
raw = str(values["remind_days"]).strip()
try:
days = int(raw)
except ValueError:
return cleaned, "提前提醒天数须为不小于 1 的整数。"
if days < 1 or days > 30:
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
cleaned["remind_days"] = str(days)
return cleaned, None
def update_settings(
connection: sqlite3.Connection,
values: dict[str, object],
actor: sqlite3.Row,
) -> dict[str, str]:
cleaned, error = validate_settings(values)
if error is not None:
raise ValueError(error)
if not cleaned:
raise ValueError("没有需要保存的设置项。")
current = get_settings(connection)
with connection:
for key, new_value in cleaned.items():
old_value = current.get(key)
if old_value == new_value:
continue
connection.execute(
"""
INSERT INTO system_settings (key, value, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_by = excluded.updated_by,
updated_at = excluded.updated_at
""",
(key, new_value, actor["id"], utc_now()),
)
connection.execute(
"""
INSERT INTO system_setting_changes (
key, before_value, after_value,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
key,
old_value,
new_value,
actor["id"],
actor["username"],
utc_now(),
),
)
return get_settings(connection)
# ----------------------------------------------------------------------
# Reminder pending items
# ----------------------------------------------------------------------
def _current_period() -> tuple[str, str]:
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
today = datetime.now(timezone.utc)
start = today.strftime("%Y-%m-01")
year, month = today.year, today.month
if month == 12:
end = f"{year + 1}-01-01"
else:
end = f"{year}-{month + 1:02d}-01"
return start, end
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
"""Derive the list of pending reminder items for one company."""
items: list[dict[str, str]] = []
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
return items
period_start, _ = _current_period()
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
active_accounts = connection.execute(
"""
SELECT id, account_number FROM bank_accounts
WHERE company_id = ? AND status = 'active'
""",
(company_id,),
).fetchall()
confirmed_this_period = connection.execute(
"""
SELECT COUNT(*) AS n
FROM sheet_reviews rv
JOIN import_batches b ON b.id = rv.import_batch_id
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
AND rv.sheet_batch_id IN (
SELECT id FROM sheet_batches s
WHERE s.period_end >= ?
)
""",
(company_id, period_start),
).fetchone()["n"]
if active_accounts and confirmed_this_period == 0:
items.append(
{
"kind": "流水未提交",
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
}
)
# 2) 待确认工作表:仍有未确认的解析结果。
pending_sheets = connection.execute(
"""
SELECT COUNT(*) AS n
FROM sheet_reviews rv
JOIN import_batches b ON b.id = rv.import_batch_id
WHERE b.company_id = ? AND rv.review_status = 'pending'
""",
(company_id,),
).fetchone()["n"]
if pending_sheets:
items.append(
{
"kind": "待确认工作表",
"content": f"{pending_sheets} 个导入工作表尚未确认,请核对后确认。",
}
)
# 3) 待审核账户登记:处于待复核状态的银行账户。
pending_accounts = connection.execute(
"""
SELECT COUNT(*) AS n FROM bank_accounts
WHERE company_id = ? AND status = 'pending'
""",
(company_id,),
).fetchone()["n"]
if pending_accounts:
items.append(
{
"kind": "账户登记",
"content": f"{pending_accounts} 个银行账户登记待审核。",
}
)
# 4) 待确认往来事项:尚未解决的往来匹配。
pending_transfers = connection.execute(
"""
SELECT COUNT(*) AS n
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants p
ON p.decision_id = d.id AND p.company_id = ?
WHERE d.classification IN ('unresolved', 'needs_review')
""",
(company_id,),
).fetchone()["n"]
if pending_transfers:
items.append(
{
"kind": "往来待确认",
"content": f"{pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
}
)
return items
def send_reminders(
connection: sqlite3.Connection,
company_id: int,
actor: sqlite3.Row,
) -> tuple[list[dict[str, str]], str | None]:
"""Create reminder rows for a company's pending items.
Returns ``(created, deadline)``. ``created`` is the list of persisted
reminder payloads; ``deadline`` is derived from the closing-day setting.
"""
items = pending_items(connection, company_id)
if not items:
return [], None
settings = get_settings(connection)
try:
closing_day = int(settings["closing_day"])
except ValueError:
closing_day = 5
today = datetime.now(timezone.utc)
# Deadline: next month's closing day (current month if today is before it).
if today.day < closing_day:
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
else:
year, month = today.year, today.month
if month == 12:
year, month = year + 1, 1
else:
month += 1
deadline = f"{year}-{month:02d}-{closing_day:02d}"
created: list[dict[str, str]] = []
with connection:
for item in items:
cursor = connection.execute(
"""
INSERT INTO reminders (
company_id, kind, content, deadline, source,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
""",
(
company_id,
item["kind"],
item["content"],
deadline,
actor["id"],
actor["username"],
utc_now(),
),
)
created.append(
{
"id": cursor.lastrowid,
"company_id": company_id,
"kind": item["kind"],
"content": item["content"],
"deadline": deadline,
"source": "manual",
"status": "unread",
"actor_username": actor["username"],
"created_at": utc_now(),
}
)
return created, deadline
-86
View File
@@ -1,86 +0,0 @@
"use strict";
const path = require("path");
const assert = require("assert");
const ui = require(path.join(__dirname, "..", "web", "app.js"));
assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
assert.strictEqual(ui.cycleTab(0, 4, false), 1);
assert.strictEqual(ui.cycleTab(3, 4, false), 0);
assert.strictEqual(ui.cycleTab(0, 4, true), 3);
assert.strictEqual(ui.cycleTab(2, 5, true), 1);
assert.strictEqual(ui.cycleTab(0, 0, false), 0);
assert.strictEqual(ui.drawerEscAction(1), "close");
assert.strictEqual(ui.drawerEscAction(0), "close");
assert.strictEqual(ui.drawerEscAction(2), "back");
assert.strictEqual(ui.drawerEscAction(3), "back");
const abs = ui.amountWithCurrency(1280, "CNY");
assert.ok(abs.includes("CNY"));
assert.ok(abs.includes("1,280.00"));
assert.ok(!abs.includes("+"));
assert.ok(!abs.includes(""));
const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
assert.ok(repay.includes(""));
assert.ok(!repay.includes("+"));
assert.ok(repay.includes("3,200,000.00"));
assert.strictEqual(ui.resultDirection(10).label, "应收");
assert.strictEqual(ui.resultDirection(-10).label, "应付");
assert.strictEqual(ui.resultDirection(0).label, "持平");
assert.strictEqual(ui.isCompactAmount(1280), false);
assert.strictEqual(ui.isCompactAmount(999999999), false);
assert.strictEqual(ui.isCompactAmount(1000000000), true);
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
const subjectFields = ui.auditEvidenceFields("subject-review", {
direction: "outgoing",
effectiveAt: "2026-07-18T09:00:00",
amount: "600",
currency: "CNY",
summary: "资金调拨",
});
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
assert.strictEqual(subjectFields[0][1], "转出");
const manualFields = ui.auditEvidenceFields("manual-review", {
counterpartyName: "乙公司",
direction: "incoming",
relatedSourceRowId: "",
submittedBy: "出纳甲",
attachmentName: "",
amount: "80",
currency: "CNY",
summary: "补记",
});
assert.deepStrictEqual(
manualFields.map((item) => item[0]),
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
);
assert.strictEqual(manualFields[0][1], "乙公司");
assert.strictEqual(manualFields[1][1], "转入");
assert.strictEqual(manualFields[2][1], "无关联流水");
assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
console.log("b44_ui_check ok");
-138
View File
@@ -1,138 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>B-44 余额目录布局回归</title>
<link rel="stylesheet" href="../../web/styles.css" />
</head>
<body data-portal="admin">
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
<nav class="nav-list">
<button class="nav-item is-active" type="button"><span>往来查询</span></button>
</nav>
</aside>
<div class="workspace">
<main id="main-content">
<section class="app-view is-active">
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
<section class="panel company-ledger-panel" aria-label="公司余额目录">
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
<div class="company-ledgers">
<details class="company-ledger is-balances">
<summary>
<span class="company-name" title="甲公司"><i></i><b>甲公司</b><small class="currency-tag">CNY</small></span>
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
<span class="ledger-cutoff">截止 2026.07.31</span>
<svg></svg>
</summary>
</details>
<details class="company-ledger is-balances" id="stressRow">
<summary>
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i></i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
<span class="ledger-cutoff">截止 2026.07.31</span>
<svg></svg>
</summary>
</details>
</div>
</section>
</section>
</main>
</div>
</div>
<aside class="drawer is-open" id="evidenceDrawer">
<header class="drawer-header">
<div>
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
<h2>甲公司 ↔ 乙公司</h2>
</div>
<button type="button" class="icon-button" aria-label="关闭">×</button>
</header>
<div class="drawer-body">
<div class="pair-balance-line is-six" id="drawerSix">
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
</div>
</div>
</aside>
<pre id="b175-metrics" hidden></pre>
<script>
function box(el) {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
}
function overlap(a, b) {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(Math.min(dx, dy) === dy ? dx : dx);
}
function clipped(el) {
return el.scrollWidth > el.clientWidth + 1;
}
function measure() {
const row = document.querySelector("#stressRow");
const summary = row.querySelector("summary");
const amount = row.querySelector(".ledger-result .amount-with-currency");
const direction = row.querySelector(".ledger-result .status");
const unresolved = row.querySelector(".ledger-unresolved .status");
const cutoff = row.querySelector(".ledger-cutoff");
const chevron = row.querySelector("summary > svg");
const head = document.querySelector(".ledger-head.is-balances");
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
const amountBox = box(amount);
const unresolvedBox = box(unresolved);
const cutoffBox = box(cutoff);
const chevronBox = box(chevron);
const directionBox = box(direction);
const metrics = {
viewport: window.innerWidth,
clientWidth: document.documentElement.clientWidth,
fiveColVisible: getComputedStyle(head).display !== "none",
amountText: amount.textContent.replace(/\s+/g, " ").trim(),
unresolvedText: unresolved.textContent.replace(/\s+/g, " ").trim(),
cutoffText: cutoff.textContent.replace(/\s+/g, " ").trim(),
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
amountCutoffOverlap: overlap(amountBox, cutoffBox),
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
amountClipped: clipped(amount),
unresolvedClipped: clipped(unresolved),
cutoffClipped: clipped(cutoff),
amountVisible: amountBox.width > 4 && amountBox.height > 4,
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
drawerAmountClipped: clipped(drawerAmount),
summaryWidth: Math.round(box(summary).width),
};
const node = document.getElementById("b175-metrics");
node.hidden = false;
node.textContent = JSON.stringify(metrics);
document.title = "B175 " + node.textContent;
}
window.measureB175 = measure;
if (document.readyState === "complete") {
measure();
} else {
window.addEventListener("load", measure);
}
</script>
</body>
</html>
+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:
-109
View File
@@ -1,109 +0,0 @@
"""Static + Node checks for the B-44 visual rework.
Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
event table, and the exported keyboard/amount helpers.
"""
from __future__ import annotations
from pathlib import Path
import re
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
class B44FrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
cls.company = (WEB / "company.html").read_text(encoding="utf-8")
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
def test_admin_ids_are_unique(self) -> None:
self.assertEqual(1, self.admin.count('id="companyLedgers"'))
self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
self.assertIn('data-view="pair"', self.admin)
self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?<span>往来查询</span>')
self.assertIn("<h1>往来查询</h1>", self.admin)
self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
self.assertIn('id="auditExceptionNote"', self.admin)
def test_company_balance_groups_and_nav(self) -> None:
self.assertIn('id="companyBalanceGroups"', self.company)
self.assertNotIn('id="companyBalanceLine"', self.company)
self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?<span>往来余额</span>')
self.assertIn("<h1>往来余额</h1>", self.company)
def test_css_directory_and_drawer_breakpoints(self) -> None:
self.assertIn("@media (max-width: 375px)", self.css)
self.assertIn("@media (max-width: 1179px)", self.css)
self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css)
self.assertIn(
"minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px",
self.css,
)
self.assertIn("@media (max-width: 767px)", self.css)
self.assertIn(".drawer {", self.css)
self.assertIn("width: 640px", self.css)
self.assertIn(".drawer { width: 480px; }", self.css)
self.assertIn(".drawer { width: 100%; }", self.css)
self.assertIn(".drawer .pair-balance-line.is-six", self.css)
self.assertIn("repeat(3, 1fr)", self.css)
self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
self.assertIn(".company-row.is-balance-counterparty", self.css)
self.assertIn(".ledger-hide-md", self.css)
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
self.assertIn("grid-row: 1 / span 2", self.css)
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css)
def test_js_selectors_and_event_table(self) -> None:
self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
self.assertIn("交易日期", self.js)
self.assertIn("本方账户", self.js)
self.assertIn("对方账户", self.js)
self.assertIn("摘要", self.js)
self.assertIn("匹配状态", self.js)
self.assertIn("function eventTableHead()", self.js)
self.assertIn("colspan=\"8\"", self.js)
self.assertIn("function cycleTab(", self.js)
self.assertIn("function drawerEscAction(", self.js)
self.assertIn("function eventIsNegative(", self.js)
self.assertIn("fmtAbsMoney", self.js)
self.assertIn("is-balance-counterparty", self.js)
self.assertIn("function isCompactAmount(", self.js)
self.assertIn("function auditEvidenceFields(", self.js)
self.assertIn("无关联流水", self.js)
self.assertIn('row.dataset.direction = "outgoing"', self.js)
self.assertIn("row.dataset.counterpartyName", self.js)
self.assertIn("row.dataset.relatedSourceRowId", self.js)
heads = re.search(
r"function eventTableHead\(\) \{\s*return `([^`]+)`",
self.js,
)
self.assertIsNotNone(heads)
markup = heads.group(1)
self.assertNotIn("来源", markup)
for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
self.assertIn(label, markup)
self.assertEqual(8, len(re.findall(r"<th\b", markup)))
def test_node_keyboard_and_amount_helpers(self) -> None:
result = subprocess.run(
["node", str(ROOT / "tests" / "b44_ui_check.js")],
capture_output=True,
text=True,
cwd=str(ROOT),
)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertIn("b44_ui_check ok", result.stdout)
if __name__ == "__main__":
unittest.main()
-530
View File
@@ -1,530 +0,0 @@
"""Chromium layout regression for the B-44 admin balance directory.
Measures 15-digit period-end amounts against the unresolved chip using
getBoundingClientRect — not static text presence.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import socket
import subprocess
import tempfile
import threading
import time
from urllib.parse import urlparse
from urllib.request import urlopen
import unittest
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
FIXTURE = "/tests/fixtures/b44-balance-directory.html"
CHROME_CANDIDATES = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "Google/Chrome/Application/chrome.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "Microsoft/Edge/Application/msedge.exe",
]
VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440)
def chrome_bin() -> Path:
for path in CHROME_CANDIDATES:
if path.exists():
return path
raise FileNotFoundError("Chrome/Edge not found for layout regression")
class QuietHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, directory=str(ROOT), **kwargs)
def log_message(self, *_args) -> None:
pass
def handle(self) -> None:
try:
super().handle()
except (ConnectionResetError, BrokenPipeError, TimeoutError):
pass
class _Cdp:
def __init__(self, ws_url: str) -> None:
parsed = urlparse(ws_url)
self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10)
self._sock.settimeout(10)
key = base64.b64encode(os.urandom(16)).decode()
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
self._sock.sendall(
(
f"GET {path} HTTP/1.1\r\n"
f"Host: {parsed.netloc}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
).encode()
)
header = b""
while b"\r\n\r\n" not in header:
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("CDP websocket handshake failed")
header += chunk
expected = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
).decode()
if expected not in header.decode("latin1"):
raise ConnectionError("CDP websocket accept mismatch")
leftover = header.split(b"\r\n\r\n", 1)[1]
self._buf = leftover
self._next_id = 1
def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
msg_id = self._next_id
self._next_id += 1
self._send({"id": msg_id, "method": method, "params": params or {}})
deadline = time.time() + timeout
while time.time() < deadline:
payload = self._recv()
if payload.get("id") == msg_id:
if "error" in payload:
raise RuntimeError(f"{method}: {payload['error']}")
return payload.get("result") or {}
raise TimeoutError(method)
def wait_event(self, name: str, timeout: float = 15) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
payload = self._recv()
if payload.get("method") == name:
return payload.get("params") or {}
raise TimeoutError(name)
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
def _send(self, obj: dict) -> None:
data = json.dumps(obj, separators=(",", ":")).encode()
mask = os.urandom(4)
header = bytearray([0x81])
length = len(data)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(length.to_bytes(2, "big"))
else:
header.append(0x80 | 127)
header.extend(length.to_bytes(8, "big"))
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
self._sock.sendall(header + masked)
def _recv(self) -> dict:
while True:
opcode, payload = self._read_frame()
if opcode == 0x9:
self._send_pong(payload)
continue
if opcode == 0xA:
continue
if opcode == 0x8:
raise ConnectionError("CDP websocket closed")
return json.loads(payload.decode())
def _send_pong(self, payload: bytes) -> None:
mask = os.urandom(4)
header = bytearray([0x8A, 0x80 | len(payload)])
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self._sock.sendall(header + masked)
def _read_frame(self) -> tuple[int, bytes]:
header = self._read_exact(2)
opcode = header[0] & 0x0F
length = header[1] & 0x7F
masked = bool(header[1] & 0x80)
if length == 126:
length = int.from_bytes(self._read_exact(2), "big")
elif length == 127:
length = int.from_bytes(self._read_exact(8), "big")
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
def _read_exact(self, size: int) -> bytes:
while len(self._buf) < size:
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("CDP websocket closed")
self._buf += chunk
data, self._buf = self._buf[:size], self._buf[size:]
return data
class B44LayoutRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.chrome = chrome_bin()
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.http_thread.start()
cls.port = cls.httpd.server_address[1]
cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}"
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True)
user_dir = cls.tmp.name
cls.proc = subprocess.Popen(
[
str(cls.chrome),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--remote-debugging-port=0",
f"--user-data-dir={user_dir}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(user_dir) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(
f"http://127.0.0.1:{cls.debug_port}/json/list",
timeout=5,
).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "httpd", None):
cls.httpd.shutdown()
cls.httpd.server_close()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _metrics(self, width: int) -> dict:
self.cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width,
"height": 1100,
"deviceScaleFactor": 1,
"mobile": False,
})
self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"})
deadline = time.time() + 10
last = None
while time.time() < deadline:
result = self.cdp.call(
"Runtime.evaluate",
{
"expression": (
"typeof window.measureB175 === 'function' "
"? (window.measureB175(), document.getElementById('b175-metrics').textContent) "
": null"
),
"returnByValue": True,
},
)
last = result.get("result", {}).get("value")
if last:
return json.loads(last)
time.sleep(0.1)
raise TimeoutError(f"layout metrics not ready: {last}")
def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None:
for width in VIEWPORTS:
with self.subTest(width=width):
metrics = self._metrics(width)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertIn("23,456,789,012,345.00", metrics["amountText"])
self.assertIn("未决", metrics["unresolvedText"])
self.assertIn("2026.07.31", metrics["cutoffText"])
self.assertTrue(metrics["amountVisible"])
self.assertTrue(metrics["unresolvedVisible"])
self.assertTrue(metrics["cutoffVisible"])
self.assertFalse(metrics["amountClipped"], metrics)
self.assertFalse(metrics["unresolvedClipped"], metrics)
self.assertFalse(metrics["cutoffClipped"], metrics)
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics)
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics)
self.assertFalse(metrics["drawerAmountClipped"], metrics)
if 376 <= width <= 1179:
self.assertTrue(metrics["fiveColVisible"], metrics)
if 376 <= width <= 900:
self.assertTrue(metrics["stackedResult"], metrics)
if width <= 375:
self.assertFalse(metrics["fiveColVisible"], metrics)
LIVE_MEASURE = r"""
(() => {
const pair = document.querySelector('[data-view="pair"]');
if (pair) pair.click();
const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")];
if (!rows.length) return null;
const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0];
const amount = row.querySelector(".ledger-result .amount-with-currency");
const direction = row.querySelector(".ledger-result .status");
const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved");
const cutoff = row.querySelector(".ledger-cutoff");
const chevron = row.querySelector("summary > svg");
const box = (el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
const overlap = (a, b) => {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx);
};
const clipped = (el) => el.scrollWidth > el.clientWidth + 1;
const amountBox = box(amount);
const unresolvedBox = box(unresolved);
return {
viewport: window.innerWidth,
amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(),
unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(),
cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(),
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
directionUnresolvedOverlap: overlap(box(direction), unresolvedBox),
amountCutoffOverlap: overlap(amountBox, box(cutoff)),
unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)),
cutoffChevronOverlap: overlap(box(cutoff), box(chevron)),
amountClipped: clipped(amount),
unresolvedClipped: clipped(unresolved),
cutoffClipped: clipped(cutoff),
fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none",
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
rowCount: rows.length,
};
})()
"""
COMPANY_MEASURE = r"""
(() => {
const balances = document.querySelector('[data-view="balances"]');
if (balances) balances.click();
const row = document.querySelector(".company-row.is-balance-counterparty");
if (!row) return null;
const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong");
const direction = row.querySelector(".status");
const cutoff = row.querySelector(".company-row-figure small");
const box = (el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
};
const overlap = (a, b) => {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx);
};
return {
viewport: window.innerWidth,
amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(),
directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(),
cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(),
amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0,
visible: Boolean(amount && amount.getBoundingClientRect().width > 4),
};
})()
"""
class LiveAdminDirectoryLayoutTests(unittest.TestCase):
"""Chromium against a real login/API page with B-168-scale 15-digit amounts."""
@classmethod
def setUpClass(cls) -> None:
from test_positions_api import IntercompanyApiTests
cls.api = IntercompanyApiTests("test_admin_balances_directory")
cls.api.setUp()
confirmed = cls.api._fresh_pair("123456789012345.00")
cls.api._confirm(confirmed["ledger_event_id"])
cls.api._fresh_pair("2150.00")
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True)
cls.proc = subprocess.Popen(
[
str(chrome_bin()),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--remote-debugging-port=0",
f"--user-data-dir={cls.tmp.name}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(
f"http://127.0.0.1:{cls.debug_port}/json/list",
timeout=5,
).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
cls.cdp.call("Network.enable")
token = cls.api.admin.cookies["cw_session"]
cls.cdp.call(
"Network.setCookie",
{
"name": "cw_session",
"value": token,
"url": f"http://127.0.0.1:{cls.api.port}/",
},
)
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "api", None):
cls.api.tearDown()
cls.api.doCleanups()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _eval(self, expression: str):
deadline = time.time() + 12
last = None
while time.time() < deadline:
result = self.cdp.call(
"Runtime.evaluate",
{"expression": expression, "returnByValue": True},
)
last = result.get("result", {}).get("value")
if last:
return last
time.sleep(0.15)
raise TimeoutError(f"live page metrics not ready: {last}")
def _open(self, path: str, width: int) -> None:
self.cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width,
"height": 1100,
"deviceScaleFactor": 1,
"mobile": False,
})
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"})
time.sleep(0.4)
def test_live_admin_directory_viewports(self) -> None:
for width in (720, 768, 800, 900, 375, 1024, 1440):
with self.subTest(width=width):
self._open("admin.html", width)
metrics = self._eval(LIVE_MEASURE)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertGreaterEqual(metrics["rowCount"], 1, metrics)
self.assertIn("123,456,789,012,345", metrics["amountText"], metrics)
self.assertIn("未决", metrics["unresolvedText"], metrics)
self.assertIn("2026.07.31", metrics["cutoffText"], metrics)
self.assertFalse(metrics["amountClipped"], metrics)
self.assertFalse(metrics["unresolvedClipped"], metrics)
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
if 376 <= width <= 900:
self.assertTrue(metrics["stackedResult"], metrics)
if width <= 375:
self.assertFalse(metrics["fiveColVisible"], metrics)
def test_live_company_balance_rows(self) -> None:
token = self.api.cashier_a.cookies["cw_session"]
self.cdp.call(
"Network.setCookie",
{
"name": "cw_session",
"value": token,
"url": f"http://127.0.0.1:{self.api.port}/",
},
)
for width in (375, 1440):
with self.subTest(width=width):
self._open("company.html", width)
metrics = self._eval(COMPANY_MEASURE)
self.assertTrue(metrics["visible"], metrics)
self.assertTrue(metrics["amountText"], metrics)
self.assertTrue(metrics["directionText"], metrics)
self.assertIn("截止", metrics["cutoffText"], metrics)
self.assertEqual(0, metrics["amountDirectionOverlap"], metrics)
if __name__ == "__main__":
unittest.main()
+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()
+498
View File
@@ -0,0 +1,498 @@
"""Dashboard aggregate helpers and admin HTTP endpoints (no openpyxl dependency)."""
from __future__ import annotations
import os
from pathlib import Path
import tempfile
import threading
import unittest
from bank_importer import dashboard, master_data
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
class DashboardUnitTests(unittest.TestCase):
def setUp(self) -> None:
self.connection = connect(":memory:")
migrate(self.connection)
def tearDown(self) -> None:
self.connection.close()
def test_empty_dashboard(self) -> None:
payload = dashboard.build_dashboard(
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
)
self.assertEqual(0, payload["audit"]["total"])
self.assertEqual([], payload["companies"])
self.assertEqual(7, len(payload["weekly_flow"]["labels"]))
def test_pending_account_counts_as_medium(self) -> None:
now = master_data.utc_now()
cursor = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
(now, now),
)
company_id = int(cursor.lastrowid)
self.connection.commit()
master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="工行",
account_type="一般户",
account_number="6222020000000001",
start_date="2026-01-01",
actor=None,
)
counts = dashboard.audit_counts(self.connection)
self.assertEqual(1, counts["medium"])
self.assertEqual(0, counts["high"])
self.assertEqual(1, counts["total"])
def test_pending_manual_counts_as_medium(self) -> None:
from bank_importer import auth, manual_records
now = master_data.utc_now()
a = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
(now, now),
).lastrowid
b = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
(now, now),
).lastrowid
self.connection.commit()
auth.create_user(
self.connection, "cashier-a", "CashierPass123", "company", company_id=int(a)
)
actor = self.connection.execute(
"SELECT * FROM users WHERE username = 'cashier-a'"
).fetchone()
manual_records.submit(
self.connection,
company_id=int(a),
counterparty_company_id=int(b),
occurred_at="2026-02-01T09:00:00",
direction="incoming",
amount="100.00",
currency="CNY",
funding_source="other",
requested_subject="receivable",
request_key="hel157-manual-1",
actor=actor,
)
counts = dashboard.audit_counts(self.connection)
self.assertEqual(1, counts["medium"])
self.assertEqual(0, counts["high"])
self.assertEqual(1, counts["total"])
def test_audit_total_equals_queue_sum(self) -> None:
"""Homepage / badge / audit heading must share one backend total."""
now = master_data.utc_now()
a = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
(now, now),
).lastrowid
self.connection.commit()
master_data.submit_bank_account(
self.connection,
company_id=int(a),
bank_name="工行",
account_type="一般户",
account_number="6222020000000002",
start_date="2026-01-01",
actor=None,
)
event_id = self.connection.execute(
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
).lastrowid
decision_id = self.connection.execute(
"""
INSERT INTO transfer_match_decisions (
event_id, revision, effective_at, amount, currency, classification,
pairing, locked, mode, rule_version, created_at
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
'single', 0, 'manual', 'test', ?)
""",
(event_id, now),
).lastrowid
self.connection.execute(
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
(event_id, decision_id),
)
self.connection.commit()
counts = dashboard.audit_counts(self.connection)
payload = dashboard.build_dashboard(
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
)
self.assertEqual(counts, payload["audit"])
self.assertEqual(
counts["total"],
counts["high"] + counts["medium"] + counts["low"],
)
self.assertEqual(1, counts["high"])
self.assertEqual(1, counts["medium"])
self.assertEqual(2, counts["total"])
def test_company_summaries_from_eligible_events(self) -> None:
now = master_data.utc_now()
a = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
(now, now),
).lastrowid
b = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
(now, now),
).lastrowid
event_id = self.connection.execute(
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
).lastrowid
decision_id = self.connection.execute(
"""
INSERT INTO transfer_match_decisions (
event_id, revision, effective_at, amount, currency, classification,
pairing, locked, mode, rule_version, created_at
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'intercompany',
'paired', 0, 'manual', 'test', ?)
""",
(event_id, now),
).lastrowid
self.connection.execute(
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
(event_id, decision_id),
)
self.connection.execute(
"""
INSERT INTO transfer_decision_participants (
decision_id, role, company_id, bank_account_id, resolve_method, created_at
) VALUES (?, 'payer', ?, NULL, 'manual', ?), (?, 'payee', ?, NULL, 'manual', ?)
""",
(decision_id, a, now, decision_id, b, now),
)
self.connection.commit()
items, totals = dashboard.company_summaries(
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
)
self.assertEqual(1, totals["detail_count"])
by_name = {row["name"]: row for row in items}
self.assertEqual(1, by_name["甲公司"]["detail_count"])
self.assertEqual("10.00", by_name["甲公司"]["debit_wan"])
self.assertEqual("-10.00", by_name["甲公司"]["net_wan"])
self.assertEqual("10.00", by_name["乙公司"]["credit_wan"])
self.assertEqual("10.00", by_name["乙公司"]["net_wan"])
detail = dashboard.company_peer_groups(
self.connection, int(a), from_date="2026-01-01", cutoff="2026-08-20"
)
self.assertEqual(1, len(detail["groups"]))
self.assertEqual("乙公司", detail["groups"][0]["peer_name"])
self.assertIsNone(detail["groups"][0]["opening"])
class DashboardApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
assert server.ensure_bootstrap_admin(connection) is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/admin/companies", {"name": "甲公司", "username": "cashier-a"}
)
assert status == 200, data
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def test_dashboard_ok_for_admin(self) -> None:
status, _, raw = self.admin.get(
"/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20"
)
data = as_json(raw)
self.assertEqual(200, status)
self.assertEqual("ok", data["status"])
self.assertIn("audit", data)
self.assertEqual(1, len(data["companies"]))
self.assertEqual("甲公司", data["companies"][0]["name"])
def test_audit_counts_sync_after_account_review(self) -> None:
"""Pending account raises dashboard.audit; approve brings total back down."""
from bank_importer.db import connect as db_connect
from bank_importer import master_data as md
status, _, raw = self.admin.get("/api/admin/companies")
company_id = as_json(raw)["companies"][0]["id"]
connection = db_connect(self.db_path)
try:
account = md.submit_bank_account(
connection,
company_id=company_id,
bank_name="工行",
account_type="一般户",
account_number="6222020000000099",
start_date="2026-01-01",
actor=None,
)
account_id = account["id"]
finally:
connection.close()
status, _, raw = self.admin.get("/api/admin/dashboard")
before = as_json(raw)["audit"]
self.assertEqual(200, status)
self.assertGreaterEqual(before["medium"], 1)
self.assertGreaterEqual(before["total"], 1)
status, _, raw = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "HEL-157 sync test"},
)
self.assertEqual(200, status, raw)
status, _, raw = self.admin.get("/api/admin/dashboard")
after = as_json(raw)["audit"]
self.assertEqual(after["medium"], before["medium"] - 1)
self.assertEqual(after["total"], before["total"] - 1)
self.assertEqual(
after["total"], after["high"] + after["medium"] + after["low"]
)
def test_three_queue_audit_parity_and_dispose_sync(self) -> None:
"""Seed account+manual+exception: list sizes == dashboard.audit; dispose syncs -1."""
from bank_importer.db import connect as db_connect
from bank_importer import auth, manual_records, master_data as md
status, _, raw = self.admin.get("/api/admin/companies")
company_id = as_json(raw)["companies"][0]["id"]
# Second company for manual counterparty.
status, _, raw = self.admin.post_json(
"/api/admin/companies", {"name": "乙公司", "username": "cashier-b-hel157"}
)
self.assertEqual(200, status, raw)
company_b = as_json(raw).get("company_id") or as_json(raw).get("id")
self.assertIsNotNone(company_b)
connection = db_connect(self.db_path)
try:
account = md.submit_bank_account(
connection,
company_id=company_id,
bank_name="工行",
account_type="一般户",
account_number="6222020000000157",
start_date="2026-01-01",
actor=None,
)
account_id = account["id"]
cashier = connection.execute(
"SELECT * FROM users WHERE username = 'cashier-a'"
).fetchone()
if cashier is None:
auth.create_user(
connection,
"cashier-a",
"CashierPass123",
"company",
company_id=company_id,
)
cashier = connection.execute(
"SELECT * FROM users WHERE username = 'cashier-a'"
).fetchone()
manual = manual_records.submit(
connection,
company_id=company_id,
counterparty_company_id=int(company_b),
occurred_at="2026-02-01T09:00:00",
direction="incoming",
amount="100.00",
currency="CNY",
funding_source="other",
requested_subject="receivable",
request_key="hel157-queue-manual",
actor=cashier,
)
now = md.utc_now()
event_id = connection.execute(
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
).lastrowid
decision_id = connection.execute(
"""
INSERT INTO transfer_match_decisions (
event_id, revision, effective_at, amount, currency, classification,
pairing, locked, mode, rule_version, created_at
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
'single', 0, 'manual', 'test', ?)
""",
(event_id, now),
).lastrowid
connection.execute(
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
(event_id, decision_id),
)
connection.commit()
manual_id = manual["id"]
manual_decision_id = manual["decision_id"]
finally:
connection.close()
def queue_sizes():
st, _, body = self.admin.get("/api/admin/accounts?status=pending")
self.assertEqual(200, st, body)
accounts_n = len(as_json(body)["accounts"])
st, _, body = self.admin.get("/api/admin/manual-records?state=pending")
self.assertEqual(200, st, body)
manuals_n = len(as_json(body)["records"])
st, _, body = self.admin.get("/api/admin/match-exceptions")
self.assertEqual(200, st, body)
exceptions_n = len(as_json(body)["exceptions"])
return accounts_n, manuals_n, exceptions_n, accounts_n + manuals_n + exceptions_n
def assert_parity(expected_total: int) -> dict:
st, _, body = self.admin.get("/api/admin/dashboard")
self.assertEqual(200, st, body)
audit = as_json(body)["audit"]
accounts_n, manuals_n, exceptions_n, list_total = queue_sizes()
self.assertEqual(expected_total, audit["total"])
self.assertEqual(expected_total, list_total)
self.assertEqual(
audit["total"], audit["high"] + audit["medium"] + audit["low"]
)
self.assertGreaterEqual(accounts_n, 1 if expected_total >= 3 else 0)
return {
"audit": audit,
"accounts": accounts_n,
"manuals": manuals_n,
"exceptions": exceptions_n,
}
before = assert_parity(3)
self.assertEqual(1, before["accounts"])
self.assertEqual(1, before["manuals"])
self.assertEqual(1, before["exceptions"])
self.assertEqual(1, before["audit"]["high"])
self.assertEqual(2, before["audit"]["medium"])
# Dispose account → total 2
st, _, body = self.admin.post_json(
f"/api/admin/accounts/{account_id}/review",
{"decision": "approve", "reason": "HEL-157 three-queue dispose account"},
)
self.assertEqual(200, st, body)
after_account = assert_parity(2)
# Dispose manual → total 1
st, _, body = self.admin.post_json(
f"/api/admin/manual-records/{manual_id}/decisions",
{
"action": "approve_new",
"reason": "HEL-157 three-queue dispose manual",
"expected_decision_id": manual_decision_id,
"request_key": "hel157-dispose-manual",
},
)
self.assertEqual(200, st, body)
after_manual = assert_parity(1)
self.assertEqual(0, after_manual["manuals"])
# Dispose match exception via reverse → total 0
st, _, body = self.admin.get("/api/admin/match-exceptions")
exceptions = as_json(body)["exceptions"]
self.assertEqual(1, len(exceptions))
target = exceptions[0]
st, _, body = self.admin.post_json(
f"/api/admin/transfer-events/{target['event_id']}/decisions",
{
"action": "reverse",
"reason": "HEL-157 three-queue dispose match",
"expected_revision": target["revision"],
"request_key": "hel157-dispose-match",
},
)
self.assertEqual(200, st, body)
after_match = assert_parity(0)
self.assertEqual(0, after_match["exceptions"])
self.assertEqual(after_account["audit"]["total"] - 1, after_manual["audit"]["total"])
self.assertEqual(after_manual["audit"]["total"] - 1, after_match["audit"]["total"])
def test_company_detail_missing(self) -> None:
status, _, raw = self.admin.get(
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
)
data = as_json(raw)
self.assertEqual(404, status)
self.assertEqual("error", data["status"])
def test_company_detail_ok(self) -> None:
status, _, raw = self.admin.get("/api/admin/dashboard")
data = as_json(raw)
company_id = data["companies"][0]["id"]
status, _, raw = self.admin.get(
f"/api/admin/dashboard/companies/{company_id}?from=2026-01-01&cutoff=2026-08-20"
)
detail = as_json(raw)
self.assertEqual(200, status)
self.assertEqual([], detail["groups"])
if __name__ == "__main__":
unittest.main()
+8 -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], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -82,19 +82,22 @@ class MigrationTests(PersistenceTestCase):
"ledger_event_bank_sources",
"ledger_event_manual_sources",
"ledger_subject_suggestions",
"system_settings",
"system_setting_changes",
"reminders",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -102,7 +105,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([6, 5], rollback(self.connection, 4))
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+296
View File
@@ -0,0 +1,296 @@
"""Integration tests for system settings persistence and reminder flow."""
from __future__ import annotations
from http.client import HTTPConnection
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from bank_importer.db import connect, migrate
from bank_importer import auth, settings
import server
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
class Client:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.cookies: dict[str, str] = {}
def request(self, method, path, body=None, headers=None):
connection = HTTPConnection(self.host, self.port)
request_headers = dict(headers or {})
if self.cookies:
request_headers["Cookie"] = "; ".join(
f"{k}={v}" for k, v in self.cookies.items()
)
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
data = response.read()
set_cookie = dict(response.getheaders()).get("Set-Cookie")
if set_cookie:
cookie = SimpleCookie()
cookie.load(set_cookie)
for key, morsel in cookie.items():
if morsel.value:
self.cookies[key] = morsel.value
else:
self.cookies.pop(key, None)
status = response.status
connection.close()
return status, data
def get(self, path):
return self.request("GET", path)
def post_json(self, path, payload):
return self.request(
"POST", path, body=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
def as_json(data: bytes):
return json.loads(data.decode("utf-8"))
class SettingsAndRemindersTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
server.ensure_bootstrap_admin(connection)
# One active account for company 1 so pending items are generated.
with connection:
connection.execute(
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, 'active', 't', 't')"
)
company_id = connection.execute(
"SELECT id FROM companies WHERE name = '甲公司'"
).fetchone()["id"]
connection.execute(
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
(company_id,),
)
cls.company_id = company_id
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def test_settings_roundtrip_persists(self) -> None:
status, data = self.admin.post_json(
"/api/admin/settings",
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
)
self.assertEqual(200, status, data)
updated = as_json(data)["settings"]
self.assertEqual("1", updated["closing_day"])
self.assertEqual("2026-01-05", updated["start_date"])
self.assertEqual("0", updated["auto_remind"])
self.assertEqual("5", updated["remind_days"])
status, data = self.admin.get("/api/admin/settings")
self.assertEqual(200, status)
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
def test_invalid_closing_day_rejected(self) -> None:
for bad in ("0", "29", "abc"):
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
self.assertEqual(400, status, (bad, data))
def test_setting_change_is_audited(self) -> None:
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
self.assertEqual(200, status)
connection = connect(self.db_path)
try:
rows = connection.execute(
"SELECT key, before_value, after_value, actor_username "
"FROM system_setting_changes WHERE key = 'closing_day' "
"ORDER BY id DESC LIMIT 1"
).fetchall()
finally:
connection.close()
self.assertTrue(rows)
self.assertEqual("7", rows[0]["after_value"])
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
self.assertEqual("group-admin", rows[0]["actor_username"])
def test_reminder_pending_and_send(self) -> None:
status, data = self.admin.get(
f"/api/admin/reminders/pending?company_id={self.company_id}"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
status, data = self.admin.post_json(
"/api/admin/reminders/send", {"company_id": self.company_id}
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertGreaterEqual(len(payload["reminders"]), 1)
self.assertTrue(payload["deadline"])
status, data = self.admin.get("/api/admin/reminders")
self.assertEqual(200, status)
history = as_json(data)["reminders"]
self.assertGreaterEqual(len(history), 1)
self.assertEqual(self.company_id, history[0]["company_id"])
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
# A company user must not be able to read or write admin settings.
status, data = self.admin.post_json(
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
)
self.assertEqual(200, status, data)
initial = as_json(data)["initial_password"]
cashier = Client("127.0.0.1", self.port)
status, data = cashier.post_json(
"/api/login",
{"username": "cashier-x", "password": initial, "portal": "company"},
)
self.assertEqual(200, status)
status, data = cashier.post_json(
"/api/password/change",
{"old_password": initial, "new_password": "Changed123"},
)
self.assertEqual(200, status)
for path in ("/api/admin/settings", "/api/admin/reminders"):
status, _ = cashier.get(path)
self.assertEqual(403, status, path)
# Company session must get 403 on admin write endpoints (settings /
# reminders / review decisions), not just GETs.
write_calls = (
("POST /api/admin/settings", lambda: cashier.post_json(
"/api/admin/settings",
{"closing_day": "10", "start_date": "2026-01-01", "auto_remind": "0", "remind_days": "3"},
)),
("POST /api/admin/reminders/send", lambda: cashier.post_json(
"/api/admin/reminders/send", {"company_id": self.company_id}
)),
("POST /api/admin/accounts/1/review", lambda: cashier.post_json(
"/api/admin/accounts/1/review", {"decision": "approve"}
)),
("POST /api/admin/transfer-events/1/decisions", lambda: cashier.post_json(
"/api/admin/transfer-events/1/decisions",
{"action": "confirm_single", "reason": "越权"},
)),
("POST /api/admin/manual-records/1/decisions", lambda: cashier.post_json(
"/api/admin/manual-records/1/decisions",
{"action": "approve_new", "reason": "越权"},
)),
("POST /api/admin/intercompany/events/1/subject-decisions", lambda: cashier.post_json(
"/api/admin/intercompany/events/1/subject-decisions",
{"subject": "other_receivable", "reason": "越权"},
)),
("POST /api/admin/intercompany/events/1/adjustments", lambda: cashier.post_json(
"/api/admin/intercompany/events/1/adjustments",
{"action": "reverse", "reason": "越权"},
)),
)
for label, call in write_calls:
status, data = call()
self.assertEqual(403, status, f"{label}: {data}")
# Settings must be unchanged after the forbidden write attempt.
status, data = self.admin.get("/api/admin/settings")
self.assertEqual(200, status)
self.assertEqual("5", as_json(data)["settings"]["closing_day"])
class SettingsModuleTests(unittest.TestCase):
"""Unit tests for the settings module on a fresh in-memory database."""
def setUp(self) -> None:
self.connection = connect(":memory:")
self.addCleanup(self.connection.close)
migrate(self.connection)
def test_defaults_applied_when_no_row_exists(self) -> None:
values = settings.get_settings(self.connection)
self.assertEqual("5", values["closing_day"])
self.assertEqual("2026-01-01", values["start_date"])
self.assertEqual("1", values["auto_remind"])
self.assertEqual("3", values["remind_days"])
def test_validate_rejects_bad_values(self) -> None:
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
_, error = settings.validate_settings(bad)
self.assertIsNotNone(error)
_, error = settings.validate_settings({"start_date": "2026-13-40"})
self.assertIsNotNone(error)
_, error = settings.validate_settings({"auto_remind": "2"})
self.assertIsNotNone(error)
def test_update_writes_value_and_trail(self) -> None:
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
user = self.connection.execute(
"SELECT id, username FROM users WHERE id = ?", (user_id,)
).fetchone()
updated = settings.update_settings(
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
)
self.assertEqual("1", updated["closing_day"])
self.assertEqual("0", updated["auto_remind"])
changes = self.connection.execute(
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
).fetchall()
self.assertEqual(2, len(changes))
self.assertEqual("5", changes[0]["before_value"])
self.assertEqual("1", changes[0]["after_value"])
if __name__ == "__main__":
unittest.main()
+899 -199
View File
File diff suppressed because it is too large Load Diff
+2080 -1660
View File
File diff suppressed because it is too large Load Diff
+766 -179
View File
@@ -5,228 +5,815 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团公司业务端" />
<title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-portal="company">
<!--
THESIS: 公司端是面向出纳的当月处理驾驶舱,拒绝复制总账端的管理视角。
OWN-WORLD: 与集团一致的深黑玻璃系统,以荧光绿表示可执行动作,以琥珀红区分断档和待确认风险。
STORY: 出纳从四个本公司指标进入上传、匹配和复核,补齐证据后完成当月确认。
FIRST VIEWPORT: 浮动公司侧栏与账期顶栏围合工作区;四张待办统计卡领衔,阻断结账的异常和任务队列位于其下。
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<div class="shell">
<aside class="sidebar" id="sidebar" aria-label="公司业务导航">
<div class="brand"><span class="brand-mark"></span><span class="brand-copy"><strong>金牛集团</strong><small>公司业务端</small></span></div>
<div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div>
<nav class="nav-list">
<button class="nav-item is-active" data-view="workspace"><svg><use href="icons.svg#layout-dashboard"/></svg><span>工作台</span><b>4</b></button>
<button class="nav-item" data-view="upload"><svg><use href="icons.svg#upload"/></svg><span>流水导入</span></button>
<button class="nav-item" data-view="manual"><svg><use href="icons.svg#plus"/></svg><span>手工记录</span></button>
<button class="nav-item" data-view="flows"><svg><use href="icons.svg#file-spreadsheet"/></svg><span>流水管理</span></button>
<button class="nav-item" data-view="balances"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来余额</span></button>
<button class="nav-item" data-view="reconcile"><svg><use href="icons.svg#circle-check"/></svg><span>往来确认</span><b>2</b></button>
<button class="nav-item" data-view="accounts"><svg><use href="icons.svg#landmark"/></svg><span>银行账户</span></button>
<button class="nav-item" data-view="notifications"><svg><use href="icons.svg#bell"/></svg><span>通知</span><b>2</b></button>
<div class="side-brand">
<div class="brand-name">金牛实业 · 资金往来</div>
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
<span class="side-role company">公司业务端</span>
</div>
<nav class="side-nav">
<div class="nav-group">业务</div>
<a class="active" data-view="workspace" href="#workspace"><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="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="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>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge">3</span></a>
</nav>
<div class="sidebar-footer">
<a class="nav-item" href="index.html"><svg><use href="icons.svg#log-out"/></svg><span>退出登录</span></a>
<div class="user-block"><span class="avatar"></span><span><strong>牛女士</strong><small>A公司出纳</small></span></div>
<div class="side-foot">
<div class="user-row">
<span class="avatar"></span>
<div>
<div class="user-name">牛女士</div>
<div class="user-meta">金牛煤业 · 煤业出纳</div>
</div>
<button class="logout" type="button">退出</button>
</div>
</div>
</aside>
<div class="workspace">
<main id="main-content">
<div class="main">
<div class="topbar">
<span class="crumb">公司业务端 / <b id="currentViewName">工作台</b></span>
<div class="topbar-right">
<span class="tag">账期 2026-07</span>
<span class="tag">统计截止 2026-08-20</span>
</div>
</div>
<main class="content" id="main-content">
<section class="app-view is-active" data-page="workspace">
<header class="page-heading dashboard-head"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p><span class="status warning" id="workspacePendingStatus">4 项待处理</span></div>
<div class="content-toolbar">
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
<div class="toolbar-group">
<button class="icon-button" data-view-link="notifications" aria-label="通知,2 项未读" title="通知"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">2</i></button>
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
</div>
<div class="page-head">
<div>
<h1>工作台</h1>
<p class="page-sub"><span class="company-identity">河南金牛煤业有限公司</span> · 2026 年 7 月账期。本月 18 项账期任务已完成 13 项,剩余 4 项待办,其中 3 项阻断结账。</p>
</div>
</header>
<section class="metric-grid" aria-label="本公司关键指标">
<article class="metric-card danger" data-metric-action="upload" tabindex="0" role="button" aria-label="上传 1 个待补流水账户">
<div class="metric-copy"><span class="metric-label">待上传账户</span><strong class="metric-value">1<small>/ 4</small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#upload"/></svg></span>
<p class="metric-foot"><strong>工商银行 9481</strong> · 缺少 21 天</p>
</article>
<article class="metric-card warning" data-metric-link="reconcile" tabindex="0" role="button" aria-label="确认 1 笔待匹配流水">
<div class="metric-copy"><span class="metric-label">待确认流水</span><strong class="metric-value">1<small></small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#arrow-left-right"/></svg></span>
<p class="metric-foot"><strong>280.00 万元</strong> · 有 2 个候选</p>
</article>
<article class="metric-card" data-metric-link="manual" tabindex="0" role="button" aria-label="查看 1 笔待复核手工记录">
<div class="metric-copy"><span class="metric-label">待复核记录</span><strong class="metric-value">1<small></small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#list-checks"/></svg></span>
<p class="metric-foot"><strong>已提交总账</strong> · 等待管理员处理</p>
</article>
<article class="metric-card success">
<div class="metric-copy"><span class="metric-label">本月完成进度</span><strong class="metric-value">75<small>%</small></strong></div>
<span class="metric-icon"><svg><use href="icons.svg#circle-check"/></svg></span>
<p class="metric-foot"><strong>3 / 4 个账户</strong> · 距结账还有 3 天</p>
</article>
</section>
<section class="company-alert"><svg><use href="icons.svg#circle-alert"/></svg><div><strong>7 月数据尚不能结账</strong><p>工商银行账户缺少 07.01—07.21 流水,补齐后系统将重新计算匹配结果。</p></div><button class="button primary" data-open-upload>补传流水</button></section>
<section class="panel company-stack-panel" aria-label="账户覆盖">
<div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户</p></div><span class="status warning">3 / 4 连续</span></div>
<div class="company-list" role="list">
<article class="company-row" role="listitem" data-detail="acct-citic" tabindex="0" aria-label="中信银行 5316 全部连续,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>中信银行 · 5316</strong><small>基本户 · 最近导入 09:42</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
<article class="company-row" role="listitem" data-detail="acct-abc" tabindex="0" aria-label="农业银行 3650 全部连续,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>农业银行 · 3650</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
<article class="company-row is-danger" role="listitem" data-detail="acct-icbc" tabindex="0" aria-label="工商银行 9481 缺少 21 天,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>工商银行 · 9481</strong><small>一般户 · 已逾期 2 天</small></div><div class="company-row-figure"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><em class="status danger">断档</em></article>
<article class="company-row" role="listitem" data-detail="acct-ccb" tabindex="0" aria-label="建设银行 0845 全部连续,查看详情"><span class="company-row-mark"></span><div class="company-row-body"><strong>建设银行 · 0845</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
<div class="page-actions">
<button class="btn" data-open-upload>上传流水</button>
<button class="btn btn-primary" id="workspaceUnilateralCta" data-view-link="reconcile">去确认单边流水 (0)</button>
</div>
</section>
<div class="company-dashboard-grid">
<section class="panel task-panel">
<div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div>
<div class="cashier-tasks">
<article data-task-type="upload" data-detail="task-upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
<article data-task-type="match" data-detail="task-match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
<article data-task-type="subject" data-detail="task-subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
<article data-detail="task-notice"><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
</div>
</section>
<aside class="panel coverage-panel">
<div class="panel-heading"><div><h2>账户覆盖</h2><p>起算日 2026.01.01</p></div><span class="status warning">3 / 4 连续</span></div>
<div class="coverage-list"><article><span><strong>中信银行 · 5316</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>建设银行 · 0845</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>农业银行 · 3650</strong><small>01.01—07.31</small></span><em class="status success">连续</em></article><article><span><strong>工商银行 · 9481</strong><small>缺少 07.01—07.21</small></span><em class="status danger">断档</em></article></div>
<button class="text-button full-text-button" data-view-link="accounts">查看全部账户</button>
</aside>
</div>
<section class="work-progress" aria-label="本月处理进度"><div><span>1</span><p><strong>流水上传</strong><small>3 / 4 个账户完成</small></p></div><i></i><div><span>2</span><p><strong>异常处理</strong><small>2 项待确认</small></p></div><i></i><div class="pending"><span>3</span><p><strong>公司确认</strong><small>尚未提交</small></p></div><i></i><div class="pending"><span>4</span><p><strong>集团结账</strong><small>次月 5 日</small></p></div></section>
<section class="panel timeline-panel" aria-label="账期时间轴">
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status warning">距 7 月结账日 08.05 还有 3 天</span></div>
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
<div class="timeline-bar">
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
<i class="timeline-today" style="left:99.1%"></i>
<div class="card">
<div class="card-head">
<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">
<a class="flow-step part" data-view-link="upload" 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>
<div class="fs-meta">2/3 账户已覆盖 · 交行 7710 待审核</div>
</a>
<a class="flow-step done" data-view-link="manual" 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>
<div class="fs-meta">7 月手工记录 5 笔已提交</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 class="fs-meta">2 笔复核在途 · 无需本公司操作</div>
</div>
<div class="timeline-ticks" aria-hidden="true">
<span style="left:0">01.01<small>起算日</small></span>
<span style="left:14%">2月</span>
<span style="left:26.7%">3月</span>
<span style="left:40.7%">4月</span>
<span style="left:54.3%">5月</span>
<span style="left:68.3%">6月</span>
<span style="left:81.9%">7月</span>
<span style="left:95.9%">8月</span>
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">05</span><span class="fs-dot"></span><span class="fs-name">账期结账</span></div>
<div class="fs-state">08-29</div>
<div class="fs-meta">需先完成上方前 3 步</div>
</div>
</div>
<ul class="timeline-legend">
<li><i class="dot success"></i>1—6 月已结账</li>
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
<li><i class="dot today"></i>今天 08.08</li>
</ul>
</section>
</div>
<div class="grid grid-3-2" style="margin-top: 14px;">
<div class="stack">
<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">本公司已登记银行账户与流水上传覆盖区间</span></span>
<button class="btn btn-sm btn-ghost" data-view-link="accounts">管理账户 →</button>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr><th>银行账户</th><th>流水覆盖区间</th><th>状态</th></tr>
</thead>
<tbody>
<tr>
<td class="cell-main">工商银行<span class="cell-sub">尾号 3305 · 基本户</span></td>
<td class="meta">2026-01-01 ~ 2026-08-20</td>
<td><span class="pill pill-success">已覆盖</span></td>
</tr>
<tr>
<td class="cell-main">中国银行<span class="cell-sub">尾号 9916 · 一般户</span></td>
<td class="meta">2026-01-01 ~ 2026-08-12</td>
<td><span class="pill pill-warn">部分覆盖</span></td>
</tr>
<tr>
<td class="cell-main">交通银行<span class="cell-sub">尾号 7710 · 一般户</span></td>
<td class="meta">未上传</td>
<td><span class="pill pill-info">待审核</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="stack">
<div class="card">
<div class="card-head">
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
</div>
<div class="mini-stats">
<div class="mini-stat">
<div class="ms-label">借方合计</div>
<div class="ms-value">18,420.50<span class="unit">万元</span></div>
</div>
<div class="mini-stat">
<div class="ms-label">贷方合计</div>
<div class="ms-value">12,386.00<span class="unit">万元</span></div>
</div>
<div class="mini-stat">
<div class="ms-label">明细笔数</div>
<div class="ms-value">128<span class="unit"></span></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>
</div>
<button class="btn btn-ghost" data-view-link="flows" style="width: 100%; margin-top: 12px;">查看本公司逐笔流水 →</button>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3) →</button>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">7 月账期结账日顺延至 08-29</div>
<div class="lr-sub">请在此之前完成往来确认事项</div>
</div>
<span class="lr-side meta">08-18</span>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">3 笔单边流水待确认</div>
<div class="lr-sub">涉及金牛置业、金牛贸易往来</div>
</div>
<span class="lr-side meta">08-16</span>
</div>
<div class="list-row">
<span class="pill pill-success">通过</span>
<div class="lr-main">
<div class="lr-title">中行尾号 9916 流水导入成功</div>
<div class="lr-sub">流水覆盖至 08-12</div>
</div>
<span class="lr-side meta">08-12</span>
</div>
</div>
</div>
</div>
</section>
<section class="app-view" data-page="upload">
<header class="page-heading"><div><h1>流水导入</h1><p>上传 A公司银行账户流水,系统按表头识别银行模板</p></div><button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button></header>
<section class="panel"><div class="table-summary"><span>最近导入批次</span><span>仅已确认工作表参与匹配与计算</span></div><div class="table-scroll"><table class="data-table"><thead><tr><th>批次</th><th>银行账户</th><th>流水期间</th><th>明细数</th><th>覆盖状态</th><th>解析状态</th><th>上传时间</th></tr></thead><tbody id="importRows"></tbody></table></div></section>
<div class="page-head">
<div>
<h1>流水导入</h1>
<p class="page-sub">上传银行导出的 Excel 流水文件,系统按表头识别银行模板、解析期间与明细并做覆盖校验。当前账期 2026-07,仅已审核通过的账户可导入。</p>
</div>
</div>
<div class="grid grid-1-2">
<div class="card">
<div class="card-head">
<span class="card-title">上传流水<span class="sub">先选账户,再选择或拖入流水文件</span></span>
</div>
<form id="uploadForm" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label for="accountSelect">选择银行账户</label>
<select class="select" id="accountSelect" required><option value="">请选择账户</option></select>
<span class="hint">仅已审核通过的账户可上传;选择账户与文件后方可解析</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>流水文件</label>
<input type="file" id="fileInput" accept=".xls,.xlsx" hidden />
<div class="dropzone" id="dropzone" tabindex="0" role="button" aria-label="拖放或点击选择流水文件">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 16V5m0 0L8 9m4-4l4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
<div class="dz-title">拖放银行流水文件到此处</div>
<div class="dz-sub"><span class="dz-browse">点击选择文件</span>,支持银行导出的 Excel</div>
<div class="dz-formats">.XLS / .XLSX · MAX 20MB</div>
</div>
<div class="file-chip" id="filePreview" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>
<div class="fc-info">
<div class="fc-name" id="fileName"></div>
<div class="fc-meta" id="fileMeta"></div>
</div>
<button type="button" class="fc-remove" id="removeFile" aria-label="移除已选文件">×</button>
</div>
</div>
<div class="notice info" id="parseResult" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 18px; height: 18px; flex: none; margin-top: 1px;"><use href="icons.svg#circle-check"/></svg>
<div style="flex: 1; min-width: 0;">
<div class="n-title" id="parseTitle">文件解析完成</div>
<div class="n-body" id="parseSummary"></div>
</div>
</div>
<div id="sheetReview" hidden>
<div class="card-title" style="margin: 14px 0 6px;">工作表处理</div>
<span class="hint" style="display: block; margin-bottom: 10px;">解析成功不等于业务确认:只有勾选并确认的工作表才会进入后续匹配与计算。</span>
<div id="sheetList" aria-live="polite"></div>
</div>
<button class="btn btn-primary" id="parseButton" type="submit" disabled style="width: 100%; margin-top: 14px;"><span>开始解析</span></button>
<span class="hint" style="display: block; margin-top: 8px; text-align: center;">请先选择流水文件,选择后此按钮方可点击</span>
<div class="detail-box" style="margin-top: 14px;">
<div style="font-weight: 650; margin-bottom: 6px;">导入规则</div>
<div class="stack" style="gap: 5px;">
<div>· 同一账户同一月份重复导入时,新批次覆盖旧批次</div>
<div>· 导入后系统自动与对方公司流水轧算往来余额</div>
<div>· 无法解析的行转入审核中心,不影响其余明细入库</div>
</div>
</div>
</form>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">历史导入批次<span class="sub">2026 年 7 月账期 · 本公司全部账户</span></span>
</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="importRows"></tbody>
</table>
</div>
<div class="table-foot">
<span id="importFoot">暂无批次</span>
<span class="meta">同一账户同一月份重复导入时,新批次覆盖旧批次</span>
</div>
</div>
</div>
</section>
<section class="app-view" data-page="manual">
<header class="page-heading"><div><h1>手工记录</h1><p>登记个人过账或银行流水之外的往来事实</p></div><span class="status warning" id="manualPendingStatus">1 笔待总账复核</span></header>
<div class="manual-layout">
<form class="panel manual-form" id="manualEntryForm">
<div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div>
<div class="form-body">
<div class="form-grid"><label class="field"><span>交易日期</span><input name="transactionDate" type="date" value="2026-08-06" required /></label><label class="field"><span>收付方向</span><select name="direction" required><option>付款</option><option>收款</option></select></label></div>
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>个人过账</option></select></label></div>
<div class="form-grid"><label class="field"><span>对方类型</span><select name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></label><label class="field"><span>对方公司</span><select name="counterparty" required><option value="">请选择集团内部公司</option></select></label></div>
<div class="form-grid"><label class="field"><span>对方账号</span><input name="counterpartyAccount" maxlength="64" placeholder="可选" /></label><label class="field"><span>往来科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label></div>
<label class="field"><span>业务摘要</span><input name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></label>
<label class="field"><span>补充说明</span><textarea name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></label>
<div class="form-grid"><label class="field"><span>关联银行流水号</span><input name="bankReference" maxlength="80" placeholder="可选" /></label><label class="field"><span>证明附件</span><input name="evidence" type="file" accept=".pdf,.png,.jpg,.jpeg,.xls,.xlsx" /></label></div>
<p class="form-callout"><svg><use href="icons.svg#circle-alert"/></svg>手工记录与银行原始流水分开留痕;总账复核通过后才纳入往来计算。</p>
<div class="page-head">
<div>
<h1>手工记录</h1>
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由管理员审核,通过后计入本公司往来台账。</p>
</div>
</div>
<div class="grid grid-1-2">
<div class="card">
<div class="card-head">
<span class="card-title">登记手工往来<span class="sub">带 * 为必填项,提交后进入待审核状态</span></span>
</div>
<footer><button class="button primary" type="submit"><svg><use href="icons.svg#send"/></svg>提交总账复核</button></footer>
</form>
<section class="panel manual-records-panel">
<div class="panel-heading"><div><h2>本公司记录</h2><p>按提交时间倒序</p></div></div>
<div class="table-scroll"><table class="data-table manual-records-table"><thead><tr><th>编号 / 日期</th><th>方向 / 科目</th><th>资金来源</th><th>对方</th><th>摘要</th><th class="number">金额(元)</th><th>状态</th></tr></thead><tbody id="manualRecordRows"><tr data-demo-record><td><strong>MR-DEMO-001</strong><small>2026.07.29</small></td><td><strong>付款</strong><small>其他应收</small></td><td>个人过账</td><td><strong>B公司</strong><small>集团内部公司</small></td><td>个人代付后转回</td><td class="number">80,000.00</td><td><span class="status warning">待总账复核</span></td></tr></tbody></table></div>
</section>
<form id="manualEntryForm" novalidate>
<div class="grid grid-2" style="gap: 12px;">
<div class="field"><label>交易日期 *</label><input class="input num-input" name="transactionDate" type="date" value="2026-08-06" required /></div>
<div class="field"><label>收付方向 *</label><select class="select" name="direction" required><option>付款</option><option>收款</option></select></div>
<div class="field"><label>金额(元)*</label><input class="input num-input" name="amount" type="number" min="0.01" step="0.01" required /></div>
<div class="field"><label>资金来源 *</label><select class="select" name="sourceAccount" required><option value="">请选择</option><option>个人过账</option></select></div>
<div class="field"><label>对方类型 *</label><select class="select" name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></div>
<div class="field"><label>对方名称 *</label><input class="input" name="counterparty" maxlength="100" placeholder="公司全称或个人姓名" required /></div>
<div class="field"><label>对方账号</label><input class="input" name="counterpartyAccount" maxlength="64" placeholder="可选" /></div>
<div class="field"><label>往来科目 *</label><select class="select" name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></div>
</div>
<div class="field" style="margin-top: 12px;"><label>业务摘要 *</label><input class="input" name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></div>
<div class="field" style="margin-top: 12px;"><label>补充说明 *</label><textarea class="textarea" name="remark" rows="3" maxlength="500" placeholder="说明形成原因和核对依据" required></textarea></div>
<div class="grid grid-2" style="gap: 12px; margin-top: 12px;">
<div class="field"><label>关联银行流水号</label><input class="input" name="bankReference" maxlength="80" placeholder="可选" /></div>
<div class="field"><label>证明附件</label><input class="input" name="evidence" type="file" accept=".pdf,.png,.jpg,.jpeg,.xls,.xlsx" /></div>
</div>
<div class="notice info" style="margin-top: 14px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">管理员复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
</div>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交管理复核</button>
</form>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待管理复核</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 class="wrap">摘要</th><th>审核状态</th><th>操作</th>
</tr>
</thead>
<tbody id="manualRecordRows"></tbody>
</table>
</div>
<div class="empty" id="manualEmpty" hidden>
<div class="e-title">暂无已提交记录</div>
登记的手工往来提交后会显示在这里。
</div>
<div class="table-foot">
<span id="manualFoot">共 0 条</span>
<span class="meta">审核由管理员在 1 个工作日内完成</span>
</div>
</div>
</div>
</section>
<section class="app-view" data-page="flows">
<header class="page-heading"><div><h1>流水管理</h1><p>仅查询和导出 A公司的银行流水</p></div><button class="button secondary" id="exportFlows"><svg><use href="icons.svg#download"/></svg>导出当前结果</button></header>
<section class="filter-grid flow-filters"><label class="field"><span>银行</span><select id="flowBank"><option>全部银行</option><option>中信银行</option><option>工商银行</option><option>建设银行</option></select></label><label class="field"><span>账户</span><select id="flowAccount"><option>全部账户</option><option>5316</option><option>9481</option><option>0845</option></select></label><label class="field"><span>开始日期</span><input id="flowStart" type="date" value="2026-07-01" /></label><label class="field"><span>结束日期</span><input id="flowEnd" type="date" value="2026-07-31" /></label><label class="field grow"><span>摘要 / 对方户名 / 流水号</span><input id="flowKeyword" placeholder="输入关键词" /></label><button class="button primary" id="applyFlowFilters"><svg><use href="icons.svg#search"/></svg>查询</button></section>
<section class="panel"><div class="table-summary"><span>当前结果 <strong id="flowCount">3</strong></span><span>金额单位:元</span></div><div class="table-scroll"><table class="data-table" id="flowTable"><thead><tr><th>日期</th><th>银行账户</th><th>方向</th><th>对方户名 / 账号</th><th>摘要</th><th>银行流水号</th><th>归集状态</th><th class="number">金额</th></tr></thead><tbody><tr data-bank="中信银行"><td>2026.07.18</td><td>中信银行 · 5316</td><td>转出</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>往来款</td><td>CIT260718018</td><td><span class="status success">双边匹配</span></td><td class="number">10,000,000.00</td></tr><tr data-bank="建设银行"><td>2026.07.12</td><td>建设银行 · 0845</td><td>转出</td><td><strong>A公司</strong><small>尾号 5316</small></td><td>同名账户调拨</td><td>CCB260712031</td><td><span class="status neutral">同公司调拨</span></td><td class="number">2,000,000.00</td></tr><tr data-bank="中信银行"><td>2026.07.06</td><td>中信银行 · 5316</td><td>转入</td><td><strong>B公司</strong><small>尾号 9481</small></td><td>归还往来款</td><td>CIT260706041</td><td><span class="status success">双边匹配</span></td><td class="number">3,200,000.00</td></tr></tbody></table></div></section>
</section>
<div class="page-head">
<div>
<h1>流水管理</h1>
<p class="page-sub">本公司名下银行账户的原始流水及归集状态。集团内往来流水将进入往来确认与归集链路,外部流水不参与归集。</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="exportFlows">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 15px; height: 15px;"><path d="M12 4v11m0 0l-4-4m4 4l4-4"/><path d="M4 17v2.5A1.5 1.5 0 0 0 5.5 21h13a1.5 1.5 0 0 0 1.5-1.5V17"/></svg>
导出 CSV
</button>
</div>
</div>
<section class="app-view" data-page="balances">
<header class="page-heading"><div><h1>往来余额</h1><p>本公司往来余额、对方公司明细与允许查看的源证据</p></div>
<span class="status neutral" id="companyOpeningNote">期初不可用 · 仅显示期间净变动</span>
</header>
<div class="company-alert balance-alert" id="companyUnresolvedAlert" hidden>
<svg><use href="icons.svg#circle-alert"/></svg>
<div><strong id="companyUnresolvedText"></strong><p>未确认事项不影响已确认余额,处理前请先核对证据。</p></div>
<button class="button secondary" data-view-link="reconcile">去确认</button>
<div class="filters">
<div class="field">
<label for="flowBank">银行</label>
<select class="select" id="flowBank"><option>全部银行</option><option>工商银行</option><option>中国银行</option><option>交通银行</option></select>
</div>
<div class="field">
<label for="flowAccount">账户</label>
<select class="select" id="flowAccount"><option>全部账户</option><option>3305</option><option>9916</option><option>7710</option></select>
</div>
<div class="field">
<label for="flowStart">日期起</label>
<input class="input num-input" id="flowStart" type="date" value="2026-07-01" />
</div>
<div class="field">
<label for="flowEnd">日期止</label>
<input class="input num-input" id="flowEnd" type="date" value="2026-08-20" />
</div>
<div class="field" style="min-width: 200px;">
<label for="flowKeyword">关键词</label>
<input class="input" id="flowKeyword" type="text" placeholder="对方单位 / 摘要 / 流水号" />
</div>
<button class="btn" id="resetFlowFilters">重置</button>
<button class="btn btn-primary" id="applyFlowFilters">查询</button>
</div>
<div class="table-wrap">
<table class="ds-table" id="flowTable">
<thead>
<tr>
<th>日期</th>
<th>银行账户</th>
<th>方向</th>
<th class="wrap">对方单位</th>
<th class="wrap">摘要</th>
<th>银行流水号</th>
<th>归集状态</th>
<th class="num-col">金额</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="flowEmpty" hidden>
<div class="e-title">没有符合条件的流水</div>
调整筛选条件后重新查询。
</div>
<div class="table-foot">
<span id="flowCount">共 0 笔</span>
<span class="num" id="flowSum"></span>
</div>
</div>
<section class="pair-report" id="companyBalanceSummary">
<header class="pair-report-heading"><div><h2>本公司往来汇总</h2><p id="companyBalancePeriod">统计口径</p></div></header>
<div id="companyBalanceGroups" class="balance-currency-groups"></div>
</section>
<section class="panel company-stack-panel">
<div class="panel-heading"><div><h2>对方公司明细</h2><p>点击任意对方公司查看往来事件与允许的源证据</p></div></div>
<div class="company-list" id="companyCounterparties" aria-live="polite"></div>
</section>
</section>
<section class="app-view" data-page="reconcile">
<header class="page-heading"><div><h1>往来确认</h1><p>系统计算为主,只处理无法确定的匹配与科目</p></div></header>
<section class="reconcile-summary"><div><span>自动确认</span><strong>18 笔</strong><small>无需人工处理</small></div><div><span>单边待匹配</span><strong id="matchPendingCount">1 笔</strong><small>需选择对方证据</small></div><div><span>科目待确认</span><strong id="subjectPendingCount">1 笔</strong><small>需选择会计科目</small></div></section>
<section class="panel reconcile-panel"><div class="panel-heading"><div><h2>待确认事项</h2><p>每次确认都会记录操作人、时间和依据</p></div></div><div class="review-list" id="reviewList"><article data-review-type="match"><div class="review-main"><span class="task-level warning">匹配</span><div><h3>2026.07.18 · 转出 280.00 万元</h3><p>中信银行 5316 → B公司 · 摘要“往来款”</p></div></div><fieldset class="candidate-options"><legend>选择对方银行证据</legend><label><input type="radio" name="matchCandidate" value="B公司工商银行 9481" checked /><span><strong>B公司 · 工商银行 9481</strong><small>同金额 · 同日 · 对方账号一致 · 推荐</small></span></label><label><input type="radio" name="matchCandidate" value="B公司建设银行 2046" /><span><strong>B公司 · 建设银行 2046</strong><small>同金额 · 相差 1 日 · 摘要相近</small></span></label></fieldset><div class="review-actions"><button class="button secondary small" data-reject>无法匹配,转异常</button><button class="button primary small" data-resolve="match">确认所选匹配</button></div></article><article data-review-type="subject"><div class="review-main"><span class="task-level warning">科目</span><div><h3>2026.06.27 · 转出 600.00 万元</h3><p>建设银行 0845 → C公司 · 摘要“资金调拨”</p></div></div><div class="candidate"><label class="field compact"><span>确认科目</span><select><option>应收</option><option>其他应收</option></select></label><button class="button primary small" data-resolve="subject">确认科目</button></div></article></div><div class="review-history" id="reviewHistory" hidden><h3>本次处理记录</h3></div></section>
<div class="page-head">
<div>
<h1>往来确认</h1>
<p class="page-sub">处理本公司流水与对方公司对账出现的单边记录,并确认往来科目归属。7 月结账日因故顺延至 08-29,逾期未确认将阻断本公司结账。</p>
</div>
</div>
<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">正在读取待确认单边流水…</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">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>
<div class="card" style="margin-top: 14px; display: none;" id="matched-summary">
<div class="card-head">
<span class="card-title">已匹配记录<span class="sub">本页已确认的单边流水</span></span>
</div>
<div id="matched-list"></div>
</div>
</section>
<section id="panel-subject" style="display: none;">
<div class="card" style="padding: 0;">
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr>
<th>日期</th><th>对方</th><th class="num-col">金额</th><th>摘要</th><th>当前建议科目</th><th>科目确认</th><th>状态</th>
</tr>
</thead>
<tbody>
<tr data-subject-row>
<td class="num">2026-07-18</td>
<td class="cell-main">金牛新能源</td>
<td class="num-col amt-in">¥680,000.00</td>
<td>矿区光伏场地租赁费</td>
<td><span class="tag">其他应收</span></td>
<td>
<div class="row" style="gap: 8px;">
<select class="select" style="min-height: 30px; padding: 4px 8px;"><option>应收</option><option>应付</option><option selected>其他应收</option><option>其他应付</option></select>
<button class="btn btn-sm btn-primary subject-confirm-btn">确认</button>
</div>
</td>
<td class="subject-status"><span class="pill pill-warn">待确认</span></td>
</tr>
<tr data-subject-row>
<td class="num">2026-07-25</td>
<td class="cell-main">金牛农业</td>
<td class="num-col amt-out">¥420,000.00</td>
<td>职工食堂食材统采分摊</td>
<td><span class="tag">其他应付</span></td>
<td>
<div class="row" style="gap: 8px;">
<select class="select" style="min-height: 30px; padding: 4px 8px;"><option>应收</option><option>应付</option><option>其他应收</option><option selected>其他应付</option></select>
<button class="btn btn-sm btn-primary subject-confirm-btn">确认</button>
</div>
</td>
<td class="subject-status"><span class="pill pill-warn">待确认</span></td>
</tr>
</tbody>
</table>
</div>
<div class="table-foot">
<span>共 2 条待确认 · 确认后可在「流水管理」中继续查看</span>
<span class="num">合计 ¥1,100,000.00</span>
</div>
</div>
</section>
</section>
<section class="app-view" data-page="accounts">
<header class="page-heading"><div><h1>银行账户</h1><p>A公司登记账户与流水覆盖区间</p></div><button class="button secondary" id="openAccountDialog"><svg><use href="icons.svg#plus"/></svg>登记账户</button></header>
<section class="account-directory" id="accountDirectory"></section>
<div class="page-head">
<div>
<h1>银行账户</h1>
<p class="page-sub">本公司名下已登记银行账户。仅审核通过的账户可上传流水,账号对外仅显示尾号。</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="openAccountDialog">提交新账户登记</button>
</div>
</div>
<div class="notice info" style="margin-bottom: 14px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
<div>
<div class="n-title">新账户需管理员审核通过后方可上传流水</div>
<div class="n-body">提交登记后由管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
</div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">本公司账户<span class="sub"><span id="account-count">0</span> 个 · 统计截止 2026-08-20</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>审核状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="account-tbody"></tbody>
</table>
</div>
<div class="empty" id="account-empty" hidden>
<div class="e-title">暂无已登记账户</div>
点击右上角「提交新账户登记」提交开户信息。
</div>
<div class="table-foot">
<span id="account-foot">共 0 个账户</span>
<span class="meta">仅审核通过的账户可上传流水并参与归集</span>
</div>
</div>
</section>
<section class="app-view" data-page="notifications">
<header class="page-heading"><div><h1>通知</h1><p>系统提醒与总账管理员通知</p></div><button class="button secondary" id="markAllRead"><svg><use href="icons.svg#circle-check"/></svg>全部标为已读</button></header>
<section class="panel notification-page"><div class="notification-list" id="companyNotifications"><article class="is-unread"><span class="notification-icon danger"><svg><use href="icons.svg#circle-alert"/></svg></span><span><strong>7 月工商银行流水尚未上传</strong><small>系统自动提醒 · 今天 08:00 · 截止 08.05</small><p>账户尾号 9481 缺少 07.01—07.21 流水。</p></span><em class="status danger">未读</em></article><article class="is-unread"><span class="notification-icon warning"><svg><use href="icons.svg#bell"/></svg></span><span><strong>请完成 7 月银行流水确认</strong><small>系统管理员 · 今天 09:30 · 截止 08.08</small><p>请先处理断档和两项待确认往来,再提交公司确认。</p></span><em class="status warning">未读</em></article><article><span class="notification-icon success"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>6 月账期已完成集团结账</strong><small>系统通知 · 07.05 18:10</small></span><em class="status neutral">已读</em></article></div></section>
<div class="page-head">
<div>
<h1>通知</h1>
<p class="page-sub">系统与管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
</div>
<div class="page-actions">
<button class="btn btn-ghost" id="mark-all-read">全部标为已读</button>
</div>
</div>
<div class="card">
<div class="tabs" id="notice-tabs">
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">9</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">3</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">2</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">4</span></button>
</div>
<div id="notice-list">
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">7 月流水存在断档风险:交行 尾号 7710 未上传</div>
<div class="lr-sub"><span class="meta">2026-08-20 14:32</span> · 交行 7710 账户(待审核)7 月流水尚未上传,距 7 月结账日(顺延至 08-29)仅剩 9 天,请尽快补传。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">您有 2 笔单边流水待选择对方证据</div>
<div class="lr-sub"><span class="meta">2026-08-19 09:15</span> · 与金牛置业的煤炭采购款 ¥3,200,000.00 等 2 笔流水仅有本方记录,需选择对方银行流水佐证。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">管理员提醒:请于 08-29 前完成待确认事项</div>
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待管理员复核。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="reconcile">去处理</button></div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:中行 尾号 9916 · 42 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:52</span> · 7 月中行一般户流水已导入,42 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:工行 尾号 3305 · 86 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:48</span> · 7 月工行基本户流水已导入,86 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">2026 年 6 月账期已结账</div>
<div class="lr-sub"><span class="meta">2026-07-05 09:00</span> · 6 月账期已按每月 5 日结账规则完成结账,期末数据已锁定,不可再修改。</div>
</div>
</div>
</div>
<div class="empty" id="notice-empty" style="display: none;">
<div class="e-title">暂无该状态的通知</div>
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
</div>
</div>
</section>
</main>
</div>
</div>
<dialog id="uploadDialog" class="dialog upload-dialog">
<form id="uploadForm">
<header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong id="parseTitle">文件解析完成</strong><p id="parseSummary"></p></span></div><div class="sheet-review" id="sheetReview" hidden><h3>工作表处理</h3><p class="sheet-review-hint">解析成功不等于业务确认:只有勾选并确认的工作表才会进入后续匹配与计算。</p><div class="sheet-list" id="sheetList" aria-live="polite"></div></div></div>
<footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer>
</form>
</dialog>
<dialog id="accountDialog" class="dialog">
<form id="accountForm">
<header><div><h2>登记银行账户</h2><p>账户提交后由总账管理员复核</p></div><button type="button" class="icon-button" data-close-account aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
<div class="dialog-body"><div class="form-grid"><label class="field"><span>开户银行</span><input name="bank" placeholder="例如:郑州银行" required /></label><label class="field"><span>账户类型</span><select name="type"><option>基本户</option><option>一般户</option><option>专用户</option></select></label></div><label class="field"><span>银行账号</span><input name="accountNumber" inputmode="numeric" required /></label><label class="field"><span>启用日期</span><input name="startDate" type="date" value="2026-08-06" required /></label></div>
<footer><button type="button" class="button secondary" data-close-account>取消</button><button class="button primary" type="submit">提交登记</button></footer>
</form>
</dialog>
<div class="drawer-scrim" id="drawerScrim" data-close-drawer aria-hidden="true"></div>
<aside class="drawer" id="evidenceDrawer" aria-label="往来明细与源行证据抽屉" aria-hidden="true">
<header class="drawer-header">
<div>
<div class="drawer-breadcrumb" id="drawerBreadcrumb"></div>
<h2 id="drawerTitle">往来明细</h2>
<!-- 导入批次详情弹窗 -->
<div class="modal-backdrop" id="modal-batch">
<div class="modal">
<div class="modal-head">
<span class="modal-title">导入批次详情</span>
<button type="button" class="modal-close" data-close="modal-batch" aria-label="关闭">×</button>
</div>
<button type="button" class="icon-button" data-close-drawer aria-label="关闭抽屉" title="关闭"><svg><use href="icons.svg#x"/></svg></button>
</header>
<div class="drawer-body" id="drawerBody" tabindex="-1"></div>
</aside>
<p class="modal-sub" id="mb-sub"></p>
<div class="detail-box">
<dl class="kv">
<dt>批次号</dt><dd id="mb-id"></dd>
<dt>账户</dt><dd id="mb-bank"></dd>
<dt>流水期间</dt><dd id="mb-period"></dd>
<dt>明细数量</dt><dd id="mb-count"></dd>
<dt>覆盖状态</dt><dd id="mb-cover"></dd>
<dt>解析状态</dt><dd id="mb-parse"></dd>
<dt>导入人</dt><dd id="mb-user">本公司出纳</dd>
<dt>导入时间</dt><dd id="mb-time"></dd>
</dl>
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close="modal-batch">关闭</button>
</div>
</div>
</div>
<!-- 撤回手工记录弹窗 -->
<div class="modal-backdrop" id="withdraw-modal">
<div class="modal">
<div class="modal-head">
<span class="modal-title">撤回手工记录</span>
<button type="button" class="modal-close" data-close="withdraw-modal" aria-label="关闭">×</button>
</div>
<p class="modal-sub">撤回后该记录将从审核队列中移除,且不可恢复,需重新登记提交。</p>
<div class="detail-box">
<dl class="kv">
<dt>业务日期</dt><dd id="wd-date"></dd>
<dt>对方公司</dt><dd id="wd-peer"></dd>
<dt>金额</dt><dd id="wd-amount"></dd>
<dt>摘要</dt><dd id="wd-summary" style="font-family: var(--font-body);"></dd>
</dl>
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close="withdraw-modal">取消</button>
<button type="button" class="btn btn-danger" id="wd-confirm">确认撤回</button>
</div>
</div>
</div>
<!-- 流水详情弹窗 -->
<div class="modal-backdrop" id="tx-modal">
<div class="modal wide">
<div class="modal-head">
<span class="modal-title">流水详情</span>
<button type="button" class="modal-close" id="tx-modal-close" aria-label="关闭">×</button>
</div>
<p class="modal-sub" id="tx-modal-sub"></p>
<div class="detail-box">
<div class="card-title" style="margin-bottom: 10px;">原始流水</div>
<dl class="kv">
<dt>银行流水号</dt><dd id="d-serial"></dd>
<dt>银行</dt><dd id="d-bank"></dd>
<dt>账户</dt><dd id="d-account"></dd>
<dt>交易时间</dt><dd id="d-time"></dd>
<dt>对方户名</dt><dd id="d-peer"></dd>
<dt>对方账号</dt><dd id="d-peer-acct"></dd>
<dt>金额</dt><dd id="d-amount"></dd>
</dl>
</div>
<div class="detail-box" style="margin-top: 12px;">
<div class="card-title" style="margin-bottom: 10px;">归集链路</div>
<dl class="kv">
<dt>归集状态</dt><dd id="d-status"></dd>
<dt>归集往来</dt><dd id="d-pair"></dd>
<dt>本方科目</dt><dd id="d-subject"></dd>
<dt>归集批次</dt><dd id="d-batch"></dd>
<dt>说明</dt><dd id="d-note" style="font-family: var(--font-body);"></dd>
</dl>
</div>
<div class="modal-actions">
<button type="button" class="btn" id="tx-modal-ok">关闭</button>
</div>
</div>
</div>
<!-- 账户明细弹窗 -->
<div class="modal-backdrop" id="modal-account-detail">
<div class="modal">
<div class="modal-head">
<span class="modal-title" id="ad-title">账户明细</span>
<button type="button" class="modal-close" data-close="modal-account-detail" aria-label="关闭">×</button>
</div>
<p class="modal-sub" id="ad-sub">本公司 · 登记账户明细</p>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>开户银行</dt><dd id="ad-bank"></dd>
<dt>账号尾号</dt><dd id="ad-tail"></dd>
<dt>账户类型</dt><dd id="ad-type"></dd>
<dt>开户行名称</dt><dd id="ad-branch"></dd>
<dt>登记日期</dt><dd id="ad-reg"></dd>
<dt>启用状态</dt><dd id="ad-status"></dd>
<dt>审核状态</dt><dd id="ad-audit"></dd>
<dt>用途说明</dt><dd id="ad-purpose" style="font-family: var(--font-body);"></dd>
</dl>
</div>
<div class="detail-box">
<dl class="kv">
<dt>申请启用</dt><dd id="ad-effective"></dd>
<dt>流水覆盖区间</dt><dd id="ad-range"></dd>
<dt>退回原因</dt><dd id="ad-reason" style="font-family: var(--font-body);"></dd>
</dl>
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close="modal-account-detail">关闭</button>
</div>
</div>
</div>
<!-- 登记银行账户弹窗 -->
<div class="modal-backdrop" id="accountDialog">
<div class="modal">
<div class="modal-head">
<span class="modal-title">登记银行账户</span>
<button type="button" class="modal-close" data-close="accountDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub">账户提交后由管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
<form id="accountForm" novalidate>
<div class="grid grid-2">
<div class="field">
<label>开户银行</label>
<input class="input" name="bank" placeholder="例如:郑州银行" required />
</div>
<div class="field">
<label>账户类型</label>
<select class="select" name="type"><option>基本户</option><option>一般户</option><option>专用户</option></select>
</div>
</div>
<div class="field" style="margin-top: 12px;">
<label>银行账号</label>
<input class="input num-input" name="accountNumber" inputmode="numeric" required />
<span class="hint">仅尾号对外显示,完整账号仅管理员可见</span>
</div>
<div class="field" style="margin-top: 12px;">
<label>启用日期</label>
<input class="input num-input" name="startDate" type="date" required />
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close="accountDialog">取消</button>
<button type="submit" class="btn btn-primary">提交登记</button>
</div>
</form>
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script>
<script src="app.js?v=11"></script>
</body>
</html>
+990
View File
@@ -0,0 +1,990 @@
/* ─── 金牛实业资金往来管理系统 · 共享样式 ─────────────────────────────
方向:tech-utility(数据密集型工具)。六枚基础 token 绑定设计方向,
状态色仅在此 :root 块内以 oklch 派生,组件一律引用变量。 */
:root {
--bg: oklch(98% 0.005 250);
--surface: oklch(100% 0 0);
--fg: oklch(22% 0.02 240);
--muted: oklch(50% 0.018 240);
--border: oklch(90% 0.008 240);
--accent: oklch(58% 0.16 145);
/* 状态色 — 仅此处定义 */
--success: oklch(55% 0.14 150);
--warn: oklch(62% 0.14 70);
--danger: oklch(55% 0.18 25);
--info: oklch(52% 0.12 240);
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
--success-soft: color-mix(in oklch, var(--success) 12%, transparent);
--warn-soft: color-mix(in oklch, var(--warn) 14%, transparent);
--danger-soft: color-mix(in oklch, var(--danger) 11%, transparent);
--info-soft: color-mix(in oklch, var(--info) 10%, transparent);
--fg-soft: color-mix(in oklch, var(--fg) 5%, transparent);
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
--radius: 8px;
--radius-lg: 12px;
--side-w: 232px;
}
/* ─── reset ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
img, svg { display: block; }
a { color: inherit; text-decoration: none; }
button { font: inherit; cursor: pointer; }
h1, h2, h3, h4 { margin: 0; line-height: 1.3; }
p { margin: 0; }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ─── 应用外壳 ──────────────────────────────────────────────────── */
.shell { display: grid; grid-template-columns: var(--side-w) 1fr; min-height: 100vh; }
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.side-brand {
padding: 18px 20px 16px;
border-bottom: 1px solid var(--border);
}
.side-brand .brand-name { font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
.side-brand .brand-sub { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 3px; letter-spacing: 0.02em; }
.side-role {
display: inline-flex; align-items: center; gap: 6px;
margin-top: 10px;
padding: 3px 9px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 11px; font-weight: 600;
}
.side-role.company { background: var(--info-soft); color: var(--info); }
.side-nav { flex: 1; overflow-y: auto; padding: 12px 10px; }
.side-nav .nav-group { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em; color: var(--muted); padding: 14px 10px 6px; }
.side-nav a {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--muted);
font-size: 13.5px;
margin-bottom: 1px;
}
.side-nav a svg { width: 16px; height: 16px; flex: none; }
.side-nav a:hover { color: var(--fg); background: var(--fg-soft); }
.side-nav a.active {
color: var(--fg);
background: var(--accent-soft);
font-weight: 600;
}
.side-nav a.active svg { color: var(--accent); }
.side-nav a .nav-badge {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10.5px;
padding: 1px 7px;
border-radius: 999px;
background: var(--danger-soft);
color: var(--danger);
font-weight: 600;
}
.side-foot { border-top: 1px solid var(--border); padding: 12px 16px; }
.side-foot .user-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
.side-foot .user-row > div { min-width: 0; }
.side-foot .avatar {
width: 30px; height: 30px; border-radius: 50%;
background: var(--fg); color: var(--surface);
display: grid; place-items: center;
font-size: 12px; font-weight: 600;
flex: none;
}
.side-foot .user-name { font-size: 13px; font-weight: 600; white-space: nowrap; }
.side-foot .user-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.side-foot .logout { margin-left: auto; color: var(--muted); font-size: 12px; padding: 4px 6px; border-radius: 6px; background: none; border: 0; flex: none; white-space: nowrap; }
.side-foot .logout:hover { color: var(--danger); background: var(--danger-soft); }
.main { min-width: 0; display: flex; flex-direction: column; }
.topbar {
position: sticky; top: 0; z-index: 20;
background: color-mix(in oklch, var(--bg) 88%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
padding: 12px 28px;
display: flex; align-items: center; gap: 16px;
flex-wrap: wrap;
}
.topbar .crumb { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.topbar .crumb b { color: var(--fg); font-weight: 600; }
.topbar .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.content { padding: 24px 28px 64px; max-width: 1440px; width: 100%; margin-inline: auto; }
/* ─── 页头 ──────────────────────────────────────────────────────── */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }
.page-head h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.015em; }
.page-head .page-sub { color: var(--muted); font-size: 13px; margin-top: 5px; max-width: 72ch; }
.page-head .page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ─── 按钮 ──────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 8px 14px;
min-height: 34px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
color: var(--fg);
font-size: 13px; font-weight: 550;
transition: background 0.12s ease, border-color 0.12s ease;
}
.btn:hover { border-color: color-mix(in oklch, var(--fg) 40%, var(--border)); background: var(--fg-soft); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 88%, black); border-color: color-mix(in oklch, var(--accent) 88%, black); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn-ghost:hover { color: var(--fg); background: var(--fg-soft); }
.btn-danger { color: var(--danger); border-color: color-mix(in oklch, var(--danger) 35%, var(--border)); }
.btn-danger:hover { background: var(--danger-soft); border-color: var(--danger); }
.btn-sm { padding: 4px 10px; min-height: 26px; font-size: 12px; border-radius: 6px; }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.btn-primary[disabled] { opacity: 1; background: var(--fg-soft); border-color: var(--border); color: var(--muted); }
/* ─── 卡片 ──────────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px 20px;
}
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.card-title { font-size: 14px; font-weight: 650; letter-spacing: -0.005em; }
.card-title .sub { display: block; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 2px; }
/* ─── 指标卡 ────────────────────────────────────────────────────── */
.stat-card { padding: 16px 18px; }
.stat-card .stat-label { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.stat-card .stat-value {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 26px; font-weight: 650;
letter-spacing: -0.02em;
margin-top: 6px;
}
.stat-card .stat-value .unit { font-size: 13px; color: var(--muted); font-weight: 400; margin-left: 2px; }
.stat-card .stat-foot { font-size: 12px; color: var(--muted); margin-top: 6px; }
.stat-card.alert .stat-value { color: var(--danger); }
.stat-card.warn .stat-value { color: var(--warn); }
.stat-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.stat-dot.danger { background: var(--danger); }
.stat-dot.warn { background: var(--warn); }
.stat-dot.success { background: var(--success); }
.stat-dot.info { background: var(--info); }
/* ─── 栅格与布局工具 ────────────────────────────────────────────── */
.grid { display: grid; gap: 14px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.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; }
.muted { color: var(--muted); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.meta { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.mt-0 { margin-top: 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 {
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);
}
}
/* ─── 数据表 ────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); }
.ds-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 640px; }
.ds-table th, .ds-table td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
.ds-table th {
color: var(--muted); font-weight: 550;
font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.05em;
background: var(--bg);
position: sticky; top: 0;
}
.ds-table tbody tr:hover { background: var(--fg-soft); }
.ds-table tbody tr:last-child td { border-bottom: 0; }
.ds-table .num-col { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
.ds-table td.wrap, .ds-table th.wrap { white-space: normal; min-width: 180px; }
.ds-table .cell-main { font-weight: 550; }
.ds-table .cell-sub { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
.ds-table tr.clickable { cursor: pointer; }
.ds-table .amt-in { color: var(--success); }
.ds-table .amt-out { color: var(--danger); }
.table-foot { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); }
/* ─── 徽章 / 状态 ───────────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11.5px; font-weight: 600;
white-space: nowrap;
}
.pill::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.pill-success { background: var(--success-soft); color: var(--success); }
.pill-warn { background: var(--warn-soft); color: color-mix(in oklch, var(--warn) 80%, black); }
.pill-danger { background: var(--danger-soft); color: var(--danger); }
.pill-info { background: var(--info-soft); color: var(--info); }
.pill-muted { background: var(--fg-soft); color: var(--muted); }
.pill-accent { background: var(--accent-soft); color: var(--accent); }
.tag {
display: inline-flex; align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
/* ─── 筛选栏与表单 ──────────────────────────────────────────────── */
.filters {
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-bottom: 14px;
}
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.field > label { font-size: 12px; color: var(--muted); font-weight: 550; }
.input, .select, .textarea, .field .input, .field .select, .field .textarea {
padding: 7px 11px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--fg);
font: inherit;
font-size: 13px;
min-height: 34px;
}
.input:focus, .select:focus, .textarea:focus, .field .input:focus, .field .select:focus, .field .textarea:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.input.num-input { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.textarea { min-height: 84px; resize: vertical; line-height: 1.55; }
.field .hint { font-size: 11.5px; color: var(--muted); }
.field .hint.error { color: var(--danger); }
/* ─── Tabs ──────────────────────────────────────────────────────── */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; }
.tabs button {
background: none; border: 0;
padding: 9px 14px;
font-size: 13px; font-weight: 550;
color: var(--muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
flex: none;
white-space: nowrap;
}
.tabs button:hover { color: var(--fg); }
.tabs button.active { color: var(--fg); border-bottom-color: var(--accent); }
.tabs button .tab-count {
font-family: var(--font-mono); font-size: 10.5px;
padding: 1px 6px; border-radius: 999px;
background: var(--fg-soft); color: var(--muted);
margin-left: 5px;
}
.tabs button.active .tab-count { background: var(--accent-soft); color: var(--accent); }
/* ─── 时间轴(账期) ─────────────────────────────────────────────── */
.timeline { display: flex; gap: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); padding: 0; }
.tl-cell { flex: 1; padding: 12px 10px; border-right: 1px solid var(--border); text-align: center; min-width: 0; }
.tl-cell:last-child { border-right: 0; }
.tl-cell .tl-month { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: 0.04em; }
.tl-cell .tl-state { font-size: 12px; font-weight: 600; margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 5px; }
.tl-cell.closed { background: var(--fg-soft); }
.tl-cell.closed .tl-state { color: var(--muted); }
.tl-cell.current { background: var(--accent-soft); }
.tl-cell.current .tl-state { color: var(--accent); }
.tl-cell.open .tl-state { color: var(--fg); }
/* ─── 进度条 ────────────────────────────────────────────────────── */
.progress { height: 6px; border-radius: 999px; background: var(--fg-soft); overflow: hidden; }
.progress > span { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
.progress.warn > span { background: var(--warn); }
.progress.danger > span { background: var(--danger); }
/* ─── 列表行(快查 / 待办 / 通知) ────────────────────────────────── */
.list-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.list-row:last-child { border-bottom: 0; }
.list-row .lr-main { min-width: 0; flex: 1; }
.list-row .lr-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-row .lr-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
.list-row .lr-side { text-align: right; flex: none; }
[data-detail] { cursor: pointer; }
.list-row[data-detail]:hover { background: var(--fg-soft); }
.list-row[data-detail] { margin-inline: -4px; padding-inline: 4px; border-radius: var(--radius); }
/* ─── 弹窗 ──────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed; inset: 0; z-index: 50;
background: color-mix(in oklch, var(--fg) 45%, transparent);
display: none;
align-items: center; justify-content: center;
padding: 24px;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
width: 100%; max-width: 560px;
max-height: 86vh; overflow-y: auto;
padding: 22px 24px;
box-shadow: 0 18px 50px color-mix(in oklch, var(--fg) 25%, transparent);
}
.modal.wide { max-width: 760px; }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.modal-title { font-size: 16px; font-weight: 700; }
.modal-close { background: none; border: 0; color: var(--muted); font-size: 18px; padding: 4px 8px; border-radius: 6px; }
.modal-close:hover { background: var(--fg-soft); color: var(--fg); }
.modal-sub { font-size: 12.5px; color: var(--muted); margin-bottom: 16px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
/* ─── 明细展开行 ────────────────────────────────────────────────── */
.detail-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
font-size: 12.5px;
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 18px; font-size: 12.5px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* ─── 提示条 ────────────────────────────────────────────────────── */
.notice {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
font-size: 13px;
background: var(--surface);
}
.notice.warn { background: var(--warn-soft); border-color: color-mix(in oklch, var(--warn) 30%, transparent); }
.notice.danger { background: var(--danger-soft); border-color: color-mix(in oklch, var(--danger) 30%, transparent); }
.notice.info { background: var(--info-soft); border-color: color-mix(in oklch, var(--info) 25%, transparent); }
.notice .n-title { font-weight: 650; }
.notice .n-body { color: color-mix(in oklch, var(--fg) 80%, var(--muted)); margin-top: 2px; font-size: 12.5px; }
/* ─── 登录页 ────────────────────────────────────────────────────── */
.login-wrap {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
}
.login-aside {
background: var(--fg);
color: var(--surface);
padding: 48px 56px;
display: flex; flex-direction: column;
}
.login-aside .brand-mark { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; opacity: 0.65; }
.login-aside h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin-top: 18px; line-height: 1.25; }
.login-aside .aside-sub { opacity: 0.72; font-size: 14px; margin-top: 14px; max-width: 40ch; }
.login-aside .aside-list { margin-top: auto; display: flex; flex-direction: column; gap: 12px; }
.login-aside .aside-item { display: flex; gap: 10px; font-size: 13px; opacity: 0.85; align-items: baseline; }
.login-aside .aside-item .tick { font-family: var(--font-mono); color: var(--accent); }
.login-panel { display: grid; place-items: center; padding: 48px 32px; }
.login-card { width: 100%; max-width: 400px; }
.login-card h2 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
.login-card .login-sub { color: var(--muted); font-size: 13px; margin: 6px 0 24px; }
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 18px; }
.role-switch button {
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
padding: 12px 10px;
text-align: left;
transition: border-color 0.12s ease, background 0.12s ease;
}
.role-switch button .r-name { font-size: 13.5px; font-weight: 650; }
.role-switch button .r-desc { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
.role-switch button.active { border-color: var(--accent); background: var(--accent-soft); }
.role-switch button.active .r-name { color: var(--accent); }
.login-card .field { margin-bottom: 14px; }
.login-foot { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
.login-foot a { color: var(--accent); font-weight: 550; }
.login-foot a:hover { text-decoration: underline; }
/* 端标识:登录页顶部大字区分管理端 / 公司端 */
.login-role-title {
font-size: 34px;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1.15;
margin-bottom: 6px;
}
.login-role-title.admin { color: var(--accent); }
.login-role-title.company { color: var(--info); }
.login-role-caption { color: var(--muted); font-size: 13px; margin-bottom: 22px; }
/* 管理端青绿 / 公司端品蓝侧栏色块 */
.login-aside.company { background: var(--info); }
.login-aside.company .aside-item .tick { color: var(--surface); opacity: 0.85; }
.login-back {
display: inline-flex; align-items: center; gap: 6px;
margin-bottom: 20px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--fg);
font-size: 12.5px; font-weight: 600;
}
.login-back:hover { border-color: var(--accent); color: var(--accent); }
.login-back.company:hover { border-color: var(--info); color: var(--info); }
@media (max-width: 900px) {
.login-wrap { grid-template-columns: 1fr; }
.login-aside { display: none; }
}
/* ─── 空状态 ────────────────────────────────────────────────────── */
.empty {
padding: 40px 20px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
.empty .e-title { font-weight: 600; color: var(--fg); margin-bottom: 4px; }
/* ─── 账期流程条(公司端工作台) ─────────────────────────────────── */
.flow { display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
.flow-step { padding: 14px 16px 13px; border-right: 1px solid var(--border); min-width: 0; }
.flow-step:last-child { border-right: 0; }
a.flow-step { cursor: pointer; transition: background 0.12s ease; }
a.flow-step:hover { background: var(--fg-soft); }
.flow-step .fs-top { display: flex; align-items: center; gap: 8px; }
.flow-step .fs-idx { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.flow-step .fs-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--border); flex: none; }
.flow-step .fs-name { font-size: 13.5px; font-weight: 650; }
.flow-step .fs-state { font-size: 12px; font-weight: 600; margin-top: 7px; color: var(--muted); }
.flow-step .fs-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
.flow-step.done .fs-dot { background: var(--success); }
.flow-step.done .fs-state { color: var(--success); }
.flow-step.part .fs-dot { background: var(--warn); }
.flow-step.part .fs-state { color: color-mix(in oklch, var(--warn) 78%, black); }
.flow-step.doing { background: var(--warn-soft); }
.flow-step.doing .fs-dot { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn) 22%, transparent); }
.flow-step.doing .fs-state { color: color-mix(in oklch, var(--warn) 78%, black); }
a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, transparent); }
@media (max-width: 860px) {
.flow { grid-template-columns: 1fr; }
.flow-step { border-right: 0; border-bottom: 1px solid var(--border); }
.flow-step:last-child { border-bottom: 0; }
}
/* ─── 迷你指标(2×2 数字块) ────────────────────────────────────── */
.mini-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mini-stat { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 13px; min-width: 0; }
.mini-stat .ms-label { font-size: 11.5px; color: var(--muted); }
.mini-stat .ms-value { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; margin-top: 3px; }
.mini-stat .ms-value .unit { font-size: 11px; font-weight: 400; color: var(--muted); margin-left: 2px; }
.mini-stat .ms-value.pos { color: var(--success); }
.mini-stat .ms-value.neg { color: var(--danger); }
/* ─── 上传组件(拖放区 / 文件条 / 附件选择) ────────────────────── */
.dropzone {
border: 1.5px dashed color-mix(in oklch, var(--fg) 28%, var(--border));
border-radius: var(--radius-lg);
background: var(--bg);
padding: 30px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
}
.dropzone:hover, .dropzone:focus-visible { border-color: var(--accent); background: var(--accent-soft); }
.dropzone.dragover { border-color: var(--accent); border-style: solid; background: var(--accent-soft); }
.dropzone svg { width: 28px; height: 28px; margin: 0 auto 10px; color: var(--muted); }
.dropzone .dz-title { font-size: 14px; font-weight: 650; }
.dropzone .dz-sub { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
.dropzone .dz-sub .dz-browse { color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
.dropzone .dz-formats { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 12px; letter-spacing: 0.02em; }
.file-chip {
display: flex; align-items: center; gap: 10px;
margin-top: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.file-chip svg { width: 18px; height: 18px; color: var(--muted); flex: none; }
.file-chip .fc-info { min-width: 0; flex: 1; }
.file-chip .fc-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-chip .fc-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
.file-chip .fc-remove { background: none; border: 0; color: var(--muted); font-size: 16px; line-height: 1; padding: 4px 8px; border-radius: 6px; flex: none; }
.file-chip .fc-remove:hover { color: var(--danger); background: var(--danger-soft); }
.attach-pick {
display: inline-flex; align-items: center; gap: 7px;
padding: 7px 12px;
min-height: 34px;
border: 1.5px dashed color-mix(in oklch, var(--fg) 28%, var(--border));
border-radius: var(--radius);
color: var(--muted);
font-size: 13px;
cursor: pointer;
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.attach-pick svg { width: 15px; height: 15px; }
.attach-pick:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* ─── 数据表合计行 ─────────────────────────────────────────────── */
.ds-table tfoot td { background: var(--bg); font-weight: 650; border-top: 1.5px solid color-mix(in oklch, var(--fg) 30%, var(--border)); border-bottom: 0; }
/* ─── 入口页(index) ────────────────────────────────────────────── */
.portal-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; }
.portal-inner { width: 100%; max-width: 880px; }
.portal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 28px; }
.portal-card {
display: block;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
transition: border-color 0.12s ease, transform 0.12s ease;
}
.portal-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.portal-card .pc-role { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; color: var(--accent); }
.portal-card h3 { font-size: 17px; font-weight: 700; margin-top: 8px; }
.portal-card p { color: var(--muted); font-size: 13px; margin-top: 6px; }
.portal-card .pc-go { font-size: 12.5px; font-weight: 600; color: var(--accent); margin-top: 14px; }
@media (max-width: 720px) { .portal-grid { grid-template-columns: 1fr; } }
/* ─── SPA 视图切换 / 无障碍结构类 ───────────────────────────────── */
.app-view { display: none; }
.app-view.is-active { display: block; }
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.skip-link {
position: fixed; top: 8px; left: 50%; z-index: 500;
padding: 8px 14px;
border-radius: var(--radius);
background: var(--accent); color: var(--surface);
font-size: 13px; font-weight: 600;
transform: translate(-50%, -150%);
transition: transform 0.12s ease;
}
.skip-link:focus { transform: translate(-50%, 0); }
/* ─── 图标按钮 / 窄屏侧栏折叠 ──────────────────────────────────── */
.icon-button {
display: inline-grid; place-items: center;
width: 34px; height: 34px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface); color: var(--muted);
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
}
.icon-button:hover { color: var(--fg); border-color: color-mix(in oklch, var(--fg) 40%, var(--border)); background: var(--fg-soft); }
.icon-button svg { width: 18px; height: 18px; }
.menu-button { display: none; }
@media (max-width: 860px) {
.menu-button { display: inline-grid; }
.shell { grid-template-columns: 1fr; }
.sidebar {
position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
width: var(--side-w); height: 100dvh;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.sidebar.is-open { transform: none; box-shadow: 0 0 48px color-mix(in oklch, var(--fg) 22%, transparent); }
}
/* ─── Toast 轻量提示 ───────────────────────────────────────────── */
.toast-region { position: fixed; right: 16px; bottom: 16px; z-index: 300; display: grid; gap: 8px; }
.toast {
display: flex; align-items: flex-start; gap: 9px;
min-width: 260px; max-width: 380px;
padding: 11px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: 0 12px 34px color-mix(in oklch, var(--fg) 18%, transparent);
font-size: 13px;
}
.toast .t-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 5px; background: var(--muted); }
.toast .t-body { min-width: 0; }
.toast .t-title { font-weight: 650; }
.toast .t-detail { color: var(--muted); font-size: 12px; margin-top: 1px; }
.toast.success { border-color: color-mix(in oklch, var(--success) 35%, var(--border)); }
.toast.success .t-dot { background: var(--success); }
.toast.warn { border-color: color-mix(in oklch, var(--warn) 40%, var(--border)); }
.toast.warn .t-dot { background: var(--warn); }
.toast.danger { border-color: color-mix(in oklch, var(--danger) 35%, var(--border)); }
.toast.danger .t-dot { background: var(--danger); }
.toast.info { border-color: color-mix(in oklch, var(--info) 35%, var(--border)); }
.toast.info .t-dot { background: var(--info); }
@media (max-width: 720px) {
.toast-region { right: 12px; bottom: 12px; left: 12px; }
.toast { min-width: 0; max-width: none; }
}
/* ─── 侧滑详情抽屉(用 modal/card token 重画) ─────────────────── */
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; z-index: 60;
width: min(400px, calc(100vw - 24px));
display: flex; flex-direction: column;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -18px 0 50px color-mix(in oklch, var(--fg) 22%, transparent);
transform: translateX(100%);
visibility: hidden;
transition: transform 0.22s ease, visibility 0.22s;
}
.drawer.is-open { transform: none; visibility: visible; }
.drawer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 20px 12px; border-bottom: 1px solid var(--border); }
.drawer-head .d-title { font-size: 16px; font-weight: 700; margin-top: 6px; }
.drawer-head .d-desc { color: var(--muted); font-size: 12.5px; margin-top: 4px; }
.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.drawer-body .kv { grid-template-columns: auto 1fr; gap: 8px 18px; }
.drawer-body .kv dt { color: var(--muted); white-space: nowrap; }
.drawer-body .kv dd { text-align: right; word-break: break-all; }
.drawer-tip { margin: 0 20px 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--muted); font-size: 12.5px; }
.drawer-tip strong { display: block; color: var(--fg); margin-bottom: 4px; }
.drawer-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
/* ─── 加载中骨架 / 表内加载行 ──────────────────────────────────── */
.skeleton { display: block; border-radius: var(--radius); background: var(--fg-soft); position: relative; overflow: hidden; }
.skeleton::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--surface) 60%, transparent), transparent);
animation: skeleton-shimmer 1.3s ease-in-out infinite;
}
@keyframes skeleton-shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.skeleton-line { height: 12px; margin-bottom: 10px; }
.loading-row { padding: 40px 20px; text-align: center; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; letter-spacing: 0.04em; }
.loading-inline { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-family: var(--font-mono); font-size: 12.5px; }
.loading-inline::before {
content: ""; width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--border); border-top-color: var(--accent);
animation: loading-spin 0.7s linear infinite;
}
@keyframes loading-spin { to { transform: rotate(360deg); } }
/* ─── 提示条补充:成功态 ───────────────────────────────────────── */
.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); }
+30 -45
View File
@@ -1,48 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="河南金牛实业集团内部资金往来与银行流水管理" />
<title>登录 · 河南金牛实业集团</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body class="entry-page">
<!--
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
STORY: 居中集团登录卡:先确认集团身份与系统名称,再选择工作端口登录。
FIRST VIEWPORT: 一张悬浮玻璃登录卡居于氛围光中央,集团名称置顶,端口选择、账号表单与系统事实依次排列。
FORM: 用户参考图锁定的深色玻璃财务工作台,Operate 模式;seed key fe8a50aa。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<main class="entry-shell">
<section class="entry-card" aria-labelledby="login-title">
<div class="entry-brand"><span class="brand-mark"></span><span><strong id="product-name">河南金牛实业集团</strong><small>集团资金往来管理系统</small></span></div>
<form class="entry-form" id="loginForm">
<header><h2 id="login-title">登录</h2><p>请选择与账号一致的工作端口</p></header>
<div class="role-switch" role="radiogroup" aria-label="工作端口">
<label><input type="radio" name="role" value="admin" checked /><span><svg><use href="icons.svg#shield-check"/></svg><b>总账管理端</b><small>集团管理员</small></span></label>
<label><input type="radio" name="role" value="company" /><span><svg><use href="icons.svg#building"/></svg><b>公司业务端</b><small>公司出纳</small></span></label>
</div>
<label class="field"><span>账号</span><input name="username" autocomplete="username" required /></label>
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
<div id="changePassword" hidden>
<p class="entry-note">首次登录须修改密码,请设置新密码后再进入工作台。</p>
<label class="field"><span>新密码</span><input name="new_password" type="password" autocomplete="new-password" /></label>
<label class="field"><span>确认新密码</span><input name="confirm_password" type="password" autocomplete="new-password" /></label>
</div>
<p class="entry-note" id="loginError" role="alert" hidden></p>
<label class="check-field"><input type="checkbox" checked />记住本次登录</label>
<button class="button primary wide" type="submit"><span id="loginAction">进入总账管理端</span><svg><use href="icons.svg#chevron-right"/></svg></button>
</form>
<dl class="entry-facts">
<div><dt>全局起算日</dt><dd>2026.01.01</dd></div>
<div><dt>当前账期</dt><dd>2026 年 7 月</dd></div>
<div><dt>覆盖银行</dt><dd>已对接 6 家</dd></div>
</dl>
</section>
</main>
<script src="app.js"></script>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>金牛实业资金往来管理系统 · 入口</title>
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body>
<div class="portal-wrap">
<div class="portal-inner">
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
<div class="portal-grid">
<a class="portal-card" href="login-admin.html">
<span class="pc-role">管理端 · 7 个页面</span>
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
<span class="pc-go">去管理端登录 →</span>
</a>
<a class="portal-card" href="login-company.html">
<span class="pc-role">公司业务端 · 7 个页面</span>
<h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 往来确认 / 银行账户 / 通知</h3>
<p>面向成员公司出纳:上传银行流水、登记手工往来、确认单边匹配与科目,跟踪本月完成进度。</p>
<span class="pc-go">去公司端登录 →</span>
</a>
</div>
</div>
</div>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 金牛实业资金往来管理系统</title>
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-role="admin">
<div class="login-wrap">
<aside class="login-aside">
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
<div class="aside-list">
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
</div>
</aside>
<main class="login-panel">
<div class="login-card">
<a class="login-back" href="index.html">← 返回入口页</a>
<div class="login-role-title admin">管理端</div>
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
<h2>登录系统</h2>
<p class="login-sub">请输入账号密码登录管理端。首次登录需修改初始密码。</p>
<form id="login-form" novalidate>
<div class="field">
<label for="account">账号</label>
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
<span class="hint error" id="account-err" hidden>请输入账号</span>
</div>
<div class="field">
<label for="password">密码</label>
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
<span class="hint error" id="password-err" hidden>请输入密码</span>
</div>
<div class="field">
<span class="hint error" id="login-err" role="alert" hidden></span>
</div>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
</form>
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
</div>
</main>
</div>
<div class="modal-backdrop" id="pwd-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
<div class="modal-head">
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
</div>
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
<div class="field" style="margin-bottom: 12px;">
<label for="new-pwd">新密码</label>
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
</div>
<div class="field">
<label for="new-pwd2">确认新密码</label>
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
</div>
<div class="field" style="margin-top: 12px;">
<span class="hint error" id="pwd-err" role="alert" hidden></span>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
</div>
</div>
</div>
<script src="login.js?v=5"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 金牛实业资金往来管理系统</title>
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-role="company">
<div class="login-wrap">
<aside class="login-aside company">
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
<div class="aside-list">
<div class="aside-item"><span class="tick">01</span><span>银行流水自动归集,公司间往来余额实时轧算</span></div>
<div class="aside-item"><span class="tick">02</span><span>单边匹配、流水断档、科目确认集中审核</span></div>
<div class="aside-item"><span class="tick">03</span><span>月度结账检查:流水提交、账户连续、审核完成</span></div>
<div class="aside-item"><span class="tick">04</span><span>汇总 → 公司 → 账户 → 原始流水,四级穿透追溯</span></div>
</div>
</aside>
<main class="login-panel">
<div class="login-card">
<a class="login-back company" href="index.html">← 返回入口页</a>
<div class="login-role-title company">公司端</div>
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
<h2>登录系统</h2>
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
<form id="login-form" novalidate>
<div class="field">
<label for="account">账号</label>
<input class="input" id="account" placeholder="请输入账号" autocomplete="username" />
<span class="hint error" id="account-err" hidden>请输入账号</span>
</div>
<div class="field">
<label for="password">密码</label>
<input class="input" id="password" type="password" placeholder="请输入密码" autocomplete="current-password" />
<span class="hint error" id="password-err" hidden>请输入密码</span>
</div>
<div class="field">
<span class="hint error" id="login-err" role="alert" hidden></span>
</div>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
</form>
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
</div>
</main>
</div>
<div class="modal-backdrop" id="pwd-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pwd-title">
<div class="modal-head">
<span class="modal-title" id="pwd-title">首次登录 · 修改初始密码</span>
</div>
<p class="modal-sub">为保障资金安全,首次登录必须修改初始密码。新密码需 8 位以上,且包含字母与数字。</p>
<div class="field" style="margin-bottom: 12px;">
<label for="new-pwd">新密码</label>
<input class="input" id="new-pwd" type="password" placeholder="8 位以上,含字母与数字" autocomplete="new-password" />
<span class="hint error" id="new-pwd-err" hidden>密码需 8 位以上,且包含字母与数字</span>
</div>
<div class="field">
<label for="new-pwd2">确认新密码</label>
<input class="input" id="new-pwd2" type="password" placeholder="再次输入新密码" autocomplete="new-password" />
<span class="hint error" id="new-pwd2-err" hidden>两次输入的密码不一致</span>
</div>
<div class="field" style="margin-top: 12px;">
<span class="hint error" id="pwd-err" role="alert" hidden></span>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="pwd-confirm">确认修改并进入系统</button>
</div>
</div>
</div>
<script src="login.js?v=5"></script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
const ROLE = document.body.dataset.role || "admin";
const REDIRECT = ROLE === "admin" ? "admin.html" : "company.html";
const form = document.getElementById("login-form");
const accountInput = document.getElementById("account");
const passwordInput = document.getElementById("password");
const accountErr = document.getElementById("account-err");
const passwordErr = document.getElementById("password-err");
const loginErr = document.getElementById("login-err");
const submitButton = form.querySelector('button[type="submit"]');
const modal = document.getElementById("pwd-modal");
const newPwd = document.getElementById("new-pwd");
const newPwd2 = document.getElementById("new-pwd2");
const newPwdErr = document.getElementById("new-pwd-err");
const newPwd2Err = document.getElementById("new-pwd2-err");
const pwdErr = document.getElementById("pwd-err");
const pwdConfirm = document.getElementById("pwd-confirm");
const submitLabel = "登 录";
function setLoading(loading) {
submitButton.disabled = loading;
submitButton.textContent = loading ? "登录中…" : submitLabel;
accountInput.disabled = loading;
passwordInput.disabled = loading;
}
function showLoginError(message) {
loginErr.textContent = message || "";
loginErr.hidden = !message;
}
function openPasswordModal() {
newPwd.value = "";
newPwd2.value = "";
newPwdErr.hidden = true;
newPwd2Err.hidden = true;
pwdErr.hidden = true;
modal.classList.add("open");
newPwd.focus();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
showLoginError("");
const account = accountInput.value.trim();
const password = passwordInput.value;
accountErr.hidden = Boolean(account);
passwordErr.hidden = Boolean(password);
if (!account || !password) return;
setLoading(true);
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: account, password, portal: ROLE }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showLoginError(result?.message || "登录服务暂时不可用,请稍后重试。");
setLoading(false);
return;
}
if (result.must_change_password) {
setLoading(false);
openPasswordModal();
return;
}
window.location.href = REDIRECT;
});
pwdConfirm.addEventListener("click", async () => {
const p1 = newPwd.value;
const p2 = newPwd2.value;
const valid = p1.length >= 8 && /[a-zA-Z]/.test(p1) && /\d/.test(p1);
newPwdErr.hidden = valid;
newPwd2Err.hidden = p1 === p2;
pwdErr.hidden = true;
if (!valid || p1 !== p2) return;
pwdConfirm.disabled = true;
const response = await fetch("/api/password/change", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: passwordInput.value, new_password: p1 }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
pwdErr.textContent = result?.message || "修改密码失败,请稍后重试。";
pwdErr.hidden = false;
pwdConfirm.disabled = false;
return;
}
window.location.href = REDIRECT;
});
-936
View File
@@ -1,936 +0,0 @@
:root {
color-scheme: dark;
--font-ui: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
--font-data: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
--color-bg: #050505;
--color-bg-soft: #0a0a0a;
--color-nav: #0a0a0a;
--color-surface: rgba(22, 22, 22, 0.82);
--color-surface-solid: #161616;
--color-surface-raised: #1c1c1c;
--color-surface-muted: rgba(255, 255, 255, 0.035);
--color-line: rgba(255, 255, 255, 0.09);
--color-line-strong: rgba(255, 255, 255, 0.16);
--color-ink: #f2f7f4;
--color-ink-soft: #a6b0aa;
--color-ink-muted: #89938d;
--color-primary: #37eb89;
--color-primary-strong: #70ffae;
--color-primary-dark: #08160e;
--color-primary-wash: rgba(55, 235, 137, 0.11);
--color-positive: #37eb89;
--color-positive-wash: rgba(55, 235, 137, 0.1);
--color-warning: #ffbc52;
--color-warning-wash: rgba(255, 188, 82, 0.11);
--color-danger: #ff626d;
--color-danger-wash: rgba(255, 98, 109, 0.11);
--color-info: #66a8ff;
--color-info-wash: rgba(102, 168, 255, 0.11);
--row-h-evidence: 55px;
--z-drawer: 150;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 22px;
--radius-xl: 28px;
--shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035);
--shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.06), inset 0 -1px 0 rgba(0, 0, 0, 0.22);
--shadow-glow: 0 0 28px rgba(55, 235, 137, 0.12);
--duration-fast: 150ms;
--duration-standard: 260ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--color-bg); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 14px/1.6 var(--font-ui); -webkit-font-smoothing: antialiased; }
button, input, select, textarea { font: inherit; }
button, a, summary { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, p, dl, dd { margin: 0; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
::selection { background: rgba(55, 235, 137, 0.26); color: #fff; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 10px; background: rgba(255, 255, 255, 0.15); background-clip: padding-box; }
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.skip-link { position: fixed; top: 8px; left: 50%; z-index: 500; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-primary); color: var(--color-primary-dark); transform: translate(-50%, -150%); }
.skip-link:focus { transform: translate(-50%, 0); }
:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }
[hidden] { display: none !important; }
/* Application frame */
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 266px minmax(0, 1fr); }
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 248px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 12, 12, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 20px; border-bottom: 1px solid var(--color-line); }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; }
.brand-copy { display: flex; flex-direction: column; min-width: 0; }
.brand-copy strong { font-size: 15px; }
.brand-copy small, .user-block small, .company-context small { color: var(--color-ink-muted); font-size: 10px; }
.company-context { min-height: 64px; display: flex; align-items: center; gap: 10px; margin: 12px 4px 2px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.company-context > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary); color: var(--color-primary-dark); font-weight: 800; }
.company-context div { display: flex; flex-direction: column; }
.nav-list { display: grid; gap: 8px; padding: 14px 2px; overflow-y: auto; }
.nav-item { position: relative; width: 100%; min-height: 46px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
.nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.075); color: var(--color-ink); box-shadow: inset 0 1px rgba(255, 255, 255, 0.045), 0 8px 22px rgba(0, 0, 0, 0.2); }
.nav-item.is-active svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); }
.nav-item svg { width: 17px; flex: 0 0 auto; }
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-item b { min-width: 19px; height: 19px; display: grid; place-items: center; margin-left: auto; border-radius: 7px; background: var(--color-primary-wash); color: var(--color-primary); font-size: 10px; }
.sidebar-footer { display: grid; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-line); }
.user-block { min-height: 51px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; }
.user-block > span:last-child { display: flex; flex-direction: column; }
.avatar { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #242c28; color: var(--color-primary-strong); font-weight: 700; }
.workspace { min-width: 0; grid-column: 2; }
.topbar { position: sticky; top: 0; z-index: 90; height: 56px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 5, 5, 0.82); backdrop-filter: blur(22px); }
.topbar-spacer { flex: 1; }
.workspace-name { display: flex; flex-direction: column; min-width: 150px; }
.workspace-name span { color: var(--color-ink-muted); font-size: 10px; }
.workspace-name strong { font-size: 14px; }
.demo-badge { padding: 3px 8px; border: 1px solid rgba(255, 188, 82, 0.22); border-radius: 8px; background: var(--color-warning-wash); color: var(--color-warning); font-size: 10px; }
.topbar-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.search-box { width: clamp(200px, 22vw, 340px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
.search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.search-box input::placeholder, input::placeholder, textarea::placeholder { color: #626b66; }
.period-button, .icon-button, .inside-icon, .swap-button { display: inline-grid; place-items: center; border: 1px solid var(--color-line); background: var(--color-surface-muted); color: var(--color-ink-soft); cursor: pointer; transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.period-button { min-height: 40px; grid-auto-flow: column; gap: 8px; padding: 0 13px; border-radius: 14px; }
.icon-button { position: relative; width: 40px; height: 40px; border-radius: 13px; }
.inside-icon { width: 36px; height: 36px; border: 0; background: transparent; }
.period-button:hover, .icon-button:hover, .inside-icon:hover, .swap-button:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.07); color: var(--color-ink); transform: translateY(-1px); }
.notification-dot { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; display: grid; place-items: center; border: 2px solid var(--color-bg); border-radius: 9px; background: var(--color-danger); color: #fff; font: 9px var(--font-data); }
.menu-button { display: none; }
main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
.app-view { display: none; }
.app-view.is-active { display: block; }
.page-heading { min-height: 56px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.page-heading h1 { font-size: clamp(22px, 2.4vw, 30px); line-height: 1.2; letter-spacing: 0; }
.page-heading p { margin-top: 5px; color: var(--color-ink-muted); }
.content-toolbar { display: contents; }
.content-toolbar > .search-box { margin-inline: auto; width: clamp(260px, 42vw, 520px); }
.content-toolbar .toolbar-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.dashboard-head { align-items: center; gap: 20px; margin-bottom: 20px; }
/* Commands and fields */
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; font-weight: 700; cursor: pointer; transition: transform var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast); }
.button:hover { transform: translateY(-1px); }
.button:active { transform: translateY(0) scale(0.985); }
.button.primary { background: var(--color-primary); color: var(--color-primary-dark); box-shadow: 0 8px 24px rgba(55, 235, 137, 0.18); }
.button.primary:hover { background: var(--color-primary-strong); box-shadow: 0 10px 30px rgba(55, 235, 137, 0.25); }
.button.secondary { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink); }
.button.secondary:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.08); }
.button.small { min-height: 34px; padding-inline: 11px; border-radius: 11px; font-size: 11px; }
.button.wide { width: 100%; }
.button:disabled { opacity: 0.42; cursor: not-allowed; transform: none; }
.text-button { padding: 3px 0; border: 0; background: none; color: var(--color-primary); cursor: pointer; }
.text-button:hover { color: var(--color-primary-strong); }
.full-text-button { width: 100%; padding: 13px; border-top: 1px solid var(--color-line); }
.field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--color-ink-soft); }
.field > span { color: var(--color-ink-muted); font-size: 11px; }
.field input, .field select, .field textarea { width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--color-line); border-radius: 11px; outline: 0; background: rgba(2, 5, 3, 0.48); color: var(--color-ink); transition: border-color var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast); }
.field textarea { resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: rgba(55, 235, 137, 0.5); background: rgba(2, 5, 3, 0.7); box-shadow: 0 0 0 3px rgba(55, 235, 137, 0.08); }
.field select { color-scheme: dark; }
.field.compact { min-width: 150px; }
.password-field { display: grid; grid-template-columns: 1fr 38px; border: 1px solid var(--color-line); border-radius: 12px; background: rgba(2, 5, 3, 0.48); }
.password-field input { border: 0; background: transparent; }
.check-field { display: flex; align-items: center; gap: 8px; color: var(--color-ink-soft); }
.check-field input { accent-color: var(--color-primary); }
.swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; }
/* Glass surfaces */
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { position: relative; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.82) 0%, rgba(16, 16, 16, 0.86) 50%, rgba(10, 10, 10, 0.88) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, rgba(255, 255, 255, 0) 70%); pointer-events: none; }
.panel:hover { box-shadow: 0 20px 48px rgba(0, 0, 0, 0.32), inset 0 1px rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
.panel { min-width: 0; overflow: hidden; }
.panel > * { position: relative; }
.panel-heading { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line); }
.panel-heading h2 { font-size: 15px; }
.panel-heading p { margin-top: 3px; color: var(--color-ink-muted); font-size: 11px; }
.status { display: inline-flex; align-items: center; justify-content: center; gap: 5px; width: max-content; min-height: 23px; padding: 2px 8px; border: 1px solid transparent; border-radius: 8px; font-size: 10px; font-style: normal; white-space: nowrap; }
.status.success { border-color: rgba(55, 235, 137, 0.18); background: var(--color-positive-wash); color: var(--color-positive); }
.status.warning { border-color: rgba(255, 188, 82, 0.18); background: var(--color-warning-wash); color: var(--color-warning); }
.status.danger { border-color: rgba(255, 98, 109, 0.18); background: var(--color-danger-wash); color: var(--color-danger); }
.status.neutral { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); }
.task-level { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; font-size: 10px; font-style: normal; font-weight: 800; }
.task-level.danger { background: var(--color-danger-wash); color: var(--color-danger); }
.task-level.warning { background: var(--color-warning-wash); color: var(--color-warning); }
.task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
/* Dashboard */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0 0 18px; }
.metric-card { position: relative; min-height: 164px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 24px 22px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.86) 0%, rgba(14, 14, 14, 0.88) 50%, rgba(8, 8, 8, 0.9) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transform: perspective(900px) rotateX(0.6deg) rotateY(-0.8deg); transform-origin: center bottom; transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 42%, rgba(255, 255, 255, 0) 72%); pointer-events: none; }
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; transition: opacity var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card:hover::after { opacity: 0.7; box-shadow: 0 -10px 34px currentColor; }
.metric-card:hover { transform: perspective(900px) rotateX(3.5deg) rotateY(-4.5deg) translateY(-10px); border-color: rgba(255, 255, 255, 0.32); box-shadow: 0 36px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 1px rgba(255, 255, 255, 0.14); }
.metric-copy { align-self: start; }
.metric-label { display: block; color: var(--color-ink-soft); font-size: 13px; }
.metric-value { display: block; margin-top: 7px; font: 700 34px/1.05 var(--font-data); letter-spacing: 0; }
.metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); }
.metric-icon { position: relative; width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--color-line); border-radius: 50%; background: radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 70%); color: var(--color-ink-soft); box-shadow: inset 0 1px rgba(255, 255, 255, 0.05); }
.metric-icon::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.12; pointer-events: none; }
.metric-foot { grid-column: 1 / -1; color: var(--color-ink-muted); font-size: 11px; }
.metric-foot strong { color: var(--color-positive); }
.metric-card.warning { color: var(--color-warning); }
.metric-card.danger { color: var(--color-danger); }
.metric-card.success { color: var(--color-positive); }
.metric-card .metric-copy { color: var(--color-ink); }
.metric-card .metric-icon { color: var(--color-ink-soft); }
.metric-card .metric-foot { color: var(--color-ink-muted); }
.metric-card[data-metric-link], .metric-card[data-metric-action] { cursor: pointer; }
.metric-card.warning .metric-icon, .metric-card.warning .metric-foot strong { color: var(--color-warning); }
.metric-card.warning .metric-icon::after { opacity: 0.28; }
.metric-card.danger .metric-icon, .metric-card.danger .metric-foot strong { color: var(--color-danger); }
.metric-card.danger .metric-icon::after { opacity: 0.32; }
.metric-card.success .metric-icon { color: var(--color-positive); }
.metric-card.success .metric-icon::after { opacity: 0.22; border-color: var(--color-positive); }
.period-ribbon { display: grid; grid-template-columns: 1fr 1fr 1fr minmax(310px, 1.4fr); margin-bottom: 18px; overflow: hidden; }
.period-ribbon > div { min-height: 72px; display: flex; flex-direction: column; justify-content: center; padding: 13px 17px; border-left: 1px solid var(--color-line); }
.period-ribbon > div:first-child { border-left: 0; }
.period-ribbon span, .period-ribbon small { color: var(--color-ink-muted); }
.period-ribbon strong { margin-top: 3px; }
.period-ribbon > .period-warning { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.period-warning > svg { color: var(--color-warning); }
.period-warning > span { display: flex; flex-direction: column; }
.period-warning > span strong { color: var(--color-ink); }
.admin-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr); gap: 18px; margin-bottom: 18px; }
.task-list button { width: 100%; min-height: 73px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 18px; align-items: center; gap: 11px; padding: 12px 16px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; transition: background var(--duration-fast); }
.task-list button:first-child { border-top: 0; }
.task-list button:hover { background: rgba(255, 255, 255, 0.035); }
.task-list button > span:nth-child(2) { display: flex; flex-direction: column; }
.task-list small, .task-list button > b { color: var(--color-ink-muted); font-size: 11px; }
.task-list button > svg { color: var(--color-ink-muted); }
.quick-pair-form { display: grid; grid-template-columns: 1fr 38px 1fr; gap: 10px; padding: 16px; }
.quick-pair-form .button { grid-column: 1 / -1; }
.quick-result { margin: 0 16px 16px; padding: 14px; border: 1px solid rgba(55, 235, 137, 0.16); border-radius: var(--radius-md); background: var(--color-primary-wash); }
.quick-result span, .quick-result p { color: var(--color-ink-muted); }
.quick-result strong { display: block; margin: 4px 0; color: var(--color-primary-strong); font: 700 22px var(--font-data); }
.quick-result small { margin-left: 4px; font: 10px var(--font-ui); }
/* Key-company / account-coverage flat list (replaces the floating 3D card stack) */
.company-list { display: grid; gap: 8px; padding: 10px 12px 14px; }
.company-row { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 62px; padding: 11px 13px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(22, 22, 22, 0.7) 0%, rgba(12, 12, 12, 0.75) 100%); cursor: pointer; transition: background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard); }
.company-row:hover { background: linear-gradient(160deg, rgba(32, 32, 32, 0.8) 0%, rgba(18, 18, 18, 0.85) 100%); border-color: var(--color-line-strong); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); }
.company-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
.company-row-mark { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
.company-row.is-warning .company-row-mark { background: var(--color-warning-wash); color: var(--color-warning); }
.company-row.is-danger .company-row-mark { background: var(--color-danger-wash); color: var(--color-danger); }
.company-row-body { display: flex; flex-direction: column; min-width: 0; }
.company-row-body strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.company-row-body small { color: var(--color-ink-muted); font-size: 11px; }
.company-row-figure { display: flex; flex-direction: column; align-items: flex-end; text-align: right; min-width: 92px; }
.company-row-figure strong { font: 600 14px var(--font-data); color: var(--color-ink); }
.company-row-figure small { color: var(--color-ink-muted); font-size: 11px; }
.company-row .status { flex: 0 0 auto; }
/* Timeline-strip: horizontal calculation window ribbon */
.timeline-strip { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 14px; align-items: stretch; padding: 16px 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(180deg, rgba(22, 22, 22, 0.86), rgba(10, 10, 10, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
.timeline-strip::before { content: ""; position: absolute; }
.timeline-legend { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
.timeline-legend strong { font-size: 13px; }
.timeline-legend span { color: var(--color-ink-muted); font-size: 11px; }
.timeline-body { position: relative; min-height: 88px; padding-top: 6px; }
.timeline-axis { position: relative; height: 14px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); border-bottom: 1px solid var(--color-line); }
.timeline-axis span { font: 600 10px var(--font-data); color: var(--color-ink-muted); align-self: end; padding-bottom: 4px; text-align: left; }
.timeline-axis span + span { border-left: 1px solid var(--color-line); padding-left: 4px; }
.timeline-rows { display: grid; gap: 8px; margin-top: 12px; }
.timeline-row { position: relative; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px; font-size: 11px; }
.timeline-row > span { color: var(--color-ink-soft); }
.timeline-track { position: relative; height: 14px; border-radius: 7px; background: rgba(255, 255, 255, 0.04); overflow: hidden; }
.timeline-track i { position: absolute; top: 0; bottom: 0; border-radius: inherit; }
.timeline-track i.coverage { background: linear-gradient(90deg, rgba(55, 235, 137, 0.35), rgba(55, 235, 137, 0.55)); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.32); }
.timeline-track i.warning { background: linear-gradient(90deg, rgba(255, 188, 82, 0.32), rgba(255, 188, 82, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 188, 82, 0.32); }
.timeline-track i.danger { background: linear-gradient(90deg, rgba(255, 98, 109, 0.32), rgba(255, 98, 109, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 98, 109, 0.32); }
.timeline-today { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 12px rgba(112, 255, 174, 0.6); border-radius: 1px; }
.timeline-today::after { content: ""; position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: var(--color-primary-strong); box-shadow: 0 0 0 4px rgba(112, 255, 174, 0.18); }
.timeline-strip-panel { position: relative; }
/* Ledger and queries */
.inline-search { width: min(240px, 38vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 11px; background: rgba(0, 0, 0, 0.18); color: var(--color-ink-muted); }
.inline-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.ledger-head, .company-ledger summary { display: grid; grid-template-columns: minmax(210px, 1.4fr) repeat(2, minmax(100px, 0.75fr)) minmax(150px, 0.9fr) 86px 24px; align-items: center; gap: 10px; }
.ledger-head { min-height: 42px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 10px; }
.company-ledger { border-top: 1px solid var(--color-line); }
.company-ledger:first-child { border-top: 0; }
.company-ledger summary { min-height: 72px; padding: 10px 16px; list-style: none; cursor: pointer; transition: background var(--duration-fast); }
.company-ledger summary::-webkit-details-marker { display: none; }
.company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); }
.company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); }
.company-ledger[open] summary > svg { transform: rotate(180deg); }
.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; min-width: 0; }
.company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; }
.company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.company-name small { color: var(--color-ink-muted); }
.amount { font: 600 13px var(--font-data); }
.amount.debit { color: var(--color-positive); }
.amount.credit { color: var(--color-warning); }
.ledger-breakdown { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--color-line); background: rgba(0, 0, 0, 0.13); }
.ledger-breakdown section { padding: 14px 16px; }
.ledger-breakdown section + section { border-left: 1px solid var(--color-line); }
.ledger-breakdown header { display: flex; justify-content: space-between; margin-bottom: 8px; color: var(--color-ink-soft); }
.subject-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; }
.subject-row:hover { background: rgba(255, 255, 255, 0.035); }
.subject-row span { display: flex; flex-direction: column; }
.subject-row small { color: var(--color-ink-muted); }
.query-band, .filter-grid { padding: 17px; margin-bottom: 18px; }
.pair-query-form { display: grid; grid-template-columns: minmax(150px, 1fr) 38px minmax(150px, 1fr) minmax(150px, 0.8fr) auto; align-items: end; gap: 11px; }
.pair-report { overflow: hidden; }
.pair-report-heading { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
.pair-report-heading p { color: var(--color-ink-muted); }
.pair-result { text-align: right; }
.pair-result strong { display: block; color: var(--color-primary); font: 700 22px var(--font-data); }
.pair-balance-line { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
.pair-balance-line div { min-height: 76px; display: flex; flex-direction: column; justify-content: center; padding: 12px 16px; border-left: 1px solid var(--color-line); }
.pair-balance-line div:first-child { border-left: 0; }
.pair-balance-line span, .pair-balance-line small { color: var(--color-ink-muted); }
.pair-balance-line strong { margin-top: 3px; font: 600 15px var(--font-data); }
.pair-final strong { color: var(--color-primary); font-size: 19px; }
.subject-strip { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
.subject-strip button { min-height: 62px; display: flex; flex-direction: column; justify-content: center; padding: 9px 14px; border: 0; border-left: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.018); text-align: left; cursor: pointer; }
.subject-strip button:first-child { border-left: 0; }
.subject-strip button.is-active { background: var(--color-primary-wash); color: var(--color-primary); box-shadow: inset 0 -2px var(--color-primary); }
.subject-strip span { color: var(--color-ink-muted); font-size: 10px; }
/* Filters and tables */
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px; margin-bottom: 18px; }
.segmented { display: flex; gap: 4px; padding: 4px; overflow-x: auto; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
.segmented button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); white-space: nowrap; cursor: pointer; }
.segmented button.is-active { background: rgba(255, 255, 255, 0.09); color: var(--color-ink); }
.flow-filters { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)) minmax(200px, 1.4fr) auto; align-items: end; gap: 11px; }
.flow-filters .grow { min-width: 0; }
.table-summary { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 11px; }
.table-scroll { max-width: 100%; overflow: auto; }
.data-table { width: 100%; min-width: 860px; border-collapse: collapse; }
.data-table th { height: 42px; padding: 8px 13px; border-bottom: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.025); color: var(--color-ink-muted); font-size: 10px; font-weight: 500; text-align: left; white-space: nowrap; }
.data-table td { height: 55px; padding: 9px 13px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); vertical-align: middle; }
.data-table tbody tr { transition: background var(--duration-fast); }
.data-table tbody tr:hover { background: rgba(55, 235, 137, 0.035); }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table td strong, .data-table td small { display: block; }
.data-table td strong { color: var(--color-ink); }
.data-table td small { color: var(--color-ink-muted); }
.data-table .number { color: var(--color-ink); text-align: right; font-family: var(--font-data); font-variant-numeric: tabular-nums; }
/* Settings, reminders and company work */
.settings-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; }
.form-body { display: grid; gap: 15px; padding: 17px; }
.panel form footer, form.panel footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 17px; border-top: 1px solid var(--color-line); }
.closing-panel { grid-column: 1 / -1; }
.closing-checks { display: grid; grid-template-columns: repeat(4, 1fr); }
.closing-checks article { min-height: 75px; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-left: 1px solid var(--color-line); }
.closing-checks article:first-child { border-left: 0; }
.closing-checks svg { color: var(--color-positive); }
.closing-checks .is-blocked svg { color: var(--color-danger); }
.closing-checks span { display: flex; flex-direction: column; }
.closing-checks small { color: var(--color-ink-muted); }
.closing-footer { min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
.closing-footer > span { margin-right: auto; color: var(--color-ink-muted); }
.reminder-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; }
.notification-list article { min-height: 76px; display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--color-line); }
.notification-list article:first-child { border-top: 0; }
.notification-list article.is-unread { background: rgba(102, 168, 255, 0.045); }
.notification-list article > span:nth-child(2) { display: flex; flex-direction: column; }
.notification-list small, .notification-list p { color: var(--color-ink-muted); }
.notification-list p { margin-top: 4px; }
.notification-icon { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 12px; background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
.notification-icon.danger { background: var(--color-danger-wash); color: var(--color-danger); }
.notification-icon.warning { background: var(--color-warning-wash); color: var(--color-warning); }
.notification-icon.success { background: var(--color-positive-wash); color: var(--color-positive); }
.company-alert { min-height: 78px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 18px; padding: 14px 16px; border: 1px solid rgba(255, 98, 109, 0.22); border-radius: var(--radius-lg); background: var(--color-danger-wash); box-shadow: var(--shadow-low); }
.company-alert > svg { color: var(--color-danger); }
.company-alert p { color: #c18e92; }
.company-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); gap: 18px; }
.cashier-tasks article { min-height: 92px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 16px; border-top: 1px solid var(--color-line); }
.cashier-tasks article:first-child { border-top: 0; }
.cashier-tasks p, .cashier-tasks small { color: var(--color-ink-muted); }
.coverage-list article { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
.coverage-list article:first-child { border-top: 0; }
.coverage-list article > span { display: flex; flex-direction: column; }
.coverage-list small { color: var(--color-ink-muted); }
.work-progress { display: grid; grid-template-columns: minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr); align-items: center; margin-top: 18px; padding: 18px; }
.work-progress div { display: flex; align-items: center; gap: 10px; }
.work-progress div > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 11px; background: var(--color-positive); color: var(--color-primary-dark); font-weight: 800; }
.work-progress p { display: flex; flex-direction: column; }
.work-progress small { color: var(--color-ink-muted); }
.work-progress i { height: 1px; background: var(--color-line-strong); }
.work-progress .pending > span { background: rgba(255, 255, 255, 0.065); color: var(--color-ink-muted); }
.reconcile-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; overflow: hidden; }
.reconcile-summary div { min-height: 86px; display: flex; flex-direction: column; justify-content: center; padding: 14px 18px; border-left: 1px solid var(--color-line); }
.reconcile-summary div:first-child { border-left: 0; }
.reconcile-summary span, .reconcile-summary small { color: var(--color-ink-muted); }
.reconcile-summary strong { font: 600 19px var(--font-data); }
.review-list > article { padding: 16px; border-top: 1px solid var(--color-line); }
.review-list > article:first-child { border-top: 0; }
.review-main { display: grid; grid-template-columns: 38px 1fr; gap: 10px; }
.review-main p { color: var(--color-ink-muted); }
.candidate { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); }
.candidate-options { display: grid; gap: 8px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
.candidate-options legend { padding: 0 5px; color: var(--color-ink-muted); }
.candidate-options label { display: flex; align-items: center; gap: 9px; padding: 9px; border-radius: 11px; background: rgba(255, 255, 255, 0.035); }
.candidate-options input { accent-color: var(--color-primary); }
.candidate-options label span { display: flex; flex-direction: column; }
.candidate-options small { color: var(--color-ink-muted); }
.review-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 10px; }
.review-history { padding: 14px 16px; border-top: 1px solid var(--color-line); background: var(--color-positive-wash); }
.evidence-block { padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.16); }
.evidence-block strong, .evidence-block small { display: block; }
.evidence-block small { color: var(--color-ink-muted); }
.account-directory { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 16px; }
.account-directory article { overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); }
.account-directory header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid var(--color-line); }
.account-directory header div { display: flex; flex-direction: column; }
.account-directory header small { color: var(--color-ink-muted); }
.bank-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; }
.account-directory dl { display: grid; grid-template-columns: repeat(3, 1fr); }
.account-directory dl div { padding: 12px; border-left: 1px solid var(--color-line); }
.account-directory dl div:first-child { border-left: 0; }
.account-directory dt { color: var(--color-ink-muted); font-size: 10px; }
.account-directory dd { margin-top: 3px; font-size: 11px; }
.manual-layout { display: grid; grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); align-items: start; gap: 18px; }
.manual-records-table { min-width: 900px; }
/* Dialogs and feedback */
.dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 17, 17, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
.dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); }
.dialog > form > header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--color-line); }
.dialog header p { color: var(--color-ink-muted); }
.dialog-body { display: grid; gap: 15px; padding: 18px; }
.dialog > form > footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 18px; border-top: 1px solid var(--color-line); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-callout { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid rgba(102, 168, 255, 0.22); border-radius: 12px; background: var(--color-info-wash); color: #9ac6ff; }
.dropzone { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 1px dashed rgba(255, 255, 255, 0.24); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); color: var(--color-ink-soft); text-align: center; transition: border-color var(--duration-fast), background var(--duration-fast); }
.dropzone:hover, .dropzone.is-dragging { border-color: var(--color-primary); background: var(--color-primary-wash); }
.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.dropzone > svg { width: 30px; height: 30px; color: var(--color-primary); }
.file-preview { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
.file-preview > span:nth-child(2) { display: flex; flex-direction: column; min-width: 0; }
.file-preview strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-preview small { color: var(--color-ink-muted); }
.file-type { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); }
.parse-result { display: grid; grid-template-columns: 38px 1fr; align-items: center; gap: 10px; padding: 11px; border: 1px solid rgba(55, 235, 137, 0.22); border-radius: var(--radius-md); background: var(--color-positive-wash); }
.parse-result p { color: #9bd7b6; }
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
.parse-result.is-exception p { color: #e7bd78; }
.sheet-review { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); }
.sheet-review h3 { font-size: 14px; }
.sheet-review-hint { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-list { display: grid; gap: 8px; margin-top: 10px; max-height: 220px; overflow-y: auto; }
.sheet-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.sheet-item.is-pending { border-color: rgba(255, 188, 82, 0.45); }
.sheet-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sheet-item-head strong { font-size: 13px; }
.sheet-item-head small { color: var(--color-ink-muted); }
.sheet-item-meta { margin-top: 5px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-item-actions { display: flex; align-items: center; gap: 6px; }
.sheet-item-actions .text-button { font-size: 12px; padding: 4px 8px; }
.sheet-item-reason { margin-top: 6px; padding: 6px 8px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 12px; }
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 20, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
.toast strong, .toast small { display: block; }
.toast small { color: var(--color-ink-muted); }
/* Login */
.entry-page { overflow-x: hidden; }
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #080808; }
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); }
.entry-brand { display: flex; align-items: center; gap: 12px; }
.entry-brand > span:last-child { display: flex; flex-direction: column; }
.entry-brand small { color: var(--color-ink-muted); }
.entry-statement { max-width: 680px; margin: 80px 0; }
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; }
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); }
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; }
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.entry-facts dt { color: var(--color-ink-muted); }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); }
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); }
.entry-form { width: min(420px, 100%); display: grid; gap: 18px; padding: 28px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: var(--color-surface); box-shadow: var(--shadow-panel); backdrop-filter: blur(24px); }
.entry-form header h2 { font-size: 25px; }
.entry-form header p, .entry-note { color: var(--color-ink-muted); }
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.role-switch label { cursor: pointer; }
.role-switch input { position: absolute; opacity: 0; }
.role-switch span { min-height: 83px; display: grid; grid-template-columns: 30px 1fr; align-content: center; gap: 1px 9px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.14); transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); }
.role-switch small { color: var(--color-ink-muted); }
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); }
.role-switch input:checked + span svg { color: var(--color-primary); }
.entry-note { font-size: 10px; }
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 88px minmax(0, 1fr); }
.sidebar { width: 70px; padding-inline: 9px; }
.workspace { grid-column: 2; }
.brand { justify-content: center; padding-inline: 0; }
.brand-copy, .nav-item span, .nav-item b, .user-block > span:last-child, .company-context div { display: none; }
.nav-item { justify-content: center; padding: 0; }
.user-block, .company-context { justify-content: center; padding-inline: 0; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.period-ribbon { grid-template-columns: repeat(3, 1fr); }
.period-ribbon > .period-warning { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
.admin-dashboard-grid, .company-dashboard-grid { grid-template-columns: 1fr; }
.flow-filters { grid-template-columns: repeat(3, 1fr); }
.flow-filters .button { min-height: 40px; }
.manual-layout { grid-template-columns: 1fr; }
.account-directory { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
main { padding-inline: 20px; }
.search-box { display: none; }
.settings-layout, .reminder-layout { grid-template-columns: 1fr; }
.pair-query-form { grid-template-columns: 1fr 40px 1fr; }
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
.pair-query-form .button { grid-column: 3; }
.ledger-head { display: none; }
.company-ledger:not(.is-balances) summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
.company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
.ledger-breakdown { grid-template-columns: 1fr; }
.ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; }
.pair-balance-line { grid-template-columns: repeat(3, 1fr); }
.pair-balance-line .pair-final { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
.subject-strip { grid-template-columns: repeat(3, 1fr); }
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
.work-progress i:nth-of-type(2) { display: none; }
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: 58vh; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-form-wrap { min-height: auto; }
}
@media (max-width: 720px) {
.app-shell { display: block; }
.sidebar { inset: 10px auto 10px 10px; width: 242px; transform: translateX(calc(-100% - 20px)); transition: transform var(--duration-standard) var(--ease-out); }
.sidebar.is-open { transform: none; }
.sidebar.is-open .brand-copy, .sidebar.is-open .nav-item span, .sidebar.is-open .nav-item b, .sidebar.is-open .user-block > span:last-child, .sidebar.is-open .company-context div { display: flex; }
.sidebar.is-open .nav-item { justify-content: flex-start; padding: 0 12px; }
.sidebar.is-open .company-context { justify-content: flex-start; padding-inline: 10px; }
.workspace { display: block; }
.topbar { height: 64px; padding: 0 13px; gap: 9px; }
.menu-button { display: inline-grid; }
.workspace-name { min-width: 0; }
.workspace-name span { display: none; }
.demo-badge { padding: 2px 6px; }
.period-button { display: none; }
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
main { padding: 12px 13px 42px; }
.page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; }
.page-heading h1 { font-size: 27px; }
.page-heading > .button { align-self: stretch; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { min-height: 128px; grid-template-columns: minmax(0, 1fr) 38px; padding: 15px; }
.metric-icon { width: 38px; height: 38px; border-radius: 12px; }
.metric-value { font-size: 25px; }
.period-ribbon { grid-template-columns: 1fr 1fr; }
.period-ribbon > div { min-height: 64px; }
.period-ribbon > div:nth-child(3) { border-top: 1px solid var(--color-line); border-left: 0; }
.period-warning { grid-column: 1 / -1; }
.panel-heading { min-height: 60px; padding: 11px 13px; }
.inline-search { display: none; }
.task-list button { grid-template-columns: 30px minmax(0, 1fr) 18px; padding-inline: 12px; }
.task-list button > b { display: none; }
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
.quick-result { margin-inline: 12px; }
.company-ledger:not(.is-balances) summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
.company-ledger:not(.is-balances) summary > strong:nth-of-type(2), .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
.company-name { grid-template-columns: 30px minmax(0, 1fr); }
.company-name i { width: 28px; height: 28px; }
.filter-bar { align-items: stretch; flex-direction: column; }
.segmented { width: 100%; }
.flow-filters { grid-template-columns: 1fr 1fr; }
.flow-filters .grow, .flow-filters .button { grid-column: 1 / -1; }
.pair-query-form { grid-template-columns: 1fr 38px 1fr; }
.pair-query-form .field:nth-of-type(3), .pair-query-form .button { grid-column: 1 / -1; }
.pair-report-heading { align-items: flex-start; flex-direction: column; }
.pair-balance-line { grid-template-columns: 1fr 1fr; }
.pair-balance-line .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
.subject-strip { grid-template-columns: 1fr 1fr; }
.company-alert { grid-template-columns: 28px 1fr; }
.company-alert .button { grid-column: 1 / -1; }
.cashier-tasks article { grid-template-columns: 30px minmax(0, 1fr); }
.cashier-tasks .button { grid-column: 2; justify-self: start; }
.work-progress { grid-template-columns: 1fr; }
.work-progress i { display: none; }
.reconcile-summary { grid-template-columns: 1fr; }
.reconcile-summary div { min-height: 70px; border-top: 1px solid var(--color-line); border-left: 0; }
.reconcile-summary div:first-child { border-top: 0; }
.candidate, .candidate-options { align-items: stretch; flex-direction: column; margin-left: 0; }
.account-directory { grid-template-columns: 1fr; }
.account-directory dl { grid-template-columns: 1fr 1fr; }
.closing-checks { grid-template-columns: 1fr; }
.closing-checks article { border-top: 1px solid var(--color-line); border-left: 0; }
.closing-footer { align-items: stretch; flex-direction: column; }
.notification-list article { grid-template-columns: 34px minmax(0, 1fr); }
.notification-list article > em { grid-column: 2; justify-self: start; }
.table-summary { align-items: flex-start; flex-direction: column; justify-content: center; padding-block: 9px; }
.dialog { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; }
.dialog > form { min-height: 100%; display: flex; flex-direction: column; }
.dialog-body { flex: 1; }
.form-grid { grid-template-columns: 1fr; }
.toast-region { right: 12px; bottom: 12px; left: 12px; }
.toast { min-width: 0; max-width: none; }
.entry-context { min-height: auto; padding: 30px 22px; }
.entry-statement { margin: 54px 0 42px; }
.entry-statement h1 { font-size: 39px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { padding: 32px 16px 48px; }
.entry-form { padding: 22px; }
}
@media (max-width: 460px) {
.metric-card { min-height: 150px; padding: 13px; }
.metric-copy { min-height: 52px; }
.metric-label { font-size: 11px; }
.metric-value { font-size: 23px; }
.role-switch { grid-template-columns: 1fr; }
}
@media (max-width: 1180px) {
.timeline-strip { grid-template-columns: 1fr; }
.timeline-legend { padding-bottom: 4px; }
}
@media (max-width: 900px) {
.company-row:not(.is-balance-counterparty) { grid-template-columns: 34px minmax(0, 1fr) auto; }
.company-row:not(.is-balance-counterparty) .company-row-figure { display: none; }
}
@media (max-width: 720px) {
.timeline-strip { padding: 14px; }
.timeline-rows { gap: 6px; }
.timeline-row { grid-template-columns: 80px minmax(0, 1fr); }
.timeline-axis span { font-size: 9px; }
}
/* ===== 参考图对齐:胶囊搜索条 / 账期时间轴 / 右侧详情面板 ===== */
.content-toolbar > .search-box { border-radius: 999px; background: rgba(255, 255, 255, 0.045); backdrop-filter: blur(18px) saturate(120%); }
.timeline-panel { margin-top: 18px; padding: 18px; }
.timeline { padding: 10px 4px 0; }
.timeline-bar { position: relative; height: 14px; border: 1px solid var(--color-line); border-radius: 999px; background: rgba(255, 255, 255, 0.05); }
.timeline-seg, .timeline-gap { position: absolute; top: 2px; bottom: 2px; border-radius: 999px; }
.timeline-seg.is-closed { background: linear-gradient(90deg, rgba(55, 235, 137, 0.5), rgba(55, 235, 137, 0.28)); }
.timeline-seg.is-current { background: linear-gradient(90deg, rgba(255, 188, 82, 0.4), rgba(255, 188, 82, 0.18)); }
.timeline-gap { background: repeating-linear-gradient(45deg, rgba(255, 98, 109, 0.75) 0 6px, rgba(255, 98, 109, 0.35) 6px 12px); box-shadow: 0 0 12px rgba(255, 98, 109, 0.25); }
.timeline-today { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 10px rgba(55, 235, 137, 0.6); }
.timeline-today::after { content: "今天"; position: absolute; top: -19px; right: 5px; font-size: 10px; font-style: normal; color: var(--color-primary-strong); white-space: nowrap; }
.timeline-ticks { position: relative; height: 34px; margin-top: 8px; }
.timeline-ticks span { position: absolute; display: flex; flex-direction: column; transform: translateX(-50%); font-size: 10px; color: var(--color-ink-muted); text-align: center; }
.timeline-ticks span:first-child { transform: none; text-align: left; }
.timeline-ticks span:last-child { transform: translateX(-100%); text-align: right; }
.timeline-ticks small { color: var(--color-ink-soft); }
.timeline-panel .timeline-legend { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px 18px; margin: 10px 0 0; padding: 0; list-style: none; font-size: 11px; color: var(--color-ink-soft); }
.timeline-panel .timeline-legend li { display: flex; align-items: center; gap: 6px; }
.timeline-panel .dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.success { background: var(--color-positive); }
.dot.warning { background: var(--color-warning); }
.dot.danger { background: var(--color-danger); }
.dot.today { background: var(--color-primary-strong); box-shadow: 0 0 6px rgba(55, 235, 137, 0.7); }
.detail-drawer { position: fixed; top: 16px; right: 16px; bottom: 16px; z-index: 80; width: min(380px, calc(100vw - 32px)); display: flex; flex-direction: column; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.92), rgba(12, 12, 12, 0.94)); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); transform: translateX(calc(100% + 32px)); visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), visibility var(--duration-standard); }
.detail-drawer.is-open { transform: none; visibility: visible; }
.detail-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid var(--color-line); }
.detail-drawer h2 { margin-top: 8px; font-size: 17px; }
.detail-drawer .detail-desc { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields { display: grid; gap: 10px; margin: 0; padding: 14px 18px; overflow-y: auto; }
.detail-fields > div { display: flex; justify-content: space-between; gap: 14px; padding-bottom: 10px; border-bottom: 1px dashed var(--color-line); }
.detail-fields dt { flex-shrink: 0; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields dd { margin: 0; text-align: right; font-size: 12px; }
.detail-tip { margin: 0 18px 12px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-ink-soft); font-size: 12px; }
.detail-tip strong { display: block; margin-bottom: 4px; color: var(--color-ink); }
.detail-drawer > footer { display: flex; gap: 10px; margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--color-line); }
.detail-drawer > footer .button { flex: 1; justify-content: center; }
.detail-drawer .status { align-self: flex-start; }
[data-detail] { cursor: pointer; }
@media (max-width: 720px) {
.detail-drawer { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); }
.timeline-panel { padding: 14px; }
}
/* ===== 质感打磨:玻璃光泽 / 氛围光 / 对比与呼吸感 ===== */
:root {
--radius-lg: 24px;
--radius-xl: 30px;
}
body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(52vw 42vh at 62% 32%, rgba(55, 235, 137, 0.055), transparent 62%), radial-gradient(40vw 34vh at 90% 4%, rgba(102, 168, 255, 0.032), transparent 60%), radial-gradient(46vw 40vh at 28% 96%, rgba(55, 235, 137, 0.028), transparent 60%); }
.app-shell { position: relative; z-index: 0; }
.metric-card { border-color: rgba(255, 255, 255, 0.12); background: radial-gradient(120% 90% at 50% 108%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 42%, transparent 60%), linear-gradient(160deg, rgba(41, 41, 41, 0.86) 0%, rgba(23, 23, 23, 0.88) 50%, rgba(12, 12, 12, 0.9) 100%); }
.metric-card::before { background: radial-gradient(130% 100% at 50% 112%, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 45%, transparent 62%), linear-gradient(165deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.05) 42%, transparent 72%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.05), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, transparent 70%); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.16); background: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06)); box-shadow: inset 0 1px rgba(255, 255, 255, 0.14), 0 10px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(55, 235, 137, 0.08); }
.metric-value { font-size: clamp(28px, 2.6vw, 38px); font-weight: 800; color: #ffffff; white-space: nowrap; }
.metric-grid { gap: 20px; margin-bottom: 20px; }
.admin-dashboard-grid { gap: 20px; margin-bottom: 20px; }
.company-dashboard-grid { gap: 20px; }
.page-heading { margin-bottom: 22px; }
@media (max-width: 720px) {
.metric-grid { gap: 10px; margin-bottom: 14px; }
.page-heading { margin-bottom: 18px; }
}
/* ===== 反馈修正:侧栏质感 + 模块间距统一 ===== */
.sidebar { border-color: rgba(255, 255, 255, 0.13); background: linear-gradient(170deg, rgba(26, 26, 26, 0.92), rgba(10, 10, 10, 0.94)); box-shadow: var(--shadow-panel), 0 0 44px rgba(55, 235, 137, 0.05); }
.sidebar::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(170deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 45%, transparent 70%); pointer-events: none; }
.nav-list { gap: 10px; }
.nav-item { min-height: 48px; border-radius: 16px; }
.user-block { border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-surface-muted); }
.company-alert { margin-bottom: 20px; }
.period-ribbon { margin-bottom: 20px; }
.company-stack-panel { margin-bottom: 20px; }
.work-progress { margin-top: 20px; }
.timeline-panel { margin-top: 20px; }
/* ===== 反馈修正:整体提亮 + 列表改为玻璃行(去掉线条隔断) ===== */
:root {
--color-ink-soft: #b6c0ba;
--color-ink-muted: #99a39d;
}
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { border-color: rgba(255, 255, 255, 0.12); background: linear-gradient(160deg, rgba(37, 37, 37, 0.84) 0%, rgba(21, 21, 21, 0.87) 50%, rgba(12, 12, 12, 0.89) 100%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.13) 0%, rgba(255, 255, 255, 0.04) 40%, transparent 70%); }
.task-list, .cashier-tasks, .coverage-list, .notification-list, .review-list { display: grid; gap: 8px; padding: 12px 14px 14px; }
.task-list button, .cashier-tasks article, .coverage-list article, .notification-list article, .review-list > article, .subject-row { border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); transition: background var(--duration-fast), border-color var(--duration-fast), box-shadow var(--duration-fast), transform var(--duration-fast); }
.task-list button:hover, .cashier-tasks article:hover, .coverage-list article:hover, .notification-list article:hover, .review-list > article:hover, .subject-row:hover { border-color: rgba(255, 255, 255, 0.2); background: linear-gradient(160deg, rgba(44, 44, 44, 0.82) 0%, rgba(22, 22, 22, 0.86) 100%); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); }
.notification-list article.is-unread { border-color: rgba(102, 168, 255, 0.3); background: linear-gradient(160deg, rgba(102, 168, 255, 0.1), rgba(102, 168, 255, 0.04)); }
.company-ledger { margin: 8px 14px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); overflow: hidden; }
.company-ledger[open] { border-color: rgba(255, 255, 255, 0.18); }
.company-ledger:last-child { margin-bottom: 14px; }
.data-table { border-collapse: separate; border-spacing: 0 8px; }
.data-table th { height: 28px; padding: 0 13px; border-bottom: 0; background: transparent; }
.data-table td { border-bottom: 0; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); }
.data-table td:first-child { border-left: 1px solid var(--color-line); border-radius: 12px 0 0 12px; }
.data-table td:last-child { border-right: 1px solid var(--color-line); border-radius: 0 12px 12px 0; }
.data-table tbody tr:hover td { background: linear-gradient(160deg, rgba(44, 44, 44, 0.85) 0%, rgba(22, 22, 22, 0.88) 100%); }
.data-table tbody tr:last-child td { border-bottom: 1px solid var(--color-line); }
.table-scroll { padding: 2px 14px 8px; }
.table-summary { margin: 0 14px; }
/* ===== 登录页重做:居中集团登录卡 ===== */
.entry-shell { display: grid; grid-template-columns: 1fr; place-items: center; min-height: 100vh; padding: 48px 20px; }
.entry-card { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: var(--radius-xl); background: linear-gradient(165deg, rgba(36, 36, 36, 0.9) 0%, rgba(16, 16, 16, 0.92) 60%, rgba(10, 10, 10, 0.94) 100%); box-shadow: var(--shadow-panel), 0 0 60px rgba(55, 235, 137, 0.06); backdrop-filter: blur(28px) saturate(125%); }
.entry-card::before { content: ""; position: absolute; inset: 0; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.07), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 42%, transparent 70%); pointer-events: none; }
.entry-card::after { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 2px; background: linear-gradient(90deg, transparent, rgba(55, 235, 137, 0.65), transparent); }
.entry-brand { flex-direction: column; justify-content: center; gap: 15px; min-height: 0; padding: 36px 28px 24px; border-bottom: 1px solid var(--color-line); text-align: center; }
.entry-brand .brand-mark { width: 54px; height: 54px; border-radius: 17px; font-size: 26px; }
.entry-brand > span:last-child { align-items: center; }
.entry-brand strong { font-size: 21px; letter-spacing: 0.03em; }
.entry-brand small { margin-top: 4px; font-size: 12px; letter-spacing: 0.08em; }
.entry-form { width: 100%; gap: 16px; padding: 24px 28px 10px; border: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
.entry-form header { text-align: center; }
.entry-form header h2 { font-size: 22px; }
.entry-form header p { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.entry-facts { max-width: none; gap: 10px; padding: 16px 28px 28px; }
.entry-facts div { padding: 12px; text-align: center; }
.entry-facts dd { margin-top: 2px; font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
/* ---- B-44 intercompany balances ---- */
.status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); }
.amount-neutral { color: var(--color-ink-muted); }
.currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; }
.amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; }
.amount-with-currency.is-compact { font-size: 11px; }
.account-cell { white-space: nowrap; }
.summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; }
.candidate-list li { padding: 4px 0; border-bottom: 1px dashed var(--color-line); }
.candidate-list li:last-child { border-bottom: 0; }
.reason-full { max-height: calc(1.6em * 6); overflow: auto; white-space: pre-wrap; }
.decision-danger { margin-top: 8px; color: var(--color-danger); font-size: 12px; }
.balance-currency-groups { display: grid; gap: 12px; }
.balance-currency-groups .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; }
/* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */
.ledger-head.is-balances, .company-ledger.is-balances summary {
grid-template-columns: minmax(180px, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
}
.company-ledger.is-balances summary { min-height: 72px; }
.company-ledger.is-balances summary .company-name { min-width: 96px; }
.company-ledger.is-balances summary .ledger-cutoff { color: var(--color-ink-muted); font-size: 11px; white-space: nowrap; }
.company-ledger.is-balances .ledger-result { display: flex; align-items: center; gap: 7px; min-width: 0; }
.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 0; }
.company-ledger.is-balances .ledger-unresolved { font-size: 11px; white-space: nowrap; }
.company-ledger.is-balances .ledger-unresolved.is-empty { color: var(--color-ink-muted); }
.company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); }
.company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; }
.company-ledger.is-balances .subject-row small { white-space: nowrap; }
@media (max-width: 1179px) {
.ledger-head.is-balances, .company-ledger.is-balances summary {
grid-template-columns: minmax(120px, 1.2fr) minmax(136px, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px;
}
.ledger-head.is-balances .ledger-hide-md,
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
.company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; }
}
@media (min-width: 376px) and (max-width: 900px) {
.ledger-head.is-balances { display: grid; }
.ledger-head.is-balances, .company-ledger.is-balances summary {
grid-template-columns: minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px;
}
.company-ledger.is-balances summary .ledger-result {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
}
.company-ledger.is-balances .ledger-result b { font-size: 11px; }
.company-ledger.is-balances .ledger-unresolved {
min-width: 0;
white-space: normal;
}
.company-ledger.is-balances .ledger-unresolved .status {
width: auto;
max-width: 100%;
height: auto;
white-space: normal;
justify-content: flex-start;
text-align: left;
}
}
@media (max-width: 375px) {
.ledger-head.is-balances { display: none; }
.company-ledger.is-balances summary {
grid-template-columns: minmax(88px, 1fr) minmax(0, max-content) 24px;
grid-template-rows: auto auto;
align-items: center;
column-gap: 10px;
row-gap: 4px;
}
.company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; min-width: 88px; }
.company-ledger.is-balances summary .ledger-result {
grid-column: 2; grid-row: 1; justify-self: end;
flex-direction: column; align-items: flex-end; gap: 2px;
}
.company-ledger.is-balances summary .ledger-result .currency-code { display: none; }
.company-ledger.is-balances summary .ledger-unresolved {
grid-column: 1; grid-row: 2; min-width: 0; white-space: normal;
}
.company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; }
.company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; }
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
}
/* Pair report six-cell: page may be six-across; drawer is always two rows of three */
.pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); }
.pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
.pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
.pair-balance-line .pair-unresolved b { color: var(--color-warning); }
.pair-balance-line .pair-unresolved.is-empty b { color: var(--color-ink); }
.pair-balance-line .pair-cutoff { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
@media (max-width: 1180px) {
.pair-balance-line.is-six { grid-template-columns: repeat(3, 1fr); }
.pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
.pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
}
.amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; }
.amount-with-currency.is-compact { font-size: 11px; }
.amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; }
/* Evidence drawer: <768 full, 7681179 480px, >=1180 640px */
.drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); }
.drawer-scrim.is-open { opacity: 1; pointer-events: auto; }
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); }
.drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; }
@media (max-width: 1179px) { .drawer { width: 480px; } }
@media (max-width: 767px) { .drawer { width: 100%; } }
.drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
.drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; }
.drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; }
.drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; }
.drawer-header h2 { margin-top: 6px; font-size: 17px; }
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; }
.drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; }
.drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; }
.drawer .pair-balance-line strong { overflow-wrap: anywhere; }
.drawer .pair-balance-line .amount-with-currency.is-compact { white-space: normal; overflow-wrap: anywhere; }
.drawer .pair-balance-line .pair-cutoff,
.drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; }
.drawer .pair-balance-line.is-six {
grid-template-columns: repeat(3, 1fr);
}
.drawer .pair-balance-line.is-six .pair-final { grid-column: auto; }
.drawer .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
.drawer .pair-balance-line.is-six div:nth-child(5),
.drawer .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
.drawer .event-table { min-width: 860px; }
.drawer .event-table td { height: var(--row-h-evidence); }
.drawer .event-row { cursor: pointer; }
.drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; }
.drawer .evidence-stack { display: flex; flex-direction: column; gap: 10px; }
.drawer .evidence-midline { display: flex; align-items: center; gap: 10px; color: var(--color-ink-muted); font-size: 11px; }
.drawer .evidence-midline::before, .drawer .evidence-midline::after { content: ""; flex: 1; height: 1px; background: var(--color-line); }
.evidence-block.is-missing { border-color: rgba(102, 168, 255, 0.28); background: var(--color-info-wash); }
.evidence-block .evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 14px; margin-top: 10px; }
.evidence-block .evidence-grid div { display: flex; flex-direction: column; min-width: 0; }
.evidence-block .evidence-grid dt { color: var(--color-ink-muted); font-size: 10px; }
.evidence-block .evidence-grid dd { color: var(--color-ink-soft); font-size: 12px; word-break: break-all; }
.evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; }
@media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } }
.company-row.is-balance-counterparty { grid-template-columns: 34px minmax(0, 1fr) minmax(0, auto); }
.company-row.is-balance-counterparty .company-row-figure {
display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0;
}
.company-row.is-balance-counterparty .company-row-figure strong { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
@media (max-width: 900px) {
.company-row.is-balance-counterparty {
grid-template-columns: 34px minmax(0, 1fr);
row-gap: 8px;
}
.company-row.is-balance-counterparty .company-row-figure {
grid-column: 2;
align-items: flex-start;
text-align: left;
flex-wrap: wrap;
flex-direction: row;
column-gap: 10px;
row-gap: 4px;
}
}
/* Loading skeletons */
.skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); }
.skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); }
.skeleton-row span:nth-child(1) { width: 22%; } .skeleton-row span:nth-child(2) { width: 14%; }
.skeleton-row span:nth-child(3) { width: 14%; } .skeleton-row span:nth-child(4) { width: 18%; }
.skeleton-row span:nth-child(5) { width: 12%; }
.skeleton-block { animation: skeleton-breathe 1200ms ease-in-out infinite; }
/* Empty / error / no-permission states */
.state-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; justify-content: center; min-height: 240px; padding: 28px 18px; text-align: center; color: var(--color-ink-muted); }
.state-panel .state-icon { width: 40px; height: 40px; color: var(--color-ink-muted); opacity: 0.7; }
.state-panel strong { color: var(--color-ink-soft); font-size: 14px; }
.state-panel p { max-width: 420px; font-size: 12px; }
.state-panel .button { margin-top: 6px; }
.state-panel.is-error { border: 1px solid rgba(255, 98, 109, 0.3); border-radius: var(--radius-lg); background: var(--color-danger-wash); }
.state-panel.is-error strong { color: var(--color-danger); }
.state-panel.is-denied { border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); }
/* Company portal alert variant + direction chips */
.balance-alert { border-color: rgba(255, 188, 82, 0.24); background: var(--color-warning-wash); }
.balance-alert > svg { color: var(--color-warning); }
.balance-alert .button { color: var(--color-warning); }
.company-row .result-direction { font-weight: 600; font-variant-numeric: tabular-nums; }
.subject-strip button { white-space: nowrap; }
/* Direction chips: text-first, arrows as affordance, never color-only */
.direction-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 23px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); font-size: 10px; white-space: nowrap; }
@keyframes skeleton-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .skeleton-block { animation: none; } }