Compare commits

...
Author SHA1 Message Date
7826458892 HEL-200: 起算日、期初余额与流水断档检测
新增 0006 迁移与 calculation 模块,实现起算日设置、公司对期初 revision 链、
覆盖断档重算、无业务说明审核,以及完整/降级余额 API;管理端结账与期初页接入真实接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 15:58:21 +00:00
leefer 951b353765 Merge feature/ui-opendesign-shell: OpenDesign UI shell (HEL-120~125) 2026-08-25 12:17:03 +08:00
20d967a4a4 HEL-125: bump web asset ?v= to 4 for test-env deploy cache bust
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 12:15:24 +08:00
leeferandmultica-agent 31fdc2e456 HEL-124 返工:解析后恢复 #parseButton 可点 + detailContent 加「原型占位」注释
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 12:03:54 +08:00
leeferandmultica-agent 1d07812fa8 HEL-124: 第5批——缺口态补画 + 响应式扫尾 + 清理旧黑绿残留
- design-system.css: 补 .app-view/.is-active/.sr-only/.skip-link 结构类、Toast(带 kind)、侧滑详情抽屉、加载骨架/加载中行、.notice.success、窄屏侧栏折叠(汉堡菜单)
- app.js: 重写 showToast(带 kind) 与 initDetailDrawer 用新 token;fetch 补加载中/错误/空态;detailContent 改真实文案并接线 data-detail 触发;清理 animateView/initMotion/initShell 旧类名残留;COMPANY_FLOWS 加「原型占位」注释
- admin.html/company.html: 移除 styles.css 引用、bump ?v=3;待办列表接 data-detail
- 删除 web/styles.css(源头删净 #050505/#37eb89/--color-*/--jn-* 等旧黑绿残留)
- index/login 页 bump ?v=2

Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 11:49:38 +08:00
leeferandmultica-agent 2fe06b74ae HEL-123: 第4批——公司端剩余6页 + 对应弹窗换原型壳
- upload 流水导入: 多步流程(选文件→解析→分sheet审核→确认)完整保留, 内联卡片化 + 批次详情弹窗 #modal-batch
- manual 手工记录: 两栏布局 + 撤回确认弹窗 #withdraw-modal
- flows 流水管理: 公司端数据集 + 待确认/已确认状态口径 + 详情弹窗
- reconcile 往来确认: 候选匹配/匹配度/断档提示 + 未选候选禁用态 + 单边全部匹配空态
- accounts 银行账户: 表格 + 账户明细弹窗 + 新账户登记弹窗
- notifications 通知: tabs/已读/空态/角标
- 跨批依赖: 往来确认完成后回写工作台待办区(#workspacePendingStatus/待办行/账期流程条)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 11:03:17 +08:00
leeferandmultica-agent 307f9ba76f HEL-122: 第3批——管理端剩余6页 + 对应弹窗换原型壳
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 10:14:17 +08:00
leeferandmultica-agent 020b556801 HEL-121: 第2批——两端外壳 + 管理总览 + 公司工作台(按原型换壳,保留 showView/角标/退出逻辑)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 09:32:40 +08:00
leeferandmultica-agent 7938a274f2 HEL-120: 第1批门面——导引入口 + 两张登录页 + 首登改密
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 08:42:11 +08:00
leefer dc0fb76f8f Merge pull request 'HEL-56: 整理项目说明手册' (#8) from agent/agent/e89028e267fd into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/8
2026-08-23 16:56:12 +08:00
Multica 总管andmultica-agent 5a6a629597 HEL-56: 整理项目说明手册
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 14:26:55 +08:00
leefer 5f215271af Merge pull request 'HEL-34: 登录页按「深海军蓝 × 账房金」方案改造' (#7) from feat/HEL-34-login-redesign into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/7
2026-08-22 22:57:49 +08:00
腾讯WorkBuddyandmultica-agent 3b10a58d93 HEL-34: 登录页按「深海军蓝 × 账房金」方案改造
Co-authored-by: multica-agent <github@multica.ai>
2026-08-22 22:18:24 +08:00
leefer 6d23711e6c Merge pull request 'B-114: B-43 双边流水归并与规范事件层(匹配引擎、个人过账映射、事件API)' (#4) from leefer/b43-canonical-transfer-matching into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/4
2026-08-19 00:55:52 +08:00
leefer a621ebc323 Merge pull request 'B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据' (#2) from feat/b42-import-api-hardening into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/2
2026-08-17 08:53:37 +08:00
25 changed files with 6364 additions and 1553 deletions
+2
View File
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md``项目需求.md``最新进度.md``任务清单.md`
# 项目交接说明
更新时间:2026-08-06
+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 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 仓库银行样本数据分级与脱敏治理
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 持久化层与不可变银行导入基础
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 正式认证、RBAC 与公司级数据隔离
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 动态公司、用户、账户和别名主数据
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 银行导入 API 加固与回归测试
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 规范转账事件、双边匹配与调拨排除
## 背景
+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 等能力。
+424 -1
View File
@@ -10,7 +10,7 @@ from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit
from bank_importer import auth, calculation, importing, master_data, matching, multipart, personal_transit
from bank_importer.db import connect, migrate, utc_now
@@ -71,6 +71,30 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/master-changes":
self._handle_admin_master_changes(query)
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_get()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_get()
return
if path == "/api/admin/coverage-gaps":
self._handle_admin_coverage_gaps(query)
return
if path == "/api/admin/calculation-changes":
self._handle_admin_calculation_changes()
return
if path == "/api/admin/balances/pair":
self._handle_admin_pair_balance(query)
return
if path == "/api/company/coverage-gaps":
self._handle_company_coverage_gaps()
return
if path == "/api/company/balances":
self._handle_company_balances(query)
return
if path == "/api/company/balances/pair":
self._handle_company_pair_balance(query)
return
if path == "/api/company/accounts":
self._handle_company_accounts()
return
@@ -167,6 +191,43 @@ class AppHandler(SimpleHTTPRequestHandler):
if mapping_review:
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_post()
return
opening_confirm = re.fullmatch(r"/api/admin/opening-balances/(\d+)/confirm", path)
if opening_confirm:
self._handle_admin_opening_balance_confirm(int(opening_confirm.group(1)))
return
opening_revise = re.fullmatch(r"/api/admin/opening-balances/(\d+)/revisions", path)
if opening_revise:
self._handle_admin_opening_balance_revise(int(opening_revise.group(1)))
return
opening_void = re.fullmatch(r"/api/admin/opening-balances/(\d+)/void", path)
if opening_void:
self._handle_admin_opening_balance_void(int(opening_void.group(1)))
return
if path == "/api/admin/coverage-gaps/recalculate":
self._handle_admin_recalculate_gaps()
return
attestation_review = re.fullmatch(
r"/api/admin/no-business-attestations/(\d+)/review", path
)
if attestation_review:
self._handle_admin_review_attestation(int(attestation_review.group(1)))
return
if path == "/api/company/no-business-attestations":
self._handle_company_submit_attestation()
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
def do_PUT(self) -> None:
path = urlparse(self.path).path
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------
@@ -664,6 +725,8 @@ class AppHandler(SimpleHTTPRequestHandler):
}
if outcome.get("matching") is not None:
payload["matching"] = outcome["matching"]
if decision == "confirm" and outcome.get("updated", 0) > 0:
calculation.recalculate_coverage_gaps(connection)
self._send_json(200, payload)
finally:
connection.close()
@@ -1153,6 +1216,366 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
def _handle_admin_calculation_start_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = calculation.start_date_payload(connection)
payload["status"] = "ok"
payload["summary"] = calculation.opening_coverage_summary(connection)
self._send_json(200, payload)
finally:
connection.close()
def _handle_admin_calculation_start_put(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
result = calculation.set_calculation_start_date(
connection,
str(data.get("calculation_start_date") or ""),
str(data.get("reason") or ""),
admin,
)
except calculation.LockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **result})
finally:
connection.close()
def _handle_admin_opening_balances_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_opening_balances(connection)
self._send_json(
200,
{
"status": "ok",
"items": items,
"summary": calculation.opening_coverage_summary(connection),
},
)
finally:
connection.close()
def _handle_admin_opening_balances_post(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_a = int(data.get("company_id_low") or data.get("from_company_id"))
company_b = int(data.get("company_id_high") or data.get("to_company_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定有效的公司。"})
return
try:
item = calculation.create_opening_balance(
connection,
company_a,
company_b,
data.get("amount"),
str(data.get("reason") or ""),
admin,
viewer_company_id=company_a,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_confirm(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.confirm_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_revise(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
item = calculation.revise_opening_balance(
connection,
revision_id,
data.get("amount"),
str(data.get("reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_void(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.void_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_coverage_gaps(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
status = (query.get("status") or [None])[0]
items = calculation.list_coverage_gaps(connection, status=status)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_admin_recalculate_gaps(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
rebuilt = calculation.recalculate_coverage_gaps(connection)
self._send_json(200, {"status": "ok", "rebuilt": rebuilt})
finally:
connection.close()
def _handle_admin_calculation_changes(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_change_log(connection)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _resolve_company_id(
self, connection, query: dict[str, list[str]], field: str
) -> int | None:
raw = (query.get(field) or [None])[0]
if raw is None:
return None
try:
return int(raw)
except ValueError:
self._send_json(400, {"status": "error", "message": f"{field} 参数无效。"})
return -1
def _handle_admin_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
from_id = self._resolve_company_id(connection, query, "from_company_id")
to_id = self._resolve_company_id(connection, query, "to_company_id")
if from_id == -1 or to_id == -1:
return
if from_id is None or to_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 from_company_id 与 to_company_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, from_id, to_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_coverage_gaps(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
items = calculation.list_coverage_gaps(
connection, company_id=user["company_id"]
)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_company_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
payload = calculation.compute_company_balances(
connection, user["company_id"], cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
def _handle_company_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
counterparty_id = self._resolve_company_id(connection, query, "counterparty_id")
if counterparty_id == -1:
return
if counterparty_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 counterparty_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, user["company_id"], counterparty_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_submit_attestation(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
data = self._read_json_body()
if data is None:
return
try:
bank_account_id = int(data.get("bank_account_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定 bank_account_id。"})
return
try:
item = calculation.submit_no_business_attestation(
connection,
company_id=user["company_id"],
bank_account_id=bank_account_id,
gap_start=str(data.get("gap_start") or ""),
gap_end=str(data.get("gap_end") or ""),
reason=str(data.get("reason") or ""),
evidence=str(data.get("evidence") or "") or None,
actor=user,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_review_attestation(self, attestation_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
decision = str(data.get("decision") or "")
try:
item = calculation.review_no_business_attestation(
connection,
attestation_id,
decision,
str(data.get("review_reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_users(self) -> None:
connection = connect(DB_PATH)
try:
+913
View File
@@ -0,0 +1,913 @@
"""Calculation window: start date, opening balances, coverage gaps and balances."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from . import master_data, matching
SETTING_START_DATE = "calculation_start_date"
OPENING_STATUSES = ("draft", "confirmed", "superseded", "void")
GAP_KINDS = ("head", "mid", "tail")
GAP_STATUSES = ("open", "closed_attested")
ATTESTATION_STATUSES = ("pending", "approved", "rejected")
class LockedError(ValueError):
"""Start date cannot change after a period is closed."""
class ConflictError(ValueError):
"""Revision or state conflict."""
def utc_today() -> str:
return datetime.now(timezone.utc).date().isoformat()
def _parse_decimal(value: object) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError):
raise ValueError("金额格式无效。") from None
def normalize_pair(company_a: int, company_b: int) -> tuple[int, int]:
if company_a == company_b:
raise ValueError("两家公司不能相同。")
return (company_a, company_b) if company_a < company_b else (company_b, company_a)
def signed_from_viewer(viewer_id: int, low_id: int, high_id: int, amount: Decimal) -> Decimal:
return amount if viewer_id == low_id else -amount
def get_setting(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"SELECT value FROM system_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row is not None else None
def get_calculation_start_date(connection: sqlite3.Connection) -> str | None:
return get_setting(connection, SETTING_START_DATE)
def has_closed_periods(connection: sqlite3.Connection) -> bool:
row = connection.execute("SELECT 1 FROM closed_periods LIMIT 1").fetchone()
return row is not None
def is_start_date_locked(connection: sqlite3.Connection) -> bool:
return has_closed_periods(connection)
def set_calculation_start_date(
connection: sqlite3.Connection,
start_date: str,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
start_date = master_data.validate_date(start_date, "起算日", required=True)
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("修改起算日必须填写原因。")
if is_start_date_locked(connection):
raise LockedError("已有结账月份,起算日已锁定。")
before = get_calculation_start_date(connection)
now = utc_now()
with connection:
connection.execute(
"""
INSERT INTO system_settings (key, value, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(SETTING_START_DATE, start_date, now, actor["id"]),
)
master_data.record_change(
connection,
"system_setting",
0,
"update_start_date",
{"calculation_start_date": before},
{"calculation_start_date": start_date},
reason,
actor,
)
recalculate_coverage_gaps(connection)
return {
"calculation_start_date": start_date,
"locked": False,
"previous": before,
}
def start_date_payload(connection: sqlite3.Connection) -> dict[str, object]:
return {
"calculation_start_date": get_calculation_start_date(connection),
"locked": is_start_date_locked(connection),
}
def _current_opening_revision(
connection: sqlite3.Connection, low_id: int, high_id: int
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ?
AND status IN ('draft', 'confirmed')
ORDER BY revision DESC
LIMIT 1
""",
(low_id, high_id),
).fetchone()
def _next_revision(connection: sqlite3.Connection, low_id: int, high_id: int) -> int:
row = connection.execute(
"""
SELECT MAX(revision) AS max_rev FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ?
""",
(low_id, high_id),
).fetchone()
return int(row["max_rev"] or 0) + 1
def create_opening_balance(
connection: sqlite3.Connection,
company_a: int,
company_b: int,
amount: object,
reason: str,
actor: sqlite3.Row,
*,
currency: str = "CNY",
viewer_company_id: int | None = None,
) -> dict[str, object]:
low_id, high_id = normalize_pair(company_a, company_b)
decimal_amount = _parse_decimal(amount)
if viewer_company_id is not None and viewer_company_id == high_id:
decimal_amount = -decimal_amount
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("录入期初必须填写原因。")
existing = _current_opening_revision(connection, low_id, high_id)
if existing is not None and existing["status"] == "draft":
raise ConflictError("该公司对已有待确认期初,请先确认或作废后再录入。")
if existing is not None and existing["status"] == "confirmed":
raise ConflictError("该对公司已有确认期初,请使用修订。")
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
company_id_low, company_id_high, amount, currency, revision,
status, reason, actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
""",
(
low_id,
high_id,
str(decimal_amount),
currency,
revision,
reason,
actor["id"],
actor["username"],
now,
),
)
row_id = int(cursor.lastrowid)
master_data.record_change(
connection,
"opening_balance",
row_id,
"create_draft",
None,
{"company_id_low": low_id, "company_id_high": high_id, "amount": str(decimal_amount)},
reason,
actor,
)
return opening_balance_payload(connection, row_id)
def confirm_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] != "draft":
raise ConflictError("只能确认待确认状态的期初。")
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("确认期初必须填写原因。")
with connection:
connection.execute(
"""
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
WHERE id = ?
""",
(reason, revision_id),
)
master_data.record_change(
connection,
"opening_balance",
revision_id,
"confirm",
{"status": "draft"},
{"status": "confirmed"},
reason,
actor,
)
return opening_balance_payload(connection, revision_id)
def revise_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
amount: object,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] != "confirmed":
raise ConflictError("只能修订已确认期初。")
decimal_amount = _parse_decimal(amount)
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("修订期初必须填写原因。")
low_id = int(row["company_id_low"])
high_id = int(row["company_id_high"])
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
connection.execute(
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
(revision_id,),
)
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
company_id_low, company_id_high, amount, currency, revision,
status, reason, actor_user_id, actor_username, supersedes_id, created_at
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
""",
(
low_id,
high_id,
str(decimal_amount),
row["currency"],
revision,
reason,
actor["id"],
actor["username"],
revision_id,
now,
),
)
new_id = int(cursor.lastrowid)
master_data.record_change(
connection,
"opening_balance",
new_id,
"revise",
{"amount": row["amount"], "revision_id": revision_id},
{"amount": str(decimal_amount), "revision_id": new_id},
reason,
actor,
)
return opening_balance_payload(connection, new_id)
def void_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] not in ("draft", "confirmed"):
raise ConflictError("该期初已作废或已被替代。")
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("作废期初必须填写原因。")
with connection:
connection.execute(
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
(reason, revision_id),
)
master_data.record_change(
connection,
"opening_balance",
revision_id,
"void",
{"status": row["status"]},
{"status": "void"},
reason,
actor,
)
return opening_balance_payload(connection, revision_id)
def opening_balance_payload(connection: sqlite3.Connection, revision_id: int) -> dict[str, object]:
row = connection.execute(
"""
SELECT r.*, cl.name AS company_low_name, ch.name AS company_high_name
FROM opening_balance_revisions r
JOIN companies cl ON cl.id = r.company_id_low
JOIN companies ch ON ch.id = r.company_id_high
WHERE r.id = ?
""",
(revision_id,),
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
amount = _parse_decimal(row["amount"])
return {
"id": row["id"],
"company_id_low": row["company_id_low"],
"company_id_high": row["company_id_high"],
"company_low_name": row["company_low_name"],
"company_high_name": row["company_high_name"],
"amount": str(amount),
"currency": row["currency"],
"revision": row["revision"],
"status": row["status"],
"reason": row["reason"],
"actor_username": row["actor_username"],
"supersedes_id": row["supersedes_id"],
"created_at": row["created_at"],
"direction_low": "receivable" if amount >= 0 else "payable",
}
def list_opening_balances(connection: sqlite3.Connection) -> list[dict[str, object]]:
rows = connection.execute(
"""
SELECT r.id FROM opening_balance_revisions r
JOIN (
SELECT company_id_low, company_id_high, MAX(revision) AS max_rev
FROM opening_balance_revisions
WHERE status IN ('draft', 'confirmed', 'void')
GROUP BY company_id_low, company_id_high
) latest ON latest.company_id_low = r.company_id_low
AND latest.company_id_high = r.company_id_high
AND latest.max_rev = r.revision
WHERE r.status IN ('draft', 'confirmed', 'void')
ORDER BY r.company_id_low, r.company_id_high
"""
).fetchall()
return [opening_balance_payload(connection, row["id"]) for row in rows]
def confirmed_opening_amount(
connection: sqlite3.Connection, low_id: int, high_id: int
) -> Decimal | None:
row = connection.execute(
"""
SELECT amount FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ? AND status = 'confirmed'
ORDER BY revision DESC LIMIT 1
""",
(low_id, high_id),
).fetchone()
if row is None:
return None
return _parse_decimal(row["amount"])
def _date_add(day: str, delta: int) -> str:
return (date.fromisoformat(day) + timedelta(days=delta)).isoformat()
def _merge_intervals(intervals: list[tuple[str, str]]) -> list[tuple[str, str]]:
if not intervals:
return []
sorted_intervals = sorted(intervals, key=lambda item: item[0])
merged = [sorted_intervals[0]]
for start, end in sorted_intervals[1:]:
last_start, last_end = merged[-1]
if date.fromisoformat(start) <= date.fromisoformat(_date_add(last_end, 1)):
if date.fromisoformat(end) > date.fromisoformat(last_end):
merged[-1] = (last_start, end)
else:
merged.append((start, end))
return merged
def _account_coverage_intervals(
connection: sqlite3.Connection, account: sqlite3.Row
) -> list[tuple[str, str]]:
rows = connection.execute(
"""
SELECT MIN(substr(r.transaction_at, 1, 10)) AS interval_start,
MAX(substr(r.transaction_at, 1, 10)) AS interval_end
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
JOIN import_batches b ON b.id = s.import_batch_id
WHERE b.upload_bank_account_id = ?
OR r.own_account = ?
GROUP BY s.id
ORDER BY interval_start
""",
(account["id"], account["account_number"]),
).fetchall()
return [
(row["interval_start"], row["interval_end"])
for row in rows
if row["interval_start"] and row["interval_end"]
]
def _account_transaction_dates(
connection: sqlite3.Connection, account: sqlite3.Row
) -> list[str]:
intervals = _account_coverage_intervals(connection, account)
dates: list[str] = []
for start, end in intervals:
current = date.fromisoformat(start)
end_day = date.fromisoformat(end)
while current <= end_day:
dates.append(current.isoformat())
current += timedelta(days=1)
return dates
def _dates_to_intervals(dates: list[str]) -> list[tuple[str, str]]:
if not dates:
return []
intervals: list[tuple[str, str]] = []
start = dates[0]
prev = dates[0]
for current in dates[1:]:
if date.fromisoformat(current) == date.fromisoformat(prev) + timedelta(days=1):
prev = current
continue
intervals.append((start, prev))
start = current
prev = current
intervals.append((start, prev))
return intervals
def _detect_gaps(
required_start: str,
required_end: str,
covered: list[tuple[str, str]],
) -> list[tuple[str, str, str]]:
if required_start > required_end:
return []
gaps: list[tuple[str, str, str]] = []
if not covered:
gaps.append((required_start, required_end, "head"))
return gaps
merged = _merge_intervals(covered)
first_start, first_end = merged[0]
if required_start < first_start:
gaps.append((required_start, _date_add(first_start, -1), "head"))
for index in range(len(merged) - 1):
_, left_end = merged[index]
right_start, _ = merged[index + 1]
gap_start = _date_add(left_end, 1)
gap_end = _date_add(right_start, -1)
if gap_start <= gap_end:
gaps.append((gap_start, gap_end, "mid"))
last_start, last_end = merged[-1]
tail_start = _date_add(last_end, 1)
if tail_start <= required_end:
kind = "tail" if last_end >= required_start else "head"
gaps.append((tail_start, required_end, kind))
return gaps
def detect_account_gaps(
connection: sqlite3.Connection,
account: sqlite3.Row,
*,
start_date: str | None,
today: str | None = None,
) -> list[tuple[str, str, str]]:
if account["status"] != "active":
return []
today = today or utc_today()
effective_from = account["effective_from"] or start_date or today
effective_to = account["effective_to"] or today
required_start = max(filter(None, [start_date, effective_from]))
required_end = min(today, effective_to)
if not master_data.is_usable(account, required_start):
return []
intervals = _account_coverage_intervals(connection, account)
return _detect_gaps(required_start, required_end, intervals)
def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
start_date = get_calculation_start_date(connection)
today = utc_today()
accounts = connection.execute(
"SELECT * FROM bank_accounts WHERE status = 'active'"
).fetchall()
rebuilt = 0
with connection:
for account in accounts:
connection.execute(
"""
DELETE FROM coverage_gaps
WHERE bank_account_id = ? AND status = 'open'
""",
(account["id"],),
)
for gap_start, gap_end, gap_kind in detect_account_gaps(
connection, account, start_date=start_date, today=today
):
connection.execute(
"""
INSERT INTO coverage_gaps (
bank_account_id, gap_start, gap_end, gap_kind,
status, first_detected_at
) VALUES (?, ?, ?, ?, 'open', ?)
ON CONFLICT(bank_account_id, gap_start, gap_end) DO UPDATE SET
gap_kind = excluded.gap_kind,
status = CASE coverage_gaps.status
WHEN 'closed_attested' THEN 'closed_attested'
ELSE 'open'
END
""",
(account["id"], gap_start, gap_end, gap_kind, utc_now()),
)
rebuilt += 1
return rebuilt
def coverage_gap_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
account = master_data.get_account(connection, row["bank_account_id"])
if account is None:
raise ValueError("账户不存在。")
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (account["company_id"],)
).fetchone()
start = date.fromisoformat(row["gap_start"])
end = date.fromisoformat(row["gap_end"])
gap_days = (end - start).days + 1
return {
"id": row["id"],
"bank_account_id": row["bank_account_id"],
"company_id": account["company_id"],
"company_name": company["name"] if company else None,
"account_number_masked": master_data.mask_account_number(account["account_number"]),
"gap_start": row["gap_start"],
"gap_end": row["gap_end"],
"gap_kind": row["gap_kind"],
"gap_days": gap_days,
"status": row["status"],
"first_detected_at": row["first_detected_at"],
}
def list_coverage_gaps(
connection: sqlite3.Connection,
*,
company_id: int | None = None,
status: str | None = None,
) -> list[dict[str, object]]:
clauses: list[str] = []
params: list[object] = []
if company_id is not None:
clauses.append("ba.company_id = ?")
params.append(company_id)
if status is not None:
clauses.append("g.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = connection.execute(
f"""
SELECT g.* FROM coverage_gaps g
JOIN bank_accounts ba ON ba.id = g.bank_account_id
{where}
ORDER BY g.gap_start, g.bank_account_id
""",
params,
).fetchall()
return [coverage_gap_payload(connection, row) for row in rows]
def submit_no_business_attestation(
connection: sqlite3.Connection,
*,
company_id: int,
bank_account_id: int,
gap_start: str,
gap_end: str,
reason: str,
evidence: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
gap_start = master_data.validate_date(gap_start, "断档起始日", required=True)
gap_end = master_data.validate_date(gap_end, "断档结束日", required=True)
if gap_start > gap_end:
raise ValueError("断档起始日不能晚于结束日。")
reason = str(reason or "").strip()
if len(reason) < 5:
raise ValueError("无业务说明至少 5 个字符。")
account = master_data.get_account(connection, bank_account_id)
if account["company_id"] != company_id:
raise ValueError("只能为本公司账户提交说明。")
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO no_business_attestations (
bank_account_id, gap_start, gap_end, reason, evidence,
submitted_by, company_id, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
""",
(
bank_account_id,
gap_start,
gap_end,
reason,
evidence,
actor["id"],
company_id,
now,
),
)
attestation_id = int(cursor.lastrowid)
return attestation_payload(connection, attestation_id)
def review_no_business_attestation(
connection: sqlite3.Connection,
attestation_id: int,
decision: str,
review_reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
).fetchone()
if row is None:
raise ValueError("无业务说明不存在。")
if row["status"] != "pending":
raise ConflictError("该说明已审核。")
review_reason = str(review_reason or "").strip()
if decision not in ("approve", "reject"):
raise ValueError("审核决定无效。")
if len(review_reason) < 2:
raise ValueError("审核必须填写理由。")
status = "approved" if decision == "approve" else "rejected"
now = utc_now()
with connection:
connection.execute(
"""
UPDATE no_business_attestations
SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?
WHERE id = ?
""",
(status, actor["id"], now, review_reason, attestation_id),
)
if status == "approved":
connection.execute(
"""
UPDATE coverage_gaps
SET status = 'closed_attested'
WHERE bank_account_id = ?
AND gap_start = ? AND gap_end = ?
""",
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
)
recalculate_coverage_gaps(connection)
return attestation_payload(connection, attestation_id)
def attestation_payload(connection: sqlite3.Connection, attestation_id: int) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
).fetchone()
if row is None:
raise ValueError("无业务说明不存在。")
account = master_data.get_account(connection, row["bank_account_id"])
if account is None:
raise ValueError("账户不存在。")
return {
"id": row["id"],
"bank_account_id": row["bank_account_id"],
"company_id": row["company_id"],
"gap_start": row["gap_start"],
"gap_end": row["gap_end"],
"reason": row["reason"],
"evidence": row["evidence"],
"status": row["status"],
"review_reason": row["review_reason"],
"reviewed_at": row["reviewed_at"],
"created_at": row["created_at"],
"account_number_masked": master_data.mask_account_number(account["account_number"]),
}
def list_change_log(connection: sqlite3.Connection, limit: int = 100) -> list[dict[str, object]]:
rows = connection.execute(
"""
SELECT * FROM master_data_changes
WHERE entity_type IN ('system_setting', 'opening_balance')
ORDER BY id DESC LIMIT ?
""",
(limit,),
).fetchall()
items: list[dict[str, object]] = []
for row in rows:
before = json.loads(row["before_json"]) if row["before_json"] else None
after = json.loads(row["after_json"]) if row["after_json"] else None
target = "起算日"
if row["entity_type"] == "opening_balance":
target = f"期初 #{row['entity_id']}"
items.append(
{
"id": row["id"],
"created_at": row["created_at"],
"actor_username": row["actor_username"],
"action": row["action"],
"target": target,
"before": before,
"after": after,
"reason": row["reason"],
}
)
return items
def _pair_net_change(
connection: sqlite3.Connection,
viewer_id: int,
counterparty_id: int,
*,
cutoff: str | None,
start_date: str | None,
) -> Decimal:
cutoff_where = ""
params: list[object] = [viewer_id, counterparty_id, counterparty_id, viewer_id]
if cutoff:
cutoff_where = "AND d.effective_at <= ?"
params.append(cutoff)
start_where = ""
if start_date:
start_where = "AND d.effective_at >= ?"
params.append(start_date)
rows = connection.execute(
f"""
SELECT d.amount, payer.company_id AS payer_id, payee.company_id AS payee_id
FROM eligible_intercompany_events e
JOIN transfer_match_decisions d ON d.id = e.decision_id
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE (
(payer.company_id = ? AND payee.company_id = ?)
OR (payer.company_id = ? AND payee.company_id = ?)
)
{cutoff_where}
{start_where}
""",
params,
).fetchall()
total = Decimal("0")
for row in rows:
amount = _parse_decimal(row["amount"])
if row["payee_id"] == viewer_id:
total += amount
elif row["payer_id"] == viewer_id:
total -= amount
return total
def pair_has_confirmed_opening(
connection: sqlite3.Connection, company_a: int, company_b: int
) -> bool:
low_id, high_id = normalize_pair(company_a, company_b)
return confirmed_opening_amount(connection, low_id, high_id) is not None
def compute_pair_balance(
connection: sqlite3.Connection,
viewer_id: int,
counterparty_id: int,
*,
cutoff: str | None = None,
) -> dict[str, object]:
cutoff = cutoff or utc_today()
cutoff = master_data.validate_date(cutoff, "截止日", required=True)
start_date = get_calculation_start_date(connection)
low_id, high_id = normalize_pair(viewer_id, counterparty_id)
opening_amount: Decimal | None = None
if start_date and pair_has_confirmed_opening(connection, viewer_id, counterparty_id):
stored = confirmed_opening_amount(connection, low_id, high_id)
assert stored is not None
opening_amount = signed_from_viewer(viewer_id, low_id, high_id, stored)
net_change = _pair_net_change(
connection,
viewer_id,
counterparty_id,
cutoff=cutoff,
start_date=start_date,
)
unresolved = matching.unresolved_amounts(connection, viewer_id, cutoff=cutoff)
pending_total = Decimal("0")
for row in unresolved:
pending_total += _parse_decimal(row["amount"] or "0")
basis = "full" if opening_amount is not None and start_date else "net_change"
payload: dict[str, object] = {
"viewer_company_id": viewer_id,
"counterparty_company_id": counterparty_id,
"effective_at": cutoff,
"calculation_start_date": start_date,
"net_change": str(net_change),
"basis": basis,
"pending_unconfirmed": str(pending_total),
"currency": "CNY",
}
if basis == "full" and opening_amount is not None:
closing = opening_amount + net_change
payload["opening"] = str(opening_amount)
payload["closing"] = str(closing)
payload["opening_effective_at"] = _date_add(start_date, -1)
return payload
def list_company_counterparties(
connection: sqlite3.Connection, company_id: int
) -> list[int]:
rows = connection.execute(
"""
SELECT DISTINCT CASE
WHEN payer.company_id = ? THEN payee.company_id
ELSE payer.company_id
END AS other_id
FROM eligible_intercompany_events e
JOIN transfer_decision_participants payer ON payer.decision_id = e.decision_id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = e.decision_id AND payee.role = 'payee'
WHERE payer.company_id = ? OR payee.company_id = ?
""",
(company_id, company_id, company_id),
).fetchall()
return [int(row["other_id"]) for row in rows if row["other_id"] is not None]
def compute_company_balances(
connection: sqlite3.Connection,
company_id: int,
*,
cutoff: str | None = None,
) -> dict[str, object]:
cutoff = cutoff or utc_today()
start_date = get_calculation_start_date(connection)
counterparties = list_company_counterparties(connection, company_id)
pairs = [
compute_pair_balance(connection, company_id, other_id, cutoff=cutoff)
for other_id in counterparties
]
complete = bool(start_date) and all(item["basis"] == "full" for item in pairs)
return {
"company_id": company_id,
"cutoff": cutoff,
"calculation_start_date": start_date,
"basis": "full" if complete and pairs else "net_change",
"pairs": pairs,
}
def opening_coverage_summary(connection: sqlite3.Connection) -> dict[str, int]:
companies = connection.execute(
"SELECT COUNT(*) AS n FROM companies WHERE status = 'active'"
).fetchone()["n"]
recorded = connection.execute(
"""
SELECT COUNT(DISTINCT company_id_low || ':' || company_id_high) AS n
FROM opening_balance_revisions WHERE status = 'confirmed'
"""
).fetchone()["n"]
return {"company_count": companies, "confirmed_pair_count": recorded}
+162
View File
@@ -569,6 +569,168 @@ MIGRATIONS: tuple[Migration, ...] = (
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
""",
),
Migration(
version=6,
name="0006_calculation_window",
up="""
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users (id)
);
CREATE TABLE closed_periods (
year_month TEXT PRIMARY KEY,
closed_at TEXT NOT NULL,
closed_by INTEGER REFERENCES users (id)
);
CREATE TABLE opening_balance_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id_low INTEGER NOT NULL REFERENCES companies (id),
company_id_high INTEGER NOT NULL REFERENCES companies (id),
amount TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
revision INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('draft', 'confirmed', 'superseded', 'void')),
reason TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_id INTEGER REFERENCES opening_balance_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (company_id_low, company_id_high, revision),
CHECK (company_id_low < company_id_high)
);
CREATE TABLE coverage_gaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
gap_kind TEXT NOT NULL CHECK (gap_kind IN ('head', 'mid', 'tail')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed_attested')),
first_detected_at TEXT NOT NULL,
UNIQUE (bank_account_id, gap_start, gap_end)
);
CREATE TABLE no_business_attestations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
reason TEXT NOT NULL,
evidence TEXT,
submitted_by INTEGER REFERENCES users (id),
company_id INTEGER NOT NULL REFERENCES companies (id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_opening_balance_pair ON opening_balance_revisions (company_id_low, company_id_high);
CREATE INDEX idx_coverage_gaps_account ON coverage_gaps (bank_account_id);
CREATE INDEX idx_attestations_company ON no_business_attestations (company_id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping', 'system_setting', 'opening_balance'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1)
AND (
(SELECT value FROM system_settings WHERE key = 'calculation_start_date') IS NULL
OR d.effective_at >= (SELECT value FROM system_settings WHERE key = 'calculation_start_date')
);
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias', 'personal_transit_mapping'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes
WHERE entity_type NOT IN ('system_setting', 'opening_balance');
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP TABLE IF EXISTS no_business_attestations;
DROP TABLE IF EXISTS coverage_gaps;
DROP TABLE IF EXISTS opening_balance_revisions;
DROP TABLE IF EXISTS closed_periods;
DROP TABLE IF EXISTS system_settings;
""",
),
)
+493
View File
@@ -0,0 +1,493 @@
"""Tests for calculation window: start date, opening balances, coverage gaps."""
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import json
import tempfile
import threading
import unittest
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
from test_server_auth import Client, as_json
class CalculationBase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
self.admin = self._admin()
self.company_a = self._company("甲公司")
self.company_b = self._company("乙公司")
self.account_a = self._approved_account(self.company_a, "6222000000000001")
self.account_b = self._approved_account(self.company_b, "6222000000000002")
def _admin(self):
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
return self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
def _company(self, name: str) -> int:
with self.connection:
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, utc_now(), utc_now()),
)
return int(cursor.lastrowid)
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
account = master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date=start,
actor=None,
)
return master_data.review_bank_account(
self.connection, account["id"], "approve", None, self.admin,
effective_from=start,
)
def add_confirmed_row(
self,
company_id: int,
*,
account_id: int,
own_account: str,
at: str,
income: str = "0",
expense: str = "0",
cp_account: str | None = None,
sheet: str = "流水",
source_row: int = 1,
) -> int:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(f"sha-{at}-{own_account}-{source_row}", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, counterparty_account, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(sheet_batch_id, source_row, at, income, expense, own_account, cp_account, utc_now()),
)
return int(cursor.lastrowid)
def add_confirmed_batch_range(
self,
company_id: int,
*,
account_id: int,
own_account: str,
start: str,
end: str,
sheet: str,
) -> None:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(f"sha-batch-{sheet}", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 2, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
for source_row, day in ((1, start), (2, end)):
self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, ?, ?, '0', '0', ?, ?)
""",
(sheet_batch_id, source_row, f"{day}T10:00:00", own_account, utc_now()),
)
class StartDateTests(CalculationBase):
def test_set_start_date_records_change(self) -> None:
result = calculation.set_calculation_start_date(
self.connection, "2026-01-01", "首次设定", self.admin
)
self.assertEqual("2026-01-01", result["calculation_start_date"])
row = self.connection.execute(
"SELECT 1 FROM master_data_changes WHERE entity_type = 'system_setting'"
).fetchone()
self.assertIsNotNone(row)
def test_locked_after_closed_period(self) -> None:
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "首次设定", self.admin
)
with self.connection:
self.connection.execute(
"INSERT INTO closed_periods (year_month, closed_at, closed_by) VALUES ('2026-01', ?, ?)",
(utc_now(), self.admin["id"]),
)
with self.assertRaises(calculation.LockedError):
calculation.set_calculation_start_date(
self.connection, "2026-02-01", "尝试修改", self.admin
)
class OpeningBalanceTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "测试起算日", self.admin
)
def test_bilateral_conservation_on_storage(self) -> None:
item = calculation.create_opening_balance(
self.connection,
self.company_a,
self.company_b,
"100.00",
"期初录入",
self.admin,
viewer_company_id=self.company_a,
)
low, high = calculation.normalize_pair(self.company_a, self.company_b)
self.assertEqual(low, item["company_id_low"])
stored = calculation.confirmed_opening_amount(self.connection, low, high)
self.assertIsNone(stored)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认期初", self.admin
)
stored = calculation.confirmed_opening_amount(self.connection, low, high)
self.assertEqual(Decimal("100.00"), stored)
from_b = calculation.signed_from_viewer(self.company_b, low, high, stored)
self.assertEqual(Decimal("-100.00"), from_b)
def test_confirmed_requires_revision_not_overwrite(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "50", "录入", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
with self.assertRaises(calculation.ConflictError):
calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "80", "重复录入", self.admin
)
revised = calculation.revise_opening_balance(
self.connection, item["id"], "80", "修订", self.admin
)
self.assertEqual("draft", revised["status"])
class CoverageGapTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-06-01", "起算", self.admin
)
def test_adjacent_intervals_no_mid_gap(self) -> None:
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-06-21",
end="2026-07-21",
sheet="批次A",
)
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-07-22",
end="2026-08-21",
sheet="批次B",
)
calculation.recalculate_coverage_gaps(self.connection)
mids = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE gap_kind = 'mid'"
).fetchall()
self.assertEqual([], mids)
def test_missing_day_mid_gap(self) -> None:
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-06-21",
end="2026-07-21",
sheet="批次A",
)
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-07-23",
end="2026-08-21",
sheet="批次B",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"""
SELECT gap_start, gap_end FROM coverage_gaps
WHERE gap_kind = 'mid' AND gap_start = '2026-07-22'
"""
).fetchone()
self.assertIsNotNone(gap)
self.assertEqual("2026-07-22", gap["gap_end"])
def test_attestation_closes_gap_without_bank_row(self) -> None:
self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-06-21T10:00:00",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE status = 'open'"
).fetchone()
self.assertIsNotNone(gap)
before_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
cashier_id = auth.create_user(
self.connection, "cashier-a", "CashierA123", "company", self.company_a
)
cashier = self.connection.execute(
"SELECT * FROM users WHERE id = ?", (cashier_id,)
).fetchone()
att = calculation.submit_no_business_attestation(
self.connection,
company_id=self.company_a,
bank_account_id=self.account_a["id"],
gap_start=gap["gap_start"],
gap_end=gap["gap_end"],
reason="当日账户无资金往来",
evidence=None,
actor=cashier,
)
calculation.review_no_business_attestation(
self.connection, att["id"], "approve", "审核通过", self.admin
)
after_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
self.assertEqual(before_rows, after_rows)
closed = self.connection.execute(
"SELECT status FROM coverage_gaps WHERE id = ?", (gap["id"],)
).fetchone()
self.assertEqual("closed_attested", closed["status"])
class BalanceBasisTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "起算", self.admin
)
def test_without_opening_returns_net_change(self) -> None:
row_a = self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-01-05T10:00:00",
expense="100.00",
cp_account="6222000000000002",
)
row_b = self.add_confirmed_row(
self.company_b,
account_id=self.account_b["id"],
own_account="6222000000000002",
at="2026-01-05T11:00:00",
income="100.00",
cp_account="6222000000000001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("net_change", balance["basis"])
self.assertNotIn("closing", balance)
def test_with_opening_returns_full_basis(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "录入", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", balance["basis"])
self.assertEqual("200", balance["opening"])
self.assertEqual("200", balance["closing"])
def test_pre_start_events_excluded(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
row_a = self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2025-12-31T10:00:00",
expense="50.00",
cp_account="6222000000000002",
)
row_b = self.add_confirmed_row(
self.company_b,
account_id=self.account_b["id"],
own_account="6222000000000002",
at="2025-12-31T11:00:00",
income="50.00",
cp_account="6222000000000001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("0", balance["net_change"])
class CalculationApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
cls.db_path = Path(cls.temp_dir.name) / "app.db"
cls.storage_dir = Path(cls.temp_dir.name) / "files"
cls.storage_dir.mkdir()
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage_dir
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", "AdminPass123", "admin",
must_change_password=False,
)
company_id = master_data.create_company(connection, "甲公司", None, None, None)
auth.create_user(
connection, "cashier-a", "CashierA123", "company", company_id,
must_change_password=False,
)
connection.close()
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.AppHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.client = Client("127.0.0.1", cls.port)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.temp_dir.cleanup()
def test_company_cannot_call_admin_start_date(self) -> None:
self.client.post_json("/api/login", {
"username": "cashier-a", "password": "CashierA123", "portal": "company",
})
status, _, _ = self.client.request(
"PUT",
"/api/admin/settings/calculation-start",
body=json.dumps(
{"calculation_start_date": "2026-01-01", "reason": "越权"}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
self.assertEqual(403, status)
def test_admin_can_set_start_date(self) -> None:
client = Client("127.0.0.1", self.port)
client.post_json("/api/login", {
"username": "group-admin", "password": "AdminPass123", "portal": "admin",
})
status, _, body = client.request(
"PUT",
"/api/admin/settings/calculation-start",
body=json.dumps(
{"calculation_start_date": "2026-01-01", "reason": "初始化"}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
self.assertEqual(200, status)
data = as_json(body)
self.assertEqual("2026-01-01", data["calculation_start_date"])
if __name__ == "__main__":
unittest.main()
+9 -4
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], first)
self.assertEqual([1, 2, 3, 4, 5, 6], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -73,19 +73,24 @@ class MigrationTests(PersistenceTestCase):
"transfer_match_candidates",
"current_transfer_decisions",
"transfer_observation_claims",
"system_settings",
"closed_periods",
"opening_balance_revisions",
"coverage_gaps",
"no_business_attestations",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
+1017 -185
View File
File diff suppressed because it is too large Load Diff
+1347 -429
View File
File diff suppressed because it is too large Load Diff
+912 -151
View File
File diff suppressed because it is too large Load Diff
+693
View File
@@ -0,0 +1,693 @@
/* ─── 金牛实业资金往来管理系统 · 共享样式 ─────────────────────────────
方向: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; }
.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-template-columns: minmax(0, 1fr); }
}
@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; }
}
/* ─── 数据表 ────────────────────────────────────────────────────── */
.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; }
@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=4" />
</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>
+77
View File
@@ -0,0 +1,77 @@
<!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=4" />
</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">
<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=4"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!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=4" />
</head>
<body data-role="company">
<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">
<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=4"></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;
});
-738
View File
@@ -1,738 +0,0 @@
:root {
color-scheme: dark;
--font-ui: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
--font-data: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
--color-bg: #050505;
--color-bg-soft: #0a0a0a;
--color-nav: #0a0a0a;
--color-surface: rgba(22, 22, 22, 0.82);
--color-surface-solid: #161616;
--color-surface-raised: #1c1c1c;
--color-surface-muted: rgba(255, 255, 255, 0.035);
--color-line: rgba(255, 255, 255, 0.09);
--color-line-strong: rgba(255, 255, 255, 0.16);
--color-ink: #f2f7f4;
--color-ink-soft: #a6b0aa;
--color-ink-muted: #89938d;
--color-primary: #37eb89;
--color-primary-strong: #70ffae;
--color-primary-dark: #08160e;
--color-primary-wash: rgba(55, 235, 137, 0.11);
--color-positive: #37eb89;
--color-positive-wash: rgba(55, 235, 137, 0.1);
--color-warning: #ffbc52;
--color-warning-wash: rgba(255, 188, 82, 0.11);
--color-danger: #ff626d;
--color-danger-wash: rgba(255, 98, 109, 0.11);
--color-info: #66a8ff;
--color-info-wash: rgba(102, 168, 255, 0.11);
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 22px;
--radius-xl: 28px;
--shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px rgba(255, 255, 255, 0.035);
--shadow-low: 0 12px 32px rgba(0, 0, 0, 0.22), inset 0 1px rgba(255, 255, 255, 0.06), inset 0 -1px 0 rgba(0, 0, 0, 0.22);
--shadow-glow: 0 0 28px rgba(55, 235, 137, 0.12);
--duration-fast: 150ms;
--duration-standard: 260ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--color-bg); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--color-bg); color: var(--color-ink); font: 14px/1.6 var(--font-ui); -webkit-font-smoothing: antialiased; }
button, input, select, textarea { font: inherit; }
button, a, summary { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, p, dl, dd { margin: 0; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
::selection { background: rgba(55, 235, 137, 0.26); color: #fff; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 10px; background: rgba(255, 255, 255, 0.15); background-clip: padding-box; }
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.skip-link { position: fixed; top: 8px; left: 50%; z-index: 500; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-primary); color: var(--color-primary-dark); transform: translate(-50%, -150%); }
.skip-link:focus { transform: translate(-50%, 0); }
:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 3px; }
[hidden] { display: none !important; }
/* Application frame */
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 266px minmax(0, 1fr); }
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 248px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 12, 12, 0.9); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); }
.brand { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 7px 10px 20px; border-bottom: 1px solid var(--color-line); }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(112, 255, 174, 0.32); border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary-strong); box-shadow: var(--shadow-glow); font-size: 18px; font-weight: 800; }
.brand-copy { display: flex; flex-direction: column; min-width: 0; }
.brand-copy strong { font-size: 15px; }
.brand-copy small, .user-block small, .company-context small { color: var(--color-ink-muted); font-size: 10px; }
.company-context { min-height: 64px; display: flex; align-items: center; gap: 10px; margin: 12px 4px 2px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.company-context > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary); color: var(--color-primary-dark); font-weight: 800; }
.company-context div { display: flex; flex-direction: column; }
.nav-list { display: grid; gap: 8px; padding: 14px 2px; overflow-y: auto; }
.nav-item { position: relative; width: 100%; min-height: 46px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 14px; background: transparent; color: var(--color-ink-soft); text-align: left; cursor: pointer; transition: color var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-fast); }
.nav-item:hover { color: var(--color-ink); background: rgba(255, 255, 255, 0.045); transform: translateX(2px); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.075); color: var(--color-ink); box-shadow: inset 0 1px rgba(255, 255, 255, 0.045), 0 8px 22px rgba(0, 0, 0, 0.2); }
.nav-item.is-active svg { color: var(--color-primary); filter: drop-shadow(0 0 8px rgba(55, 235, 137, 0.42)); }
.nav-item svg { width: 17px; flex: 0 0 auto; }
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-item b { min-width: 19px; height: 19px; display: grid; place-items: center; margin-left: auto; border-radius: 7px; background: var(--color-primary-wash); color: var(--color-primary); font-size: 10px; }
.sidebar-footer { display: grid; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-line); }
.user-block { min-height: 51px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; }
.user-block > span:last-child { display: flex; flex-direction: column; }
.avatar { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #242c28; color: var(--color-primary-strong); font-weight: 700; }
.workspace { min-width: 0; grid-column: 2; }
.topbar { position: sticky; top: 0; z-index: 90; height: 56px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 5, 5, 0.82); backdrop-filter: blur(22px); }
.topbar-spacer { flex: 1; }
.workspace-name { display: flex; flex-direction: column; min-width: 150px; }
.workspace-name span { color: var(--color-ink-muted); font-size: 10px; }
.workspace-name strong { font-size: 14px; }
.demo-badge { padding: 3px 8px; border: 1px solid rgba(255, 188, 82, 0.22); border-radius: 8px; background: var(--color-warning-wash); color: var(--color-warning); font-size: 10px; }
.topbar-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.search-box { width: clamp(200px, 22vw, 340px); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 13px; border: 1px solid var(--color-line); border-radius: 14px; background: rgba(255, 255, 255, 0.035); color: var(--color-ink-muted); transition: border-color var(--duration-fast), background var(--duration-fast), box-shadow var(--duration-fast); }
.search-box:focus-within { border-color: rgba(55, 235, 137, 0.4); background: rgba(255, 255, 255, 0.055); box-shadow: var(--shadow-glow); }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.search-box input::placeholder, input::placeholder, textarea::placeholder { color: #626b66; }
.period-button, .icon-button, .inside-icon, .swap-button { display: inline-grid; place-items: center; border: 1px solid var(--color-line); background: var(--color-surface-muted); color: var(--color-ink-soft); cursor: pointer; transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.period-button { min-height: 40px; grid-auto-flow: column; gap: 8px; padding: 0 13px; border-radius: 14px; }
.icon-button { position: relative; width: 40px; height: 40px; border-radius: 13px; }
.inside-icon { width: 36px; height: 36px; border: 0; background: transparent; }
.period-button:hover, .icon-button:hover, .inside-icon:hover, .swap-button:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.07); color: var(--color-ink); transform: translateY(-1px); }
.notification-dot { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; display: grid; place-items: center; border: 2px solid var(--color-bg); border-radius: 9px; background: var(--color-danger); color: #fff; font: 9px var(--font-data); }
.menu-button { display: none; }
main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
.app-view { display: none; }
.app-view.is-active { display: block; }
.page-heading { min-height: 56px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.page-heading h1 { font-size: clamp(22px, 2.4vw, 30px); line-height: 1.2; letter-spacing: 0; }
.page-heading p { margin-top: 5px; color: var(--color-ink-muted); }
.content-toolbar { display: contents; }
.content-toolbar > .search-box { margin-inline: auto; width: clamp(260px, 42vw, 520px); }
.content-toolbar .toolbar-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.dashboard-head { align-items: center; gap: 20px; margin-bottom: 20px; }
/* Commands and fields */
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border: 1px solid transparent; border-radius: 13px; font-weight: 700; cursor: pointer; transition: transform var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast), border-color var(--duration-fast); }
.button:hover { transform: translateY(-1px); }
.button:active { transform: translateY(0) scale(0.985); }
.button.primary { background: var(--color-primary); color: var(--color-primary-dark); box-shadow: 0 8px 24px rgba(55, 235, 137, 0.18); }
.button.primary:hover { background: var(--color-primary-strong); box-shadow: 0 10px 30px rgba(55, 235, 137, 0.25); }
.button.secondary { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink); }
.button.secondary:hover { border-color: var(--color-line-strong); background: rgba(255, 255, 255, 0.08); }
.button.small { min-height: 34px; padding-inline: 11px; border-radius: 11px; font-size: 11px; }
.button.wide { width: 100%; }
.button:disabled { opacity: 0.42; cursor: not-allowed; transform: none; }
.text-button { padding: 3px 0; border: 0; background: none; color: var(--color-primary); cursor: pointer; }
.text-button:hover { color: var(--color-primary-strong); }
.full-text-button { width: 100%; padding: 13px; border-top: 1px solid var(--color-line); }
.field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--color-ink-soft); }
.field > span { color: var(--color-ink-muted); font-size: 11px; }
.field input, .field select, .field textarea { width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--color-line); border-radius: 11px; outline: 0; background: rgba(2, 5, 3, 0.48); color: var(--color-ink); transition: border-color var(--duration-fast), box-shadow var(--duration-fast), background var(--duration-fast); }
.field textarea { resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: rgba(55, 235, 137, 0.5); background: rgba(2, 5, 3, 0.7); box-shadow: 0 0 0 3px rgba(55, 235, 137, 0.08); }
.field select { color-scheme: dark; }
.field.compact { min-width: 150px; }
.password-field { display: grid; grid-template-columns: 1fr 38px; border: 1px solid var(--color-line); border-radius: 12px; background: rgba(2, 5, 3, 0.48); }
.password-field input { border: 0; background: transparent; }
.check-field { display: flex; align-items: center; gap: 8px; color: var(--color-ink-soft); }
.check-field input { accent-color: var(--color-primary); }
.swap-button { width: 38px; height: 38px; border-radius: 12px; align-self: end; }
/* Glass surfaces */
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { position: relative; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.82) 0%, rgba(16, 16, 16, 0.86) 50%, rgba(10, 10, 10, 0.88) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, rgba(255, 255, 255, 0) 70%); pointer-events: none; }
.panel:hover { box-shadow: 0 20px 48px rgba(0, 0, 0, 0.32), inset 0 1px rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
.panel { min-width: 0; overflow: hidden; }
.panel > * { position: relative; }
.panel-heading { min-height: 67px; display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 17px; border-bottom: 1px solid var(--color-line); }
.panel-heading h2 { font-size: 15px; }
.panel-heading p { margin-top: 3px; color: var(--color-ink-muted); font-size: 11px; }
.status { display: inline-flex; align-items: center; justify-content: center; gap: 5px; width: max-content; min-height: 23px; padding: 2px 8px; border: 1px solid transparent; border-radius: 8px; font-size: 10px; font-style: normal; white-space: nowrap; }
.status.success { border-color: rgba(55, 235, 137, 0.18); background: var(--color-positive-wash); color: var(--color-positive); }
.status.warning { border-color: rgba(255, 188, 82, 0.18); background: var(--color-warning-wash); color: var(--color-warning); }
.status.danger { border-color: rgba(255, 98, 109, 0.18); background: var(--color-danger-wash); color: var(--color-danger); }
.status.neutral { border-color: var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); }
.task-level { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; font-size: 10px; font-style: normal; font-weight: 800; }
.task-level.danger { background: var(--color-danger-wash); color: var(--color-danger); }
.task-level.warning { background: var(--color-warning-wash); color: var(--color-warning); }
.task-level.neutral { background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
/* Dashboard */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0 0 18px; }
.metric-card { position: relative; min-height: 164px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 24px 22px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.86) 0%, rgba(14, 14, 14, 0.88) 50%, rgba(8, 8, 8, 0.9) 100%); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); transform: perspective(900px) rotateX(0.6deg) rotateY(-0.8deg); transform-origin: center bottom; transition: transform var(--duration-standard) var(--ease-out), border-color var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 42%, rgba(255, 255, 255, 0) 72%); pointer-events: none; }
.metric-card::after { content: ""; position: absolute; inset: auto 15% -1px 15%; height: 1px; background: currentColor; opacity: 0.35; box-shadow: 0 -8px 24px currentColor; transition: opacity var(--duration-standard), box-shadow var(--duration-standard); }
.metric-card:hover::after { opacity: 0.7; box-shadow: 0 -10px 34px currentColor; }
.metric-card:hover { transform: perspective(900px) rotateX(3.5deg) rotateY(-4.5deg) translateY(-10px); border-color: rgba(255, 255, 255, 0.32); box-shadow: 0 36px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 1px rgba(255, 255, 255, 0.14); }
.metric-copy { align-self: start; }
.metric-label { display: block; color: var(--color-ink-soft); font-size: 13px; }
.metric-value { display: block; margin-top: 7px; font: 700 34px/1.05 var(--font-data); letter-spacing: 0; }
.metric-value small { margin-left: 5px; color: var(--color-ink-muted); font: 11px var(--font-ui); }
.metric-icon { position: relative; width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--color-line); border-radius: 50%; background: radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 70%); color: var(--color-ink-soft); box-shadow: inset 0 1px rgba(255, 255, 255, 0.05); }
.metric-icon::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.12; pointer-events: none; }
.metric-foot { grid-column: 1 / -1; color: var(--color-ink-muted); font-size: 11px; }
.metric-foot strong { color: var(--color-positive); }
.metric-card.warning { color: var(--color-warning); }
.metric-card.danger { color: var(--color-danger); }
.metric-card.success { color: var(--color-positive); }
.metric-card .metric-copy { color: var(--color-ink); }
.metric-card .metric-icon { color: var(--color-ink-soft); }
.metric-card .metric-foot { color: var(--color-ink-muted); }
.metric-card[data-metric-link], .metric-card[data-metric-action] { cursor: pointer; }
.metric-card.warning .metric-icon, .metric-card.warning .metric-foot strong { color: var(--color-warning); }
.metric-card.warning .metric-icon::after { opacity: 0.28; }
.metric-card.danger .metric-icon, .metric-card.danger .metric-foot strong { color: var(--color-danger); }
.metric-card.danger .metric-icon::after { opacity: 0.32; }
.metric-card.success .metric-icon { color: var(--color-positive); }
.metric-card.success .metric-icon::after { opacity: 0.22; border-color: var(--color-positive); }
.period-ribbon { display: grid; grid-template-columns: 1fr 1fr 1fr minmax(310px, 1.4fr); margin-bottom: 18px; overflow: hidden; }
.period-ribbon > div { min-height: 72px; display: flex; flex-direction: column; justify-content: center; padding: 13px 17px; border-left: 1px solid var(--color-line); }
.period-ribbon > div:first-child { border-left: 0; }
.period-ribbon span, .period-ribbon small { color: var(--color-ink-muted); }
.period-ribbon strong { margin-top: 3px; }
.period-ribbon > .period-warning { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.period-warning > svg { color: var(--color-warning); }
.period-warning > span { display: flex; flex-direction: column; }
.period-warning > span strong { color: var(--color-ink); }
.admin-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr); gap: 18px; margin-bottom: 18px; }
.task-list button { width: 100%; min-height: 73px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 18px; align-items: center; gap: 11px; padding: 12px 16px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; transition: background var(--duration-fast); }
.task-list button:first-child { border-top: 0; }
.task-list button:hover { background: rgba(255, 255, 255, 0.035); }
.task-list button > span:nth-child(2) { display: flex; flex-direction: column; }
.task-list small, .task-list button > b { color: var(--color-ink-muted); font-size: 11px; }
.task-list button > svg { color: var(--color-ink-muted); }
.quick-pair-form { display: grid; grid-template-columns: 1fr 38px 1fr; gap: 10px; padding: 16px; }
.quick-pair-form .button { grid-column: 1 / -1; }
.quick-result { margin: 0 16px 16px; padding: 14px; border: 1px solid rgba(55, 235, 137, 0.16); border-radius: var(--radius-md); background: var(--color-primary-wash); }
.quick-result span, .quick-result p { color: var(--color-ink-muted); }
.quick-result strong { display: block; margin: 4px 0; color: var(--color-primary-strong); font: 700 22px var(--font-data); }
.quick-result small { margin-left: 4px; font: 10px var(--font-ui); }
/* Key-company / account-coverage flat list (replaces the floating 3D card stack) */
.company-list { display: grid; gap: 8px; padding: 10px 12px 14px; }
.company-row { position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 62px; padding: 11px 13px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(22, 22, 22, 0.7) 0%, rgba(12, 12, 12, 0.75) 100%); cursor: pointer; transition: background var(--duration-fast), border-color var(--duration-fast), transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard); }
.company-row:hover { background: linear-gradient(160deg, rgba(32, 32, 32, 0.8) 0%, rgba(18, 18, 18, 0.85) 100%); border-color: var(--color-line-strong); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); }
.company-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: 2px; }
.company-row-mark { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
.company-row.is-warning .company-row-mark { background: var(--color-warning-wash); color: var(--color-warning); }
.company-row.is-danger .company-row-mark { background: var(--color-danger-wash); color: var(--color-danger); }
.company-row-body { display: flex; flex-direction: column; min-width: 0; }
.company-row-body strong { font-size: 14px; }
.company-row-body small { color: var(--color-ink-muted); font-size: 11px; }
.company-row-figure { display: flex; flex-direction: column; align-items: flex-end; text-align: right; min-width: 92px; }
.company-row-figure strong { font: 600 14px var(--font-data); color: var(--color-ink); }
.company-row-figure small { color: var(--color-ink-muted); font-size: 11px; }
.company-row .status { flex: 0 0 auto; }
/* Timeline-strip: horizontal calculation window ribbon */
.timeline-strip { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 14px; align-items: stretch; padding: 16px 18px; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: linear-gradient(180deg, rgba(22, 22, 22, 0.86), rgba(10, 10, 10, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
.timeline-strip::before { content: ""; position: absolute; }
.timeline-legend { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
.timeline-legend strong { font-size: 13px; }
.timeline-legend span { color: var(--color-ink-muted); font-size: 11px; }
.timeline-body { position: relative; min-height: 88px; padding-top: 6px; }
.timeline-axis { position: relative; height: 14px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); border-bottom: 1px solid var(--color-line); }
.timeline-axis span { font: 600 10px var(--font-data); color: var(--color-ink-muted); align-self: end; padding-bottom: 4px; text-align: left; }
.timeline-axis span + span { border-left: 1px solid var(--color-line); padding-left: 4px; }
.timeline-rows { display: grid; gap: 8px; margin-top: 12px; }
.timeline-row { position: relative; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px; font-size: 11px; }
.timeline-row > span { color: var(--color-ink-soft); }
.timeline-track { position: relative; height: 14px; border-radius: 7px; background: rgba(255, 255, 255, 0.04); overflow: hidden; }
.timeline-track i { position: absolute; top: 0; bottom: 0; border-radius: inherit; }
.timeline-track i.coverage { background: linear-gradient(90deg, rgba(55, 235, 137, 0.35), rgba(55, 235, 137, 0.55)); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.32); }
.timeline-track i.warning { background: linear-gradient(90deg, rgba(255, 188, 82, 0.32), rgba(255, 188, 82, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 188, 82, 0.32); }
.timeline-track i.danger { background: linear-gradient(90deg, rgba(255, 98, 109, 0.32), rgba(255, 98, 109, 0.52)); box-shadow: inset 0 0 0 1px rgba(255, 98, 109, 0.32); }
.timeline-today { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 12px rgba(112, 255, 174, 0.6); border-radius: 1px; }
.timeline-today::after { content: ""; position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: var(--color-primary-strong); box-shadow: 0 0 0 4px rgba(112, 255, 174, 0.18); }
.timeline-strip-panel { position: relative; }
/* Ledger and queries */
.inline-search { width: min(240px, 38vw); height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 11px; background: rgba(0, 0, 0, 0.18); color: var(--color-ink-muted); }
.inline-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--color-ink); }
.ledger-head, .company-ledger summary { display: grid; grid-template-columns: minmax(210px, 1.4fr) repeat(2, minmax(100px, 0.75fr)) minmax(150px, 0.9fr) 86px 24px; align-items: center; gap: 10px; }
.ledger-head { min-height: 42px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 10px; }
.company-ledger { border-top: 1px solid var(--color-line); }
.company-ledger:first-child { border-top: 0; }
.company-ledger summary { min-height: 72px; padding: 10px 16px; list-style: none; cursor: pointer; transition: background var(--duration-fast); }
.company-ledger summary::-webkit-details-marker { display: none; }
.company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); }
.company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); }
.company-ledger[open] summary > svg { transform: rotate(180deg); }
.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; }
.company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; }
.company-name small { color: var(--color-ink-muted); }
.amount { font: 600 13px var(--font-data); }
.amount.debit { color: var(--color-positive); }
.amount.credit { color: var(--color-warning); }
.ledger-breakdown { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--color-line); background: rgba(0, 0, 0, 0.13); }
.ledger-breakdown section { padding: 14px 16px; }
.ledger-breakdown section + section { border-left: 1px solid var(--color-line); }
.ledger-breakdown header { display: flex; justify-content: space-between; margin-bottom: 8px; color: var(--color-ink-soft); }
.subject-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-top: 1px solid var(--color-line); background: transparent; text-align: left; cursor: pointer; }
.subject-row:hover { background: rgba(255, 255, 255, 0.035); }
.subject-row span { display: flex; flex-direction: column; }
.subject-row small { color: var(--color-ink-muted); }
.query-band, .filter-grid { padding: 17px; margin-bottom: 18px; }
.pair-query-form { display: grid; grid-template-columns: minmax(150px, 1fr) 38px minmax(150px, 1fr) minmax(150px, 0.8fr) auto; align-items: end; gap: 11px; }
.pair-report { overflow: hidden; }
.pair-report-heading { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
.pair-report-heading p { color: var(--color-ink-muted); }
.pair-result { text-align: right; }
.pair-result strong { display: block; color: var(--color-primary); font: 700 22px var(--font-data); }
.pair-balance-line { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
.pair-balance-line div { min-height: 76px; display: flex; flex-direction: column; justify-content: center; padding: 12px 16px; border-left: 1px solid var(--color-line); }
.pair-balance-line div:first-child { border-left: 0; }
.pair-balance-line span, .pair-balance-line small { color: var(--color-ink-muted); }
.pair-balance-line strong { margin-top: 3px; font: 600 15px var(--font-data); }
.pair-final strong { color: var(--color-primary); font-size: 19px; }
.subject-strip { display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--color-line); }
.subject-strip button { min-height: 62px; display: flex; flex-direction: column; justify-content: center; padding: 9px 14px; border: 0; border-left: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.018); text-align: left; cursor: pointer; }
.subject-strip button:first-child { border-left: 0; }
.subject-strip button.is-active { background: var(--color-primary-wash); color: var(--color-primary); box-shadow: inset 0 -2px var(--color-primary); }
.subject-strip span { color: var(--color-ink-muted); font-size: 10px; }
/* Filters and tables */
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px; margin-bottom: 18px; }
.segmented { display: flex; gap: 4px; padding: 4px; overflow-x: auto; border: 1px solid var(--color-line); border-radius: 13px; background: rgba(0, 0, 0, 0.18); }
.segmented button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--color-ink-muted); white-space: nowrap; cursor: pointer; }
.segmented button.is-active { background: rgba(255, 255, 255, 0.09); color: var(--color-ink); }
.flow-filters { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)) minmax(200px, 1.4fr) auto; align-items: end; gap: 11px; }
.flow-filters .grow { min-width: 0; }
.table-summary { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--color-line); color: var(--color-ink-muted); font-size: 11px; }
.table-scroll { max-width: 100%; overflow: auto; }
.data-table { width: 100%; min-width: 860px; border-collapse: collapse; }
.data-table th { height: 42px; padding: 8px 13px; border-bottom: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.025); color: var(--color-ink-muted); font-size: 10px; font-weight: 500; text-align: left; white-space: nowrap; }
.data-table td { height: 55px; padding: 9px 13px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); vertical-align: middle; }
.data-table tbody tr { transition: background var(--duration-fast); }
.data-table tbody tr:hover { background: rgba(55, 235, 137, 0.035); }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table td strong, .data-table td small { display: block; }
.data-table td strong { color: var(--color-ink); }
.data-table td small { color: var(--color-ink-muted); }
.data-table .number { color: var(--color-ink); text-align: right; font-family: var(--font-data); font-variant-numeric: tabular-nums; }
/* Settings, reminders and company work */
.settings-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; }
.form-body { display: grid; gap: 15px; padding: 17px; }
.panel form footer, form.panel footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 17px; border-top: 1px solid var(--color-line); }
.closing-panel { grid-column: 1 / -1; }
.closing-checks { display: grid; grid-template-columns: repeat(4, 1fr); }
.closing-checks article { min-height: 75px; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-left: 1px solid var(--color-line); }
.closing-checks article:first-child { border-left: 0; }
.closing-checks svg { color: var(--color-positive); }
.closing-checks .is-blocked svg { color: var(--color-danger); }
.closing-checks span { display: flex; flex-direction: column; }
.closing-checks small { color: var(--color-ink-muted); }
.closing-footer { min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
.closing-footer > span { margin-right: auto; color: var(--color-ink-muted); }
.reminder-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; }
.notification-list article { min-height: 76px; display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--color-line); }
.notification-list article:first-child { border-top: 0; }
.notification-list article.is-unread { background: rgba(102, 168, 255, 0.045); }
.notification-list article > span:nth-child(2) { display: flex; flex-direction: column; }
.notification-list small, .notification-list p { color: var(--color-ink-muted); }
.notification-list p { margin-top: 4px; }
.notification-icon { width: 35px; height: 35px; display: grid; place-items: center; border-radius: 12px; background: rgba(255, 255, 255, 0.055); color: var(--color-ink-soft); }
.notification-icon.danger { background: var(--color-danger-wash); color: var(--color-danger); }
.notification-icon.warning { background: var(--color-warning-wash); color: var(--color-warning); }
.notification-icon.success { background: var(--color-positive-wash); color: var(--color-positive); }
.company-alert { min-height: 78px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 18px; padding: 14px 16px; border: 1px solid rgba(255, 98, 109, 0.22); border-radius: var(--radius-lg); background: var(--color-danger-wash); box-shadow: var(--shadow-low); }
.company-alert > svg { color: var(--color-danger); }
.company-alert p { color: #c18e92; }
.company-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); gap: 18px; }
.cashier-tasks article { min-height: 92px; display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 16px; border-top: 1px solid var(--color-line); }
.cashier-tasks article:first-child { border-top: 0; }
.cashier-tasks p, .cashier-tasks small { color: var(--color-ink-muted); }
.coverage-list article { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-line); }
.coverage-list article:first-child { border-top: 0; }
.coverage-list article > span { display: flex; flex-direction: column; }
.coverage-list small { color: var(--color-ink-muted); }
.work-progress { display: grid; grid-template-columns: minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr) 40px minmax(130px, 1fr); align-items: center; margin-top: 18px; padding: 18px; }
.work-progress div { display: flex; align-items: center; gap: 10px; }
.work-progress div > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 11px; background: var(--color-positive); color: var(--color-primary-dark); font-weight: 800; }
.work-progress p { display: flex; flex-direction: column; }
.work-progress small { color: var(--color-ink-muted); }
.work-progress i { height: 1px; background: var(--color-line-strong); }
.work-progress .pending > span { background: rgba(255, 255, 255, 0.065); color: var(--color-ink-muted); }
.reconcile-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; overflow: hidden; }
.reconcile-summary div { min-height: 86px; display: flex; flex-direction: column; justify-content: center; padding: 14px 18px; border-left: 1px solid var(--color-line); }
.reconcile-summary div:first-child { border-left: 0; }
.reconcile-summary span, .reconcile-summary small { color: var(--color-ink-muted); }
.reconcile-summary strong { font: 600 19px var(--font-data); }
.review-list > article { padding: 16px; border-top: 1px solid var(--color-line); }
.review-list > article:first-child { border-top: 0; }
.review-main { display: grid; grid-template-columns: 38px 1fr; gap: 10px; }
.review-main p { color: var(--color-ink-muted); }
.candidate { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); }
.candidate-options { display: grid; gap: 8px; margin: 13px 0 0 48px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
.candidate-options legend { padding: 0 5px; color: var(--color-ink-muted); }
.candidate-options label { display: flex; align-items: center; gap: 9px; padding: 9px; border-radius: 11px; background: rgba(255, 255, 255, 0.035); }
.candidate-options input { accent-color: var(--color-primary); }
.candidate-options label span { display: flex; flex-direction: column; }
.candidate-options small { color: var(--color-ink-muted); }
.review-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 10px; }
.review-history { padding: 14px 16px; border-top: 1px solid var(--color-line); background: var(--color-positive-wash); }
.evidence-block { padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.16); }
.evidence-block strong, .evidence-block small { display: block; }
.evidence-block small { color: var(--color-ink-muted); }
.account-directory { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 16px; }
.account-directory article { overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); }
.account-directory header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid var(--color-line); }
.account-directory header div { display: flex; flex-direction: column; }
.account-directory header small { color: var(--color-ink-muted); }
.bank-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; }
.account-directory dl { display: grid; grid-template-columns: repeat(3, 1fr); }
.account-directory dl div { padding: 12px; border-left: 1px solid var(--color-line); }
.account-directory dl div:first-child { border-left: 0; }
.account-directory dt { color: var(--color-ink-muted); font-size: 10px; }
.account-directory dd { margin-top: 3px; font-size: 11px; }
.manual-layout { display: grid; grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); align-items: start; gap: 18px; }
.manual-records-table { min-width: 900px; }
/* Dialogs and feedback */
.dialog { width: min(580px, calc(100% - 28px)); padding: 0; overflow: hidden; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: rgba(17, 17, 17, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
.dialog::backdrop { background: rgba(0, 0, 0, 0.74); backdrop-filter: blur(6px); }
.dialog > form > header { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--color-line); }
.dialog header p { color: var(--color-ink-muted); }
.dialog-body { display: grid; gap: 15px; padding: 18px; }
.dialog > form > footer { display: flex; justify-content: flex-end; gap: 9px; padding: 13px 18px; border-top: 1px solid var(--color-line); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-callout { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid rgba(102, 168, 255, 0.22); border-radius: 12px; background: var(--color-info-wash); color: #9ac6ff; }
.dropzone { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 1px dashed rgba(255, 255, 255, 0.24); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.15); color: var(--color-ink-soft); text-align: center; transition: border-color var(--duration-fast), background var(--duration-fast); }
.dropzone:hover, .dropzone.is-dragging { border-color: var(--color-primary); background: var(--color-primary-wash); }
.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.dropzone > svg { width: 30px; height: 30px; color: var(--color-primary); }
.file-preview { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); }
.file-preview > span:nth-child(2) { display: flex; flex-direction: column; min-width: 0; }
.file-preview strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-preview small { color: var(--color-ink-muted); }
.file-type { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); }
.parse-result { display: grid; grid-template-columns: 38px 1fr; align-items: center; gap: 10px; padding: 11px; border: 1px solid rgba(55, 235, 137, 0.22); border-radius: var(--radius-md); background: var(--color-positive-wash); }
.parse-result p { color: #9bd7b6; }
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
.parse-result.is-exception p { color: #e7bd78; }
.sheet-review { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); }
.sheet-review h3 { font-size: 14px; }
.sheet-review-hint { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-list { display: grid; gap: 8px; margin-top: 10px; max-height: 220px; overflow-y: auto; }
.sheet-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.sheet-item.is-pending { border-color: rgba(255, 188, 82, 0.45); }
.sheet-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sheet-item-head strong { font-size: 13px; }
.sheet-item-head small { color: var(--color-ink-muted); }
.sheet-item-meta { margin-top: 5px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.sheet-item-actions { display: flex; align-items: center; gap: 6px; }
.sheet-item-actions .text-button { font-size: 12px; padding: 4px 8px; }
.sheet-item-reason { margin-top: 6px; padding: 6px 8px; border: 1px solid var(--color-line); border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 12px; }
.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 200; display: grid; gap: 8px; }
.toast { min-width: 280px; max-width: 390px; padding: 13px 15px; border: 1px solid var(--color-line-strong); border-radius: var(--radius-md); background: rgba(20, 20, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
.toast strong, .toast small { display: block; }
.toast small { color: var(--color-ink-muted); }
/* Login */
.entry-page { overflow-x: hidden; }
.entry-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); }
.entry-context { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--color-line); background: #080808; }
.entry-context::after { content: ""; position: absolute; inset: 12% 8% 12% auto; width: 1px; background: rgba(55, 235, 137, 0.32); box-shadow: 0 0 38px rgba(55, 235, 137, 0.45); }
.entry-brand { display: flex; align-items: center; gap: 12px; }
.entry-brand > span:last-child { display: flex; flex-direction: column; }
.entry-brand small { color: var(--color-ink-muted); }
.entry-statement { max-width: 680px; margin: 80px 0; }
.entry-statement h1 { max-width: 640px; font-size: clamp(38px, 5vw, 70px); line-height: 1.12; letter-spacing: 0; }
.entry-statement p { margin-top: 20px; color: var(--color-ink-muted); }
.entry-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 670px; }
.entry-facts div { padding: 15px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); }
.entry-facts dt { color: var(--color-ink-muted); }
.entry-facts dd { margin-top: 4px; color: var(--color-primary); }
.entry-form-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; background: var(--color-bg); }
.entry-form { width: min(420px, 100%); display: grid; gap: 18px; padding: 28px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: var(--color-surface); box-shadow: var(--shadow-panel); backdrop-filter: blur(24px); }
.entry-form header h2 { font-size: 25px; }
.entry-form header p, .entry-note { color: var(--color-ink-muted); }
.role-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.role-switch label { cursor: pointer; }
.role-switch input { position: absolute; opacity: 0; }
.role-switch span { min-height: 83px; display: grid; grid-template-columns: 30px 1fr; align-content: center; gap: 1px 9px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: rgba(0, 0, 0, 0.14); transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); }
.role-switch svg { grid-row: 1 / 3; align-self: center; color: var(--color-ink-muted); }
.role-switch small { color: var(--color-ink-muted); }
.role-switch input:checked + span { border-color: rgba(55, 235, 137, 0.4); background: var(--color-primary-wash); transform: translateY(-1px); }
.role-switch input:checked + span svg { color: var(--color-primary); }
.entry-note { font-size: 10px; }
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 88px minmax(0, 1fr); }
.sidebar { width: 70px; padding-inline: 9px; }
.workspace { grid-column: 2; }
.brand { justify-content: center; padding-inline: 0; }
.brand-copy, .nav-item span, .nav-item b, .user-block > span:last-child, .company-context div { display: none; }
.nav-item { justify-content: center; padding: 0; }
.user-block, .company-context { justify-content: center; padding-inline: 0; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.period-ribbon { grid-template-columns: repeat(3, 1fr); }
.period-ribbon > .period-warning { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
.admin-dashboard-grid, .company-dashboard-grid { grid-template-columns: 1fr; }
.flow-filters { grid-template-columns: repeat(3, 1fr); }
.flow-filters .button { min-height: 40px; }
.manual-layout { grid-template-columns: 1fr; }
.account-directory { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
main { padding-inline: 20px; }
.search-box { display: none; }
.settings-layout, .reminder-layout { grid-template-columns: 1fr; }
.pair-query-form { grid-template-columns: 1fr 40px 1fr; }
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
.pair-query-form .button { grid-column: 3; }
.ledger-head { display: none; }
.company-ledger summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
.company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
.ledger-breakdown { grid-template-columns: 1fr; }
.ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; }
.pair-balance-line { grid-template-columns: repeat(3, 1fr); }
.pair-balance-line .pair-final { grid-column: 1 / -1; border-top: 1px solid var(--color-line); border-left: 0; }
.subject-strip { grid-template-columns: repeat(3, 1fr); }
.work-progress { grid-template-columns: 1fr 24px 1fr; row-gap: 16px; }
.work-progress i:nth-of-type(2) { display: none; }
.entry-shell { grid-template-columns: 1fr; }
.entry-context { min-height: 58vh; border-right: 0; border-bottom: 1px solid var(--color-line); }
.entry-form-wrap { min-height: auto; }
}
@media (max-width: 720px) {
.app-shell { display: block; }
.sidebar { inset: 10px auto 10px 10px; width: 242px; transform: translateX(calc(-100% - 20px)); transition: transform var(--duration-standard) var(--ease-out); }
.sidebar.is-open { transform: none; }
.sidebar.is-open .brand-copy, .sidebar.is-open .nav-item span, .sidebar.is-open .nav-item b, .sidebar.is-open .user-block > span:last-child, .sidebar.is-open .company-context div { display: flex; }
.sidebar.is-open .nav-item { justify-content: flex-start; padding: 0 12px; }
.sidebar.is-open .company-context { justify-content: flex-start; padding-inline: 10px; }
.workspace { display: block; }
.topbar { height: 64px; padding: 0 13px; gap: 9px; }
.menu-button { display: inline-grid; }
.workspace-name { min-width: 0; }
.workspace-name span { display: none; }
.demo-badge { padding: 2px 6px; }
.period-button { display: none; }
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
main { padding: 12px 13px 42px; }
.page-heading { min-height: auto; flex-direction: column; margin-bottom: 18px; }
.page-heading h1 { font-size: 27px; }
.page-heading > .button { align-self: stretch; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { min-height: 128px; grid-template-columns: minmax(0, 1fr) 38px; padding: 15px; }
.metric-icon { width: 38px; height: 38px; border-radius: 12px; }
.metric-value { font-size: 25px; }
.period-ribbon { grid-template-columns: 1fr 1fr; }
.period-ribbon > div { min-height: 64px; }
.period-ribbon > div:nth-child(3) { border-top: 1px solid var(--color-line); border-left: 0; }
.period-warning { grid-column: 1 / -1; }
.panel-heading { min-height: 60px; padding: 11px 13px; }
.inline-search { display: none; }
.task-list button { grid-template-columns: 30px minmax(0, 1fr) 18px; padding-inline: 12px; }
.task-list button > b { display: none; }
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
.quick-result { margin-inline: 12px; }
.company-ledger summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
.company-ledger summary > strong:nth-of-type(2), .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
.company-name { grid-template-columns: 30px minmax(0, 1fr); }
.company-name i { width: 28px; height: 28px; }
.filter-bar { align-items: stretch; flex-direction: column; }
.segmented { width: 100%; }
.flow-filters { grid-template-columns: 1fr 1fr; }
.flow-filters .grow, .flow-filters .button { grid-column: 1 / -1; }
.pair-query-form { grid-template-columns: 1fr 38px 1fr; }
.pair-query-form .field:nth-of-type(3), .pair-query-form .button { grid-column: 1 / -1; }
.pair-report-heading { align-items: flex-start; flex-direction: column; }
.pair-balance-line { grid-template-columns: 1fr 1fr; }
.pair-balance-line .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
.subject-strip { grid-template-columns: 1fr 1fr; }
.company-alert { grid-template-columns: 28px 1fr; }
.company-alert .button { grid-column: 1 / -1; }
.cashier-tasks article { grid-template-columns: 30px minmax(0, 1fr); }
.cashier-tasks .button { grid-column: 2; justify-self: start; }
.work-progress { grid-template-columns: 1fr; }
.work-progress i { display: none; }
.reconcile-summary { grid-template-columns: 1fr; }
.reconcile-summary div { min-height: 70px; border-top: 1px solid var(--color-line); border-left: 0; }
.reconcile-summary div:first-child { border-top: 0; }
.candidate, .candidate-options { align-items: stretch; flex-direction: column; margin-left: 0; }
.account-directory { grid-template-columns: 1fr; }
.account-directory dl { grid-template-columns: 1fr 1fr; }
.closing-checks { grid-template-columns: 1fr; }
.closing-checks article { border-top: 1px solid var(--color-line); border-left: 0; }
.closing-footer { align-items: stretch; flex-direction: column; }
.notification-list article { grid-template-columns: 34px minmax(0, 1fr); }
.notification-list article > em { grid-column: 2; justify-self: start; }
.table-summary { align-items: flex-start; flex-direction: column; justify-content: center; padding-block: 9px; }
.dialog { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; }
.dialog > form { min-height: 100%; display: flex; flex-direction: column; }
.dialog-body { flex: 1; }
.form-grid { grid-template-columns: 1fr; }
.toast-region { right: 12px; bottom: 12px; left: 12px; }
.toast { min-width: 0; max-width: none; }
.entry-context { min-height: auto; padding: 30px 22px; }
.entry-statement { margin: 54px 0 42px; }
.entry-statement h1 { font-size: 39px; }
.entry-facts { grid-template-columns: 1fr; }
.entry-form-wrap { padding: 32px 16px 48px; }
.entry-form { padding: 22px; }
}
@media (max-width: 460px) {
.metric-card { min-height: 150px; padding: 13px; }
.metric-copy { min-height: 52px; }
.metric-label { font-size: 11px; }
.metric-value { font-size: 23px; }
.role-switch { grid-template-columns: 1fr; }
}
@media (max-width: 1180px) {
.timeline-strip { grid-template-columns: 1fr; }
.timeline-legend { padding-bottom: 4px; }
}
@media (max-width: 900px) {
.company-row { grid-template-columns: 34px minmax(0, 1fr) auto; }
.company-row-figure { display: none; }
}
@media (max-width: 720px) {
.timeline-strip { padding: 14px; }
.timeline-rows { gap: 6px; }
.timeline-row { grid-template-columns: 80px minmax(0, 1fr); }
.timeline-axis span { font-size: 9px; }
}
/* ===== 参考图对齐:胶囊搜索条 / 账期时间轴 / 右侧详情面板 ===== */
.content-toolbar > .search-box { border-radius: 999px; background: rgba(255, 255, 255, 0.045); backdrop-filter: blur(18px) saturate(120%); }
.timeline-panel { margin-top: 18px; padding: 18px; }
.timeline { padding: 10px 4px 0; }
.timeline-bar { position: relative; height: 14px; border: 1px solid var(--color-line); border-radius: 999px; background: rgba(255, 255, 255, 0.05); }
.timeline-seg, .timeline-gap { position: absolute; top: 2px; bottom: 2px; border-radius: 999px; }
.timeline-seg.is-closed { background: linear-gradient(90deg, rgba(55, 235, 137, 0.5), rgba(55, 235, 137, 0.28)); }
.timeline-seg.is-current { background: linear-gradient(90deg, rgba(255, 188, 82, 0.4), rgba(255, 188, 82, 0.18)); }
.timeline-gap { background: repeating-linear-gradient(45deg, rgba(255, 98, 109, 0.75) 0 6px, rgba(255, 98, 109, 0.35) 6px 12px); box-shadow: 0 0 12px rgba(255, 98, 109, 0.25); }
.timeline-today { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--color-primary-strong); box-shadow: 0 0 10px rgba(55, 235, 137, 0.6); }
.timeline-today::after { content: "今天"; position: absolute; top: -19px; right: 5px; font-size: 10px; font-style: normal; color: var(--color-primary-strong); white-space: nowrap; }
.timeline-ticks { position: relative; height: 34px; margin-top: 8px; }
.timeline-ticks span { position: absolute; display: flex; flex-direction: column; transform: translateX(-50%); font-size: 10px; color: var(--color-ink-muted); text-align: center; }
.timeline-ticks span:first-child { transform: none; text-align: left; }
.timeline-ticks span:last-child { transform: translateX(-100%); text-align: right; }
.timeline-ticks small { color: var(--color-ink-soft); }
.timeline-panel .timeline-legend { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px 18px; margin: 10px 0 0; padding: 0; list-style: none; font-size: 11px; color: var(--color-ink-soft); }
.timeline-panel .timeline-legend li { display: flex; align-items: center; gap: 6px; }
.timeline-panel .dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.success { background: var(--color-positive); }
.dot.warning { background: var(--color-warning); }
.dot.danger { background: var(--color-danger); }
.dot.today { background: var(--color-primary-strong); box-shadow: 0 0 6px rgba(55, 235, 137, 0.7); }
.detail-drawer { position: fixed; top: 16px; right: 16px; bottom: 16px; z-index: 80; width: min(380px, calc(100vw - 32px)); display: flex; flex-direction: column; border: 1px solid var(--color-line-strong); border-radius: var(--radius-lg); background: linear-gradient(160deg, rgba(28, 28, 28, 0.92), rgba(12, 12, 12, 0.94)); box-shadow: var(--shadow-panel); backdrop-filter: blur(26px) saturate(125%); transform: translateX(calc(100% + 32px)); visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), visibility var(--duration-standard); }
.detail-drawer.is-open { transform: none; visibility: visible; }
.detail-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid var(--color-line); }
.detail-drawer h2 { margin-top: 8px; font-size: 17px; }
.detail-drawer .detail-desc { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields { display: grid; gap: 10px; margin: 0; padding: 14px 18px; overflow-y: auto; }
.detail-fields > div { display: flex; justify-content: space-between; gap: 14px; padding-bottom: 10px; border-bottom: 1px dashed var(--color-line); }
.detail-fields dt { flex-shrink: 0; color: var(--color-ink-muted); font-size: 12px; }
.detail-fields dd { margin: 0; text-align: right; font-size: 12px; }
.detail-tip { margin: 0 18px 12px; padding: 12px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-ink-soft); font-size: 12px; }
.detail-tip strong { display: block; margin-bottom: 4px; color: var(--color-ink); }
.detail-drawer > footer { display: flex; gap: 10px; margin-top: auto; padding: 14px 18px; border-top: 1px solid var(--color-line); }
.detail-drawer > footer .button { flex: 1; justify-content: center; }
.detail-drawer .status { align-self: flex-start; }
[data-detail] { cursor: pointer; }
@media (max-width: 720px) {
.detail-drawer { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); }
.timeline-panel { padding: 14px; }
}
/* ===== 质感打磨:玻璃光泽 / 氛围光 / 对比与呼吸感 ===== */
:root {
--radius-lg: 24px;
--radius-xl: 30px;
}
body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(52vw 42vh at 62% 32%, rgba(55, 235, 137, 0.055), transparent 62%), radial-gradient(40vw 34vh at 90% 4%, rgba(102, 168, 255, 0.032), transparent 60%), radial-gradient(46vw 40vh at 28% 96%, rgba(55, 235, 137, 0.028), transparent 60%); }
.app-shell { position: relative; z-index: 0; }
.metric-card { border-color: rgba(255, 255, 255, 0.12); background: radial-gradient(120% 90% at 50% 108%, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02) 42%, transparent 60%), linear-gradient(160deg, rgba(41, 41, 41, 0.86) 0%, rgba(23, 23, 23, 0.88) 50%, rgba(12, 12, 12, 0.9) 100%); }
.metric-card::before { background: radial-gradient(130% 100% at 50% 112%, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 45%, transparent 62%), linear-gradient(165deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.05) 42%, transparent 72%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.05), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 40%, transparent 70%); }
.nav-item.is-active { border-color: rgba(255, 255, 255, 0.16); background: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06)); box-shadow: inset 0 1px rgba(255, 255, 255, 0.14), 0 10px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(55, 235, 137, 0.08); }
.metric-value { font-size: clamp(28px, 2.6vw, 38px); font-weight: 800; color: #ffffff; white-space: nowrap; }
.metric-grid { gap: 20px; margin-bottom: 20px; }
.admin-dashboard-grid { gap: 20px; margin-bottom: 20px; }
.company-dashboard-grid { gap: 20px; }
.page-heading { margin-bottom: 22px; }
@media (max-width: 720px) {
.metric-grid { gap: 10px; margin-bottom: 14px; }
.page-heading { margin-bottom: 18px; }
}
/* ===== 反馈修正:侧栏质感 + 模块间距统一 ===== */
.sidebar { border-color: rgba(255, 255, 255, 0.13); background: linear-gradient(170deg, rgba(26, 26, 26, 0.92), rgba(10, 10, 10, 0.94)); box-shadow: var(--shadow-panel), 0 0 44px rgba(55, 235, 137, 0.05); }
.sidebar::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(170deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 45%, transparent 70%); pointer-events: none; }
.nav-list { gap: 10px; }
.nav-item { min-height: 48px; border-radius: 16px; }
.user-block { border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-surface-muted); }
.company-alert { margin-bottom: 20px; }
.period-ribbon { margin-bottom: 20px; }
.company-stack-panel { margin-bottom: 20px; }
.work-progress { margin-top: 20px; }
.timeline-panel { margin-top: 20px; }
/* ===== 反馈修正:整体提亮 + 列表改为玻璃行(去掉线条隔断) ===== */
:root {
--color-ink-soft: #b6c0ba;
--color-ink-muted: #99a39d;
}
.panel, .period-ribbon, .query-band, .filter-bar, .filter-grid, .pair-report, .work-progress, .reconcile-summary { border-color: rgba(255, 255, 255, 0.12); background: linear-gradient(160deg, rgba(37, 37, 37, 0.84) 0%, rgba(21, 21, 21, 0.87) 50%, rgba(12, 12, 12, 0.89) 100%); }
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.06), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.13) 0%, rgba(255, 255, 255, 0.04) 40%, transparent 70%); }
.task-list, .cashier-tasks, .coverage-list, .notification-list, .review-list { display: grid; gap: 8px; padding: 12px 14px 14px; }
.task-list button, .cashier-tasks article, .coverage-list article, .notification-list article, .review-list > article, .subject-row { border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); transition: background var(--duration-fast), border-color var(--duration-fast), box-shadow var(--duration-fast), transform var(--duration-fast); }
.task-list button:hover, .cashier-tasks article:hover, .coverage-list article:hover, .notification-list article:hover, .review-list > article:hover, .subject-row:hover { border-color: rgba(255, 255, 255, 0.2); background: linear-gradient(160deg, rgba(44, 44, 44, 0.82) 0%, rgba(22, 22, 22, 0.86) 100%); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); }
.notification-list article.is-unread { border-color: rgba(102, 168, 255, 0.3); background: linear-gradient(160deg, rgba(102, 168, 255, 0.1), rgba(102, 168, 255, 0.04)); }
.company-ledger { margin: 8px 14px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); overflow: hidden; }
.company-ledger[open] { border-color: rgba(255, 255, 255, 0.18); }
.company-ledger:last-child { margin-bottom: 14px; }
.data-table { border-collapse: separate; border-spacing: 0 8px; }
.data-table th { height: 28px; padding: 0 13px; border-bottom: 0; background: transparent; }
.data-table td { border-bottom: 0; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); background: linear-gradient(160deg, rgba(32, 32, 32, 0.72) 0%, rgba(15, 15, 15, 0.78) 100%); }
.data-table td:first-child { border-left: 1px solid var(--color-line); border-radius: 12px 0 0 12px; }
.data-table td:last-child { border-right: 1px solid var(--color-line); border-radius: 0 12px 12px 0; }
.data-table tbody tr:hover td { background: linear-gradient(160deg, rgba(44, 44, 44, 0.85) 0%, rgba(22, 22, 22, 0.88) 100%); }
.data-table tbody tr:last-child td { border-bottom: 1px solid var(--color-line); }
.table-scroll { padding: 2px 14px 8px; }
.table-summary { margin: 0 14px; }
/* ===== 登录页重做:居中集团登录卡 ===== */
.entry-shell { display: grid; grid-template-columns: 1fr; place-items: center; min-height: 100vh; padding: 48px 20px; }
.entry-card { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: var(--radius-xl); background: linear-gradient(165deg, rgba(36, 36, 36, 0.9) 0%, rgba(16, 16, 16, 0.92) 60%, rgba(10, 10, 10, 0.94) 100%); box-shadow: var(--shadow-panel), 0 0 60px rgba(55, 235, 137, 0.06); backdrop-filter: blur(28px) saturate(125%); }
.entry-card::before { content: ""; position: absolute; inset: 0; background: radial-gradient(150% 110% at 50% 118%, rgba(255, 255, 255, 0.07), transparent 55%), linear-gradient(165deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 42%, transparent 70%); pointer-events: none; }
.entry-card::after { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 2px; background: linear-gradient(90deg, transparent, rgba(55, 235, 137, 0.65), transparent); }
.entry-brand { flex-direction: column; justify-content: center; gap: 15px; min-height: 0; padding: 36px 28px 24px; border-bottom: 1px solid var(--color-line); text-align: center; }
.entry-brand .brand-mark { width: 54px; height: 54px; border-radius: 17px; font-size: 26px; }
.entry-brand > span:last-child { align-items: center; }
.entry-brand strong { font-size: 21px; letter-spacing: 0.03em; }
.entry-brand small { margin-top: 4px; font-size: 12px; letter-spacing: 0.08em; }
.entry-form { width: 100%; gap: 16px; padding: 24px 28px 10px; border: 0; background: transparent; box-shadow: none; backdrop-filter: none; }
.entry-form header { text-align: center; }
.entry-form header h2 { font-size: 22px; }
.entry-form header p { margin-top: 4px; color: var(--color-ink-muted); font-size: 12px; }
.entry-facts { max-width: none; gap: 10px; padding: 16px 28px 28px; }
.entry-facts div { padding: 12px; text-align: center; }
.entry-facts dd { margin-top: 2px; font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}