Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f1a93f6a6 | ||
|
|
545837446c | ||
|
|
51c301d9df | ||
|
|
f7aa4a8d06 | ||
|
|
763a940683 | ||
|
|
c292fb791d |
@@ -1,13 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.impeccable
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.xls
|
||||
*.xlsx
|
||||
screenshots
|
||||
tests
|
||||
docs
|
||||
design-system
|
||||
流水模板
|
||||
@@ -1 +0,0 @@
|
||||
APP_PORT=4173
|
||||
+11
@@ -2,3 +2,14 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.tmp-*/
|
||||
|
||||
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
||||
data/
|
||||
uploads/
|
||||
exports/
|
||||
*.local
|
||||
server.pid
|
||||
server.out.log
|
||||
server.err.log
|
||||
nul
|
||||
|
||||
|
||||
+6
-6
@@ -26,12 +26,12 @@ Body type is `13px/1.55`. Page headlines use `clamp(26px, 3vw, 35px)` with `1.15
|
||||
|
||||
| Token | Value | Use |
|
||||
| --- | --- | --- |
|
||||
| `--color-bg` | `#050706` | Continuous application canvas |
|
||||
| `--color-bg-soft` | `#090d0b` | Soft near-black tonal alternative |
|
||||
| `--color-nav` | `#090c0b` | Navigation frame fallback |
|
||||
| `--color-surface` | `rgba(20, 26, 23, 0.82)` | Primary graphite glass |
|
||||
| `--color-surface-solid` | `#131916` | Opaque graphite fallback |
|
||||
| `--color-surface-raised` | `#18201c` | Raised or denser surface |
|
||||
| `--color-bg` | `#050505` | Continuous application canvas |
|
||||
| `--color-bg-soft` | `#0a0a0a` | Soft near-black tonal alternative |
|
||||
| `--color-nav` | `#0a0a0a` | Navigation frame fallback |
|
||||
| `--color-surface` | `rgba(22, 22, 22, 0.82)` | Primary neutral graphite glass |
|
||||
| `--color-surface-solid` | `#161616` | Opaque graphite fallback |
|
||||
| `--color-surface-raised` | `#1c1c1c` | Raised or denser surface |
|
||||
| `--color-surface-muted` | `rgba(255, 255, 255, 0.035)` | Quiet inset field and row treatment |
|
||||
|
||||
### Structure and Ink
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app/src \
|
||||
APP_HOST=0.0.0.0 \
|
||||
APP_PORT=4173
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN python -m pip install --no-cache-dir --requirement requirements.txt
|
||||
|
||||
COPY server.py ./
|
||||
COPY src ./src
|
||||
COPY web ./web
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 4173
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 \
|
||||
CMD ["python", "-c", "import os, urllib.request; port = os.getenv('APP_PORT', '4173'); urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=2)"]
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
@@ -19,36 +19,44 @@ The original Excel file is never modified.
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
$env:PYTHONPATH = "src"
|
||||
python -m bank_importer.db # 创建/迁移 SQLite 数据库(默认 data/app.db)
|
||||
python -m bank_importer.cli "流水模板"
|
||||
python -m unittest discover -s tests -v
|
||||
python server.py
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
数据库与原始文件默认保存在 `data/`(已加入 `.gitignore`)。上传文件按
|
||||
SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并复用已有批次。
|
||||
同一公司重复上传返回原批次摘要;不同公司相同内容只返回通用重复状态,
|
||||
不暴露其他公司的批次标识或摘要。
|
||||
技术决策见 `docs/decisions/002-persistence.md`。
|
||||
|
||||
Windows PowerShell 一键启动:
|
||||
## 登录与账号
|
||||
|
||||
```powershell
|
||||
.\start-docker.ps1
|
||||
```
|
||||
系统已启用真实身份认证(决策见 `docs/decisions/003-auth.md`):
|
||||
|
||||
Linux 或 macOS 一键启动:
|
||||
- 首次启动若无管理员,自动创建引导管理员:用户名取环境变量
|
||||
`APP_ADMIN_USERNAME`(默认 `group-admin`),密码取
|
||||
`APP_BOOTSTRAP_ADMIN_PASSWORD`;未设置该变量则生成随机初始密码并
|
||||
只在控制台打印一次。
|
||||
- 引导管理员及管理员创建的公司账号首次登录都必须修改密码。
|
||||
- 公司、公司账号由管理员在总账端「公司与账号 → 新增公司」或
|
||||
`/api/admin/companies`、`/api/admin/users` 动态创建;
|
||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||
|
||||
```sh
|
||||
./start-docker.sh
|
||||
```
|
||||
|
||||
默认访问 `http://localhost:4173/`。如需更换宿主机端口,可运行
|
||||
`.\start-docker.ps1 -Port 8080`,或运行 `APP_PORT=8080 ./start-docker.sh`。
|
||||
停止服务使用 `docker compose down`,查看日志使用 `docker compose logs -f app`。
|
||||
|
||||
访问地址:
|
||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||
|
||||
- 登录入口:`http://127.0.0.1:4173/`
|
||||
- 总账管理端:`http://127.0.0.1:4173/admin.html`
|
||||
- 公司业务端:`http://127.0.0.1:4173/company.html`
|
||||
|
||||
公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器。当前前端是交互原型,公司、账号、期初、提醒和导入确认仅保存在当前浏览器页面中,尚未接入数据库和正式身份认证。
|
||||
公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器;解析结果、原始文件、批次和源行会持久化到 SQLite,并按登录账号绑定的公司隔离。当前前端仍是交互原型,期初、提醒、往来匹配等业务状态仅保存在当前浏览器页面中,尚未接入数据库。
|
||||
|
||||
## 样本数据政策
|
||||
|
||||
`流水模板/` 中的六份银行样本(中信、农行、工行、建行、河南农商、郑州银行)已经项目所有者(白栖知,2026-08-08)书面确认:**基于真实银行流水脱敏后的测试数据**,可以保留在仓库中作为解析器 fixture。新增银行样本的审核要求:必须先脱敏(户名、账号、对方信息等),由项目所有者确认后方可提交;真实生产流水、真实上传文件和导出文件一律不进仓库(见 `.gitignore` 中的 `data/`、`uploads/`、`exports/` 规则)。
|
||||
|
||||
The CLI prints batch summaries only. It does not print sensitive transaction
|
||||
details.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
image: caiwuzongzhang:local
|
||||
ports:
|
||||
- "${APP_PORT:-4173}:4173"
|
||||
environment:
|
||||
APP_HOST: 0.0.0.0
|
||||
APP_PORT: 4173
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
+1
-1
@@ -160,7 +160,7 @@ bank_importer 解析内核
|
||||
### 测试与运行
|
||||
|
||||
1. 现有 6 项测试只覆盖解析内核,没有 `/api/parse`、认证、导入持久化、匹配、核算、权限或端到端测试。
|
||||
2. `server.py` 基于标准库开发服务器,只绑定 `127.0.0.1`,没有生产部署、TLS、日志、备份或监控方案。
|
||||
2. `server.py` 基于标准库开发服务器,默认绑定 `0.0.0.0`(可用 `APP_HOST` 覆盖),没有生产部署、TLS、日志、备份或监控方案。
|
||||
3. `流水模板/` 已进入 Git 历史;必须确认均为合成或已脱敏数据,真实生产流水不得继续提交到仓库。
|
||||
|
||||
## 6. 已作出的重要技术决策及原因
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 002 持久化层技术决策
|
||||
|
||||
对应 Issue:B-39(`docs/issues/002-p0-persistence-and-immutable-imports.md`)。
|
||||
|
||||
## 数据库:SQLite(Python 标准库 `sqlite3`)
|
||||
|
||||
- 单文件、事务完整、零新增依赖,与当前标准库服务器和离线内网部署环境匹配。
|
||||
- 外键约束、CHECK 约束、唯一约束和触发器均可用,足以承载不可变证据模型。
|
||||
- 金额以 `TEXT` 保存 `Decimal` 的原始字符串,读取时还原为 `Decimal`,
|
||||
绝不经过二进制浮点。
|
||||
- 后续若并发写入成为瓶颈,可开启 WAL 或平迁 PostgreSQL;迁移版本表
|
||||
`schema_migrations` 不绑定具体引擎方言之外的特性。
|
||||
|
||||
## 迁移工具:仓库内置版本化迁移器(`src/bank_importer/db.py`)
|
||||
|
||||
- 每条迁移包含 `up` / `down` 两段 SQL,按版本号顺序执行并记录在
|
||||
`schema_migrations` 表中;重复执行无副作用。
|
||||
- 不引入 Alembic 等外部工具:当前模型规模小,内置迁移器保持零依赖,
|
||||
且回滚路径明确(`python -m bank_importer.db <db路径> --rollback-to <版本>`)。
|
||||
- 服务启动时自动执行 `migrate`,空数据库即可完整建表。
|
||||
|
||||
## 文件存储:内容寻址的本地文件系统(`data/files/`)
|
||||
|
||||
- 上传文件先计算 SHA-256,再写入 `data/files/<哈希前两位>/<完整哈希>.<扩展名>`。
|
||||
- 发布采用临时文件 + 硬链接:目标要么完整出现、要么不存在,且永不覆盖。
|
||||
- 相同内容(即使文件名不同)只保存一份文件、一条 `source_files` 记录。
|
||||
- `data/` 已加入 `.gitignore`,真实流水不进入版本库。
|
||||
|
||||
## 幂等与状态机
|
||||
|
||||
- 文件级幂等:`source_files.sha256` 唯一约束。重复上传插入一条
|
||||
`status='duplicate'` 的批次审计记录,指向首个批次,不产生第二份事实。
|
||||
- 行级幂等:`source_rows` 上 `UNIQUE (sheet_batch_id, source_row)`。
|
||||
- 批次状态:`parsing → parsed | exception | failed`,另有 `duplicate`。
|
||||
失败/异常批次保留 `import_exceptions` 诊断(阶段、消息、原始文件名),
|
||||
不产生任何已确认源行。
|
||||
- 事务边界:文件落盘后,`source_files` + 批次 + 工作表批次 + 源行在单个
|
||||
SQLite 事务内提交;解析失败时批次状态与异常记录同样在事务内落库。
|
||||
|
||||
## 不可变性
|
||||
|
||||
- `source_files`、`sheet_batches`、`source_rows` 三张表由数据库触发器禁止
|
||||
`UPDATE` 和 `DELETE`,任何修改只能走后续阶段的冲销/审计调整流程。
|
||||
- 该约束由 `tests/test_persistence.py` 自动化验证。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 003 身份认证与租户隔离技术决策
|
||||
|
||||
对应 Issue:B-40(`docs/issues/003-p0-auth-and-tenant-isolation.md`)。
|
||||
|
||||
## 密码散列:标准库 PBKDF2-HMAC-SHA256
|
||||
|
||||
- 只使用 Python 标准库(`hashlib.pbkdf2_hmac` + `hmac.compare_digest` +
|
||||
`secrets`),不引入 bcrypt/argon2 等第三方依赖,与离线内网部署约束一致。
|
||||
- 260 000 次迭代、16 字节随机盐,存储格式
|
||||
`pbkdf2_sha256$<迭代数>$<盐hex>$<摘要hex>`,自描述、可平滑升级参数。
|
||||
- 明文密码永不入库、永不进日志、永不写入 `audit_log`;
|
||||
`password_hash` 字段从不出现在任何 API 响应中(有测试断言)。
|
||||
|
||||
## 会话:数据库保存令牌摘要,8 小时绝对过期
|
||||
|
||||
- 令牌为 `secrets.token_urlsafe(32)`,数据库只存其 SHA-256 摘要;
|
||||
数据库泄露不直接暴露可用令牌。
|
||||
- 绝对过期 8 小时(一个工作班次),不做滑动续期,语义简单可测。
|
||||
- 会话可吊销:退出登录、停用账号、重置密码都会立即吊销该用户全部会话。
|
||||
- Cookie 名 `cw_session`,`HttpOnly; SameSite=Lax; Path=/`。
|
||||
当前是纯 HTTP 的局域网部署,**刻意不加 `Secure`**(加了浏览器会直接拒发);
|
||||
若未来上 HTTPS,应补上 `Secure` 并配置反向代理。
|
||||
|
||||
## 登录限流:同一 (账号, IP) 10 分钟内失败 5 次即锁定
|
||||
|
||||
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
||||
且在窗口内不再记录新尝试,行为确定、可测试。
|
||||
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
||||
|
||||
## 角色与公司绑定
|
||||
|
||||
- `users.role` 仅 `admin` / `company`;数据库 CHECK 约束强制
|
||||
公司账号必须绑定公司、管理员不得绑定公司。
|
||||
- 登录时前端选择工作端口(portal),服务端校验 portal 与角色一致,
|
||||
不匹配返回 403「账号与该工作端口不匹配」。
|
||||
- 管理员创建公司账号时,初始密码为随机一次性密码并置
|
||||
`must_change_password=1`;首次登录必须改密,改密前所有业务 API 返回 403。
|
||||
初始密码只在创建成功响应中显示一次,永不入库明文、永不写日志;
|
||||
创建用户的审计 detail 只记录公司,不重复账号名。
|
||||
- 「重置密码」同样生成随机一次性密码并吊销既有会话。
|
||||
|
||||
## 租户隔离在服务端强制,404 优于 403
|
||||
|
||||
- 公司用户的批次列表、行明细、CSV 导出、上传归属全部由服务端按
|
||||
会话中的 `company_id` 过滤;请求体/参数里的 `company_id` 对公司用户
|
||||
一律忽略(跨公司上传防护)。
|
||||
- 访问他公司批次返回 404 而非 403:不暴露「该批次存在但属于别人」这一事实,
|
||||
避免 IDOR 探测。导出时显式指定他公司 `company_id` 仍返回 403,
|
||||
因为用户已声明知道该公司存在,此时给出明确拒绝更有操作性。
|
||||
- `GET /admin.html`、`/company.html` 的 302 跳转只是 UX 层引导,
|
||||
不是权限边界;真正的边界全部在 API 上。
|
||||
|
||||
## 引导管理员(bootstrap)
|
||||
|
||||
- 服务启动迁移后若无任何 admin 账号,创建 `APP_ADMIN_USERNAME`
|
||||
(默认 `group-admin`);密码取 `APP_BOOTSTRAP_ADMIN_PASSWORD`,
|
||||
未设置则生成随机初始密码并**只打印一次到 stdout**(不写日志文件),
|
||||
`must_change_password=1`。
|
||||
- 不在迁移或代码中预置任何公司或用户;公司与公司账号全部由管理员
|
||||
通过 `/api/admin/companies`、`/api/admin/users` 动态创建。
|
||||
|
||||
## 审计
|
||||
|
||||
- `audit_log` 记录登录成功/失败、退出、改密、上传、导出(含公司范围与行数)、
|
||||
建公司、建/停/启用户、重置密码等动作,含操作者、目标、IP、时间。
|
||||
- 初始密码、新旧密码均不进入审计内容。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 004 动态主数据技术决策
|
||||
|
||||
对应 Issue:B-41(`docs/issues/004-p1-dynamic-master-data.md`)。
|
||||
|
||||
## 账户状态机与生效区间
|
||||
|
||||
- `bank_accounts.status`:`pending → active → disabled`,审核退回为
|
||||
`returned`;退回后同公司重新提交复用同一行并回到 `pending`,不产生
|
||||
第二条记录。停用这个动作只对 `active` 开放,退回只对 `pending` 开放,
|
||||
越界操作返回 409。
|
||||
- 生效区间 `effective_from` / `effective_to`(含当天):公司提交时填写
|
||||
期望启用日,管理员审核通过时确认;停用时写入 `effective_to`(默认当天)。
|
||||
- 两个判定函数分开:
|
||||
- `is_usable`(上传选择、当期操作):仅 `active` 且在生效区间内。
|
||||
- `is_identifiable`(历史流水所有权识别):`active` 或 `disabled` 且在
|
||||
生效区间内——停用只终止未来参与,不改写历史归属。
|
||||
|
||||
## 账号规范化与唯一性
|
||||
|
||||
- 账号入库前统一规范化:去空格/短横线、全角数字转半角,只存 6–32 位
|
||||
数字的规范形式;唯一性由数据库 `UNIQUE(account_number)` 保证,并发
|
||||
提交必然只有一个成功(有并发测试)。
|
||||
- 公司端任何响应只含脱敏形式 `****尾四位`;完整账号只在总账管理端
|
||||
(授权审计视图)返回。审计日志 detail 也只写脱敏账号。
|
||||
|
||||
## 别名匹配优先级
|
||||
|
||||
- `account_aliases` 支持户名(`name`)与账号(`account`)两类别名,
|
||||
各自带生效区间和 `priority` 列。
|
||||
- 匹配优先级确定:精确账号(0)< 账号别名(1000+priority)<
|
||||
户名别名(2000+priority);同层内按 `priority` 再按账户 id 排序。
|
||||
别名只在自身生效区间且账户 `is_identifiable` 时参与匹配。
|
||||
|
||||
## 审计轨迹
|
||||
|
||||
- 所有主数据变更写入 `master_data_changes`:实体、动作、前值 JSON、
|
||||
后值 JSON、原因、操作人、时间。公司创建、账户提交/退回重提/
|
||||
审核/停用、别名创建全部覆盖,管理员可按实体查询。
|
||||
|
||||
## 前端边界
|
||||
|
||||
- 公司下拉(往来查询、审核筛选、流水筛选、提醒、期初)和公司表格全部
|
||||
由 `/api/admin/companies` 渲染,新增公司无需改代码即可被检索。
|
||||
- 公司端账户目录、上传账户选择、手工记录资金来源只来自
|
||||
`/api/company/accounts`,且只有 `usable` 账户进入上传选择。
|
||||
- 手工记录仍是浏览器 localStorage 演示数据(属 Issue 007/012 范围),
|
||||
账户登记已完全切换到服务端。
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Authentication, sessions, rate limiting and audit logging.
|
||||
|
||||
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib ``hashlib.pbkdf2_hmac``)
|
||||
and per-user random salts; plaintext passwords are never stored or logged.
|
||||
Session tokens are random URL-safe strings; only their SHA-256 digest is
|
||||
persisted, so a database leak does not expose usable tokens. Every login
|
||||
attempt and every privileged action lands in ``audit_log``. The reasoning
|
||||
behind these choices is recorded in ``docs/decisions/003-auth.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
|
||||
from .db import utc_now
|
||||
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
PBKDF2_ITERATIONS = 260_000
|
||||
SESSION_TTL_HOURS = 8
|
||||
RATE_LIMIT_MAX_FAILURES = 5
|
||||
RATE_LIMIT_WINDOW_MINUTES = 10
|
||||
INITIAL_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS
|
||||
)
|
||||
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
"""Constant-time check of ``password`` against a stored hash string."""
|
||||
try:
|
||||
scheme, iterations, salt_hex, hash_hex = stored.split("$")
|
||||
if scheme != "pbkdf2_sha256":
|
||||
return False
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(hash_hex)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, int(iterations)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(digest, expected)
|
||||
|
||||
|
||||
def generate_initial_password(exclude: str | None = None) -> str:
|
||||
"""Generate a 12-char initial password with upper, lower and digit chars.
|
||||
|
||||
When ``exclude`` is given, the result is guaranteed to differ from it
|
||||
(case-insensitive) so a fresh account never starts with a password equal
|
||||
to its own username.
|
||||
"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
while True:
|
||||
password = "".join(
|
||||
secrets.choice(alphabet) for _ in range(INITIAL_PASSWORD_LENGTH)
|
||||
)
|
||||
if (
|
||||
any(char.isupper() for char in password)
|
||||
and any(char.islower() for char in password)
|
||||
and any(char.isdigit() for char in password)
|
||||
and (exclude is None or password.lower() != exclude.lower())
|
||||
):
|
||||
return password
|
||||
|
||||
|
||||
def validate_password_policy(password: str, username: str) -> str | None:
|
||||
"""Return an error message when ``password`` violates policy, else None."""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return f"密码长度至少为 {MIN_PASSWORD_LENGTH} 位。"
|
||||
if password.lower() == username.lower():
|
||||
return "密码不能与账号相同。"
|
||||
if not any(char.isalpha() for char in password) or not any(
|
||||
char.isdigit() for char in password
|
||||
):
|
||||
return "密码必须同时包含字母和数字。"
|
||||
return None
|
||||
|
||||
|
||||
def create_user(
|
||||
connection: sqlite3.Connection,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str,
|
||||
company_id: int | None = None,
|
||||
must_change_password: bool = True,
|
||||
) -> int:
|
||||
"""Create a user, enforcing the role/company binding rules. Returns the id."""
|
||||
username = username.strip()
|
||||
if not username:
|
||||
raise ValueError("用户名不能为空。")
|
||||
if role not in ("admin", "company"):
|
||||
raise ValueError("角色必须是 admin 或 company。")
|
||||
if role == "company":
|
||||
if company_id is None:
|
||||
raise ValueError("公司账号必须绑定公司。")
|
||||
company = connection.execute(
|
||||
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise ValueError("绑定的公司不存在。")
|
||||
elif company_id is not None:
|
||||
raise ValueError("管理员账号不能绑定公司。")
|
||||
|
||||
now = utc_now()
|
||||
try:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
username, password_hash, role, company_id,
|
||||
must_change_password, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
username,
|
||||
hash_password(password),
|
||||
role,
|
||||
company_id,
|
||||
1 if must_change_password else 0,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError("用户名已存在。") from exc
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def authenticate(
|
||||
connection: sqlite3.Connection, username: str, password: str, ip: str
|
||||
) -> tuple[sqlite3.Row | None, str | None]:
|
||||
"""Verify credentials; returns ``(user_row, None)`` or ``(None, reason)``.
|
||||
|
||||
``reason`` is one of ``rate_limited``, ``disabled``, ``bad_credentials``.
|
||||
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
||||
``audit_log``; the password itself is never stored anywhere.
|
||||
"""
|
||||
window_start = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||
).isoformat()
|
||||
failures = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM login_attempts
|
||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||
""",
|
||||
(username, ip, window_start),
|
||||
).fetchone()
|
||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||
return None, "rate_limited"
|
||||
|
||||
user = connection.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
|
||||
if user is not None and user["status"] == "disabled":
|
||||
_record_attempt(connection, username, ip, success=False)
|
||||
audit(
|
||||
connection,
|
||||
"login_failed",
|
||||
actor=user,
|
||||
detail="账号已停用",
|
||||
ip=ip,
|
||||
)
|
||||
return None, "disabled"
|
||||
|
||||
if user is None or not verify_password(password, user["password_hash"]):
|
||||
_record_attempt(connection, username, ip, success=False)
|
||||
audit(connection, "login_failed", actor=user, detail="账号或密码不正确", ip=ip)
|
||||
return None, "bad_credentials"
|
||||
|
||||
_record_attempt(connection, username, ip, success=True)
|
||||
audit(connection, "login_success", actor=user, ip=ip)
|
||||
return user, None
|
||||
|
||||
|
||||
def create_session(
|
||||
connection: sqlite3.Connection, user_id: int, ttl_hours: int = SESSION_TTL_HOURS
|
||||
) -> str:
|
||||
"""Create a session with absolute expiry; returns the raw token."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
now = datetime.now(timezone.utc)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token_hash, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
token_hash,
|
||||
user_id,
|
||||
now.isoformat(),
|
||||
(now + timedelta(hours=ttl_hours)).isoformat(),
|
||||
),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def resolve_session(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None:
|
||||
"""Return the user row for a live session token, else None.
|
||||
|
||||
Expired or revoked sessions and disabled users are all rejected.
|
||||
"""
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT u.*, s.id AS session_id
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ?
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > ?
|
||||
AND u.status = 'active'
|
||||
""",
|
||||
(token_hash, utc_now()),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def revoke_session(connection: sqlite3.Connection, token: str) -> None:
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL",
|
||||
(utc_now(), token_hash),
|
||||
)
|
||||
|
||||
|
||||
def revoke_user_sessions(connection: sqlite3.Connection, user_id: int) -> None:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
|
||||
(utc_now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def change_password(
|
||||
connection: sqlite3.Connection,
|
||||
user_id: int,
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
) -> str | None:
|
||||
"""Change a user's password; returns an error message or None on success."""
|
||||
user = connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if user is None:
|
||||
return "用户不存在。"
|
||||
if not verify_password(old_password, user["password_hash"]):
|
||||
return "原密码不正确。"
|
||||
error = validate_password_policy(new_password, user["username"])
|
||||
if error is not None:
|
||||
return error
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET password_hash = ?, must_change_password = 0, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(hash_password(new_password), utc_now(), user_id),
|
||||
)
|
||||
audit(connection, "password_change", actor=user, target=f"user:{user_id}")
|
||||
return None
|
||||
|
||||
|
||||
def audit(
|
||||
connection: sqlite3.Connection,
|
||||
action: str,
|
||||
actor: sqlite3.Row | None = None,
|
||||
target: str | None = None,
|
||||
detail: str | None = None,
|
||||
ip: str | None = None,
|
||||
) -> None:
|
||||
"""Append an audit log entry. Never pass passwords in ``detail``."""
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO audit_log (
|
||||
actor_user_id, actor_username, action, target, detail, ip, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
actor["id"] if actor is not None else None,
|
||||
actor["username"] if actor is not None else None,
|
||||
action,
|
||||
target,
|
||||
detail,
|
||||
ip,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _record_attempt(
|
||||
connection: sqlite3.Connection, username: str, ip: str, success: bool
|
||||
) -> None:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO login_attempts (username, ip, success, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(username, ip, 1 if success else 0, utc_now()),
|
||||
)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""SQLite persistence: versioned migrations and connection helpers.
|
||||
|
||||
The database, migration runner and file storage choices are recorded in
|
||||
``docs/decisions/002-persistence.md``. Migrations are plain SQL applied in
|
||||
version order; each records itself in ``schema_migrations`` so re-running
|
||||
``migrate`` on an existing database is a no-op. Every migration ships a
|
||||
``down`` script so ``rollback`` can walk backwards for recovery and tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = Path("data/app.db")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
version: int
|
||||
name: str
|
||||
up: str
|
||||
down: str
|
||||
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(
|
||||
version=1,
|
||||
name="0001_core_persistence",
|
||||
up="""
|
||||
CREATE TABLE companies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE bank_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER REFERENCES companies (id),
|
||||
account_number TEXT NOT NULL UNIQUE,
|
||||
account_name TEXT,
|
||||
bank_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'active', 'disabled')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE source_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sha256 TEXT NOT NULL UNIQUE,
|
||||
original_filename TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE import_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_file_id INTEGER NOT NULL REFERENCES source_files (id),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('parsing', 'parsed', 'exception', 'failed', 'duplicate')),
|
||||
duplicate_of_id INTEGER REFERENCES import_batches (id),
|
||||
diagnostics TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sheet_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
|
||||
sheet_name TEXT NOT NULL,
|
||||
bank_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version INTEGER NOT NULL,
|
||||
header_row INTEGER NOT NULL,
|
||||
own_account TEXT,
|
||||
own_name TEXT,
|
||||
period_start TEXT,
|
||||
period_end TEXT,
|
||||
transaction_count INTEGER NOT NULL,
|
||||
warnings TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (import_batch_id, sheet_name)
|
||||
);
|
||||
|
||||
CREATE TABLE source_rows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sheet_batch_id INTEGER NOT NULL REFERENCES sheet_batches (id),
|
||||
source_row INTEGER NOT NULL,
|
||||
transaction_at TEXT NOT NULL,
|
||||
income TEXT NOT NULL,
|
||||
expense TEXT NOT NULL,
|
||||
balance TEXT,
|
||||
own_account TEXT,
|
||||
own_name TEXT,
|
||||
counterparty_account TEXT,
|
||||
counterparty_name TEXT,
|
||||
counterparty_bank TEXT,
|
||||
summary TEXT,
|
||||
purpose TEXT,
|
||||
reference TEXT,
|
||||
currency TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (sheet_batch_id, source_row)
|
||||
);
|
||||
|
||||
CREATE TABLE import_exceptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
|
||||
stage TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
diagnostics TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TRIGGER source_files_no_update BEFORE UPDATE ON source_files
|
||||
BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
|
||||
CREATE TRIGGER source_files_no_delete BEFORE DELETE ON source_files
|
||||
BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
|
||||
CREATE TRIGGER sheet_batches_no_update BEFORE UPDATE ON sheet_batches
|
||||
BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
|
||||
CREATE TRIGGER sheet_batches_no_delete BEFORE DELETE ON sheet_batches
|
||||
BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
|
||||
CREATE TRIGGER source_rows_no_update BEFORE UPDATE ON source_rows
|
||||
BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
|
||||
CREATE TRIGGER source_rows_no_delete BEFORE DELETE ON source_rows
|
||||
BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
|
||||
""",
|
||||
down="""
|
||||
DROP TRIGGER IF EXISTS source_rows_no_delete;
|
||||
DROP TRIGGER IF EXISTS source_rows_no_update;
|
||||
DROP TRIGGER IF EXISTS sheet_batches_no_delete;
|
||||
DROP TRIGGER IF EXISTS sheet_batches_no_update;
|
||||
DROP TRIGGER IF EXISTS source_files_no_delete;
|
||||
DROP TRIGGER IF EXISTS source_files_no_update;
|
||||
DROP TABLE IF EXISTS import_exceptions;
|
||||
DROP TABLE IF EXISTS source_rows;
|
||||
DROP TABLE IF EXISTS sheet_batches;
|
||||
DROP TABLE IF EXISTS import_batches;
|
||||
DROP TABLE IF EXISTS source_files;
|
||||
DROP TABLE IF EXISTS bank_accounts;
|
||||
DROP TABLE IF EXISTS companies;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=2,
|
||||
name="0002_auth_and_sessions",
|
||||
up="""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'company')),
|
||||
company_id INTEGER REFERENCES companies (id),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (role != 'company' OR company_id IS NOT NULL),
|
||||
CHECK (role != 'admin' OR company_id IS NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id),
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
ip TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE import_batches ADD COLUMN company_id INTEGER REFERENCES companies (id);
|
||||
""",
|
||||
down="""
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS login_attempts;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
ALTER TABLE import_batches DROP COLUMN company_id;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=3,
|
||||
name="0003_dynamic_master_data",
|
||||
# bank_accounts is rebuilt (SQLite cannot alter CHECK constraints):
|
||||
# status gains 'returned', and the account gains type, effective
|
||||
# interval and review fields. account_number stays UNIQUE and stores
|
||||
# the normalized digits-only form (see master_data.normalize).
|
||||
up="""
|
||||
CREATE TABLE bank_accounts_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
account_number TEXT NOT NULL UNIQUE,
|
||||
account_name TEXT,
|
||||
bank_name TEXT NOT NULL DEFAULT '',
|
||||
account_type TEXT NOT NULL DEFAULT '一般户'
|
||||
CHECK (account_type IN ('基本户', '一般户', '专用户')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
|
||||
effective_from TEXT,
|
||||
effective_to TEXT,
|
||||
submitted_by INTEGER REFERENCES users (id),
|
||||
reviewed_by INTEGER REFERENCES users (id),
|
||||
reviewed_at TEXT,
|
||||
review_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO bank_accounts_new (
|
||||
id, company_id, account_number, account_name, bank_name,
|
||||
status, created_at, updated_at
|
||||
)
|
||||
SELECT id, company_id, account_number, account_name,
|
||||
COALESCE(bank_name, ''), status, created_at, updated_at
|
||||
FROM bank_accounts WHERE company_id IS NOT NULL;
|
||||
DROP TABLE bank_accounts;
|
||||
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
|
||||
|
||||
CREATE TABLE account_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
|
||||
alias_kind TEXT NOT NULL CHECK (alias_kind IN ('name', 'account')),
|
||||
alias_value TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
effective_from TEXT,
|
||||
effective_to TEXT,
|
||||
created_by INTEGER REFERENCES users (id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (bank_account_id, alias_kind, alias_value)
|
||||
);
|
||||
|
||||
CREATE TABLE master_data_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL
|
||||
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
|
||||
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
|
||||
);
|
||||
|
||||
ALTER TABLE companies ADD COLUMN credit_code TEXT;
|
||||
ALTER TABLE companies ADD COLUMN cashier_name TEXT;
|
||||
ALTER TABLE companies ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'preparing', 'disabled'));
|
||||
""",
|
||||
down="""
|
||||
ALTER TABLE companies DROP COLUMN status;
|
||||
ALTER TABLE companies DROP COLUMN cashier_name;
|
||||
ALTER TABLE companies DROP COLUMN credit_code;
|
||||
DROP TABLE IF EXISTS master_data_changes;
|
||||
DROP TABLE IF EXISTS account_aliases;
|
||||
|
||||
CREATE TABLE bank_accounts_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER REFERENCES companies (id),
|
||||
account_number TEXT NOT NULL UNIQUE,
|
||||
account_name TEXT,
|
||||
bank_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'active', 'disabled')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO bank_accounts_new (
|
||||
id, company_id, account_number, account_name, bank_name,
|
||||
status, created_at, updated_at
|
||||
)
|
||||
SELECT id, company_id, account_number, account_name, bank_name,
|
||||
CASE WHEN status = 'returned' THEN 'pending' ELSE status END,
|
||||
created_at, updated_at
|
||||
FROM bank_accounts;
|
||||
DROP TABLE bank_accounts;
|
||||
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def connect(path: str | Path) -> sqlite3.Connection:
|
||||
db_path = Path(path)
|
||||
if str(db_path) != ":memory:":
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(str(db_path))
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
|
||||
def applied_versions(connection: sqlite3.Connection) -> list[int]:
|
||||
exists = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'"
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return []
|
||||
rows = connection.execute(
|
||||
"SELECT version FROM schema_migrations ORDER BY version"
|
||||
).fetchall()
|
||||
return [row["version"] for row in rows]
|
||||
|
||||
|
||||
def migrate(connection: sqlite3.Connection) -> list[int]:
|
||||
"""Apply every pending migration; returns the versions applied now."""
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
applied = set(applied_versions(connection))
|
||||
newly_applied: list[int] = []
|
||||
for migration in MIGRATIONS:
|
||||
if migration.version in applied:
|
||||
continue
|
||||
with connection:
|
||||
connection.executescript(migration.up)
|
||||
connection.execute(
|
||||
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||
(migration.version, migration.name, utc_now()),
|
||||
)
|
||||
newly_applied.append(migration.version)
|
||||
return newly_applied
|
||||
|
||||
|
||||
def rollback(connection: sqlite3.Connection, target_version: int = 0) -> list[int]:
|
||||
"""Reverse migrations above ``target_version``; returns reversed versions."""
|
||||
applied = applied_versions(connection)
|
||||
reversed_versions: list[int] = []
|
||||
for migration in sorted(MIGRATIONS, key=lambda item: item.version, reverse=True):
|
||||
if migration.version <= target_version or migration.version not in applied:
|
||||
continue
|
||||
with connection:
|
||||
connection.executescript(migration.down)
|
||||
connection.execute(
|
||||
"DELETE FROM schema_migrations WHERE version = ?",
|
||||
(migration.version,),
|
||||
)
|
||||
reversed_versions.append(migration.version)
|
||||
return reversed_versions
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Apply or roll back database migrations.")
|
||||
parser.add_argument(
|
||||
"db_path",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=DEFAULT_DB_PATH,
|
||||
help="SQLite database path (default: data/app.db)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rollback-to",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="VERSION",
|
||||
help="Reverse migrations above VERSION instead of migrating forward",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
connection = connect(args.db_path)
|
||||
try:
|
||||
if args.rollback_to is None:
|
||||
applied = migrate(connection)
|
||||
print(f"applied migrations: {applied or 'none (already up to date)'}")
|
||||
else:
|
||||
reversed_versions = rollback(connection, args.rollback_to)
|
||||
print(f"reversed migrations: {reversed_versions or 'none'}")
|
||||
finally:
|
||||
connection.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Immutable statement import pipeline.
|
||||
|
||||
Every uploaded file is hashed (SHA-256) and written once to content-addressed
|
||||
storage before parsing. A repeated upload of identical bytes never creates a
|
||||
second set of facts: it records a ``duplicate`` batch that points at the
|
||||
original batch. Parse failures keep the batch and its diagnostics as an
|
||||
``exception`` batch without producing any confirmed source rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .models import StatementBatch
|
||||
from .parser import StatementParseError, parse_statement
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportResult:
|
||||
batch_id: int
|
||||
status: str # parsed | duplicate | exception
|
||||
sha256: str
|
||||
source_file_id: int
|
||||
batches: tuple[StatementBatch, ...] = ()
|
||||
message: str | None = None
|
||||
duplicate_same_company: bool = False # only meaningful when status == 'duplicate'
|
||||
|
||||
|
||||
def import_statement(
|
||||
connection: sqlite3.Connection,
|
||||
storage_dir: str | Path,
|
||||
original_filename: str,
|
||||
content: bytes,
|
||||
company_id: int | None = None,
|
||||
) -> ImportResult:
|
||||
sha256 = hashlib.sha256(content).hexdigest()
|
||||
existing_file = connection.execute(
|
||||
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
|
||||
).fetchone()
|
||||
|
||||
if existing_file is not None:
|
||||
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
|
||||
|
||||
stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(sha256, original_filename, len(content), str(stored_path), now),
|
||||
)
|
||||
source_file_id = cursor.lastrowid
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
|
||||
VALUES (?, 'parsing', ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, now, now),
|
||||
)
|
||||
batch_id = cursor.lastrowid
|
||||
|
||||
try:
|
||||
batches = parse_statement(stored_path)
|
||||
except StatementParseError as exc:
|
||||
message = _clean_message(str(exc), stored_path, original_filename)
|
||||
with connection:
|
||||
_insert_exception(connection, batch_id, "parse", message, original_filename)
|
||||
_set_batch_status(connection, batch_id, "exception")
|
||||
return ImportResult(batch_id, "exception", sha256, source_file_id, message=message)
|
||||
except Exception as exc:
|
||||
message = f"文件解析失败,请检查文件是否完整。({type(exc).__name__})"
|
||||
with connection:
|
||||
_insert_exception(connection, batch_id, "internal", message, original_filename)
|
||||
_set_batch_status(connection, batch_id, "failed")
|
||||
raise
|
||||
|
||||
with connection:
|
||||
for batch in batches:
|
||||
_insert_sheet_batch(connection, batch_id, batch)
|
||||
_set_batch_status(connection, batch_id, "parsed")
|
||||
return ImportResult(batch_id, "parsed", sha256, source_file_id, batches=batches)
|
||||
|
||||
|
||||
def _record_duplicate(
|
||||
connection: sqlite3.Connection,
|
||||
source_file_id: int,
|
||||
sha256: str,
|
||||
company_id: int | None = None,
|
||||
) -> ImportResult:
|
||||
original = connection.execute(
|
||||
"""
|
||||
SELECT id, company_id FROM import_batches
|
||||
WHERE source_file_id = ? AND status = 'parsed'
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(source_file_id,),
|
||||
).fetchone()
|
||||
if original is None:
|
||||
original = connection.execute(
|
||||
"""
|
||||
SELECT id, company_id FROM import_batches
|
||||
WHERE source_file_id = ? AND status != 'duplicate'
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(source_file_id,),
|
||||
).fetchone()
|
||||
now = utc_now()
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches
|
||||
(source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at)
|
||||
VALUES (?, 'duplicate', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source_file_id,
|
||||
original["id"],
|
||||
company_id,
|
||||
json.dumps({"note": "内容哈希相同,复用已有批次,不产生第二份事实。"}, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
# A duplicate is only "same company" when the uploader belongs to the
|
||||
# same tenant that owns the original batch. Cross-company duplicates
|
||||
# must stay opaque: the caller gets the new duplicate batch id (which
|
||||
# belongs to its own company) but never the other company's batch id.
|
||||
same_company = original["company_id"] == company_id
|
||||
if same_company:
|
||||
batch_id = original["id"]
|
||||
else:
|
||||
batch_id = int(cursor.lastrowid)
|
||||
return ImportResult(
|
||||
batch_id,
|
||||
"duplicate",
|
||||
sha256,
|
||||
source_file_id,
|
||||
message="相同内容的文件已导入,本次按重复上传处理。",
|
||||
duplicate_same_company=same_company,
|
||||
)
|
||||
|
||||
|
||||
def _store_immutable(
|
||||
storage_dir: Path, original_filename: str, content: bytes, sha256: str
|
||||
) -> Path:
|
||||
suffix = Path(original_filename).suffix.lower()
|
||||
target_dir = storage_dir / sha256[:2]
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / f"{sha256}{suffix}"
|
||||
# Publish via a temporary file + hard link: the content-addressed target
|
||||
# either appears complete or not at all, and is never overwritten.
|
||||
temp = target_dir / f".{sha256}.tmp"
|
||||
temp.write_bytes(content)
|
||||
try:
|
||||
os.link(temp, target)
|
||||
except FileExistsError:
|
||||
# Content-addressed name means identical bytes; never overwrite.
|
||||
pass
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
def _insert_sheet_batch(
|
||||
connection: sqlite3.Connection, batch_id: int, batch: StatementBatch
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||||
header_row, own_account, own_name, period_start, period_end,
|
||||
transaction_count, warnings, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
batch_id,
|
||||
batch.sheet_name,
|
||||
batch.bank_name,
|
||||
batch.template_id,
|
||||
batch.template_version,
|
||||
batch.header_row,
|
||||
batch.own_account,
|
||||
batch.own_name,
|
||||
batch.period_start.isoformat() if batch.period_start else None,
|
||||
batch.period_end.isoformat() if batch.period_end else None,
|
||||
len(batch.transactions),
|
||||
json.dumps(list(batch.warnings), ensure_ascii=False),
|
||||
now,
|
||||
),
|
||||
)
|
||||
sheet_batch_id = cursor.lastrowid
|
||||
for transaction in batch.transactions:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense, balance,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
counterparty_bank, summary, purpose, reference, currency, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
sheet_batch_id,
|
||||
transaction.source_row,
|
||||
transaction.transaction_at.isoformat(),
|
||||
str(transaction.income),
|
||||
str(transaction.expense),
|
||||
str(transaction.balance) if transaction.balance is not None else None,
|
||||
transaction.own_account,
|
||||
transaction.own_name,
|
||||
transaction.counterparty_account,
|
||||
transaction.counterparty_name,
|
||||
transaction.counterparty_bank,
|
||||
transaction.summary,
|
||||
transaction.purpose,
|
||||
transaction.reference,
|
||||
transaction.currency,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _insert_exception(
|
||||
connection: sqlite3.Connection,
|
||||
batch_id: int,
|
||||
stage: str,
|
||||
message: str,
|
||||
original_filename: str,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO import_exceptions (import_batch_id, stage, message, diagnostics, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
batch_id,
|
||||
stage,
|
||||
message,
|
||||
json.dumps({"original_filename": original_filename}, ensure_ascii=False),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _set_batch_status(connection: sqlite3.Connection, batch_id: int, status: str) -> None:
|
||||
connection.execute(
|
||||
"UPDATE import_batches SET status = ?, updated_at = ? WHERE id = ?",
|
||||
(status, utc_now(), batch_id),
|
||||
)
|
||||
|
||||
|
||||
def _clean_message(message: str, stored_path: Path, original_filename: str) -> str:
|
||||
return message.replace(str(stored_path), original_filename).replace(
|
||||
stored_path.name, original_filename
|
||||
)
|
||||
@@ -0,0 +1,570 @@
|
||||
"""Dynamic master data: companies, bank accounts, aliases and audit trail.
|
||||
|
||||
Companies, cashier logins and bank accounts live in the database instead of
|
||||
being hard-coded in the UI. Bank account numbers are stored normalized
|
||||
(digits only) under a database UNIQUE constraint, so two concurrent
|
||||
submissions can never produce two usable accounts for the same number.
|
||||
Company-side registrations are requests: only administrator-approved
|
||||
(``active``) accounts inside their effective interval may identify ownership,
|
||||
accept uploads or take part in coverage calculation. Every change is recorded
|
||||
in ``master_data_changes`` with before/after values, actor, time and reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
|
||||
|
||||
ACCOUNT_TYPES = ("基本户", "一般户", "专用户")
|
||||
ACCOUNT_STATUSES = ("pending", "active", "returned", "disabled")
|
||||
ALIAS_KINDS = ("name", "account")
|
||||
|
||||
# Deterministic alias match priority: an exact account-number hit always
|
||||
# beats an account alias, which always beats a name alias. The per-alias
|
||||
# ``priority`` column only orders matches within the same tier.
|
||||
PRIORITY_EXACT_ACCOUNT = 0
|
||||
PRIORITY_ACCOUNT_ALIAS = 1000
|
||||
PRIORITY_NAME_ALIAS = 2000
|
||||
|
||||
_ACCOUNT_NUMBER_PATTERN = re.compile(r"^[0-9]{6,32}$")
|
||||
_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_FULL_WIDTH_DIGITS = str.maketrans("0123456789", "0123456789")
|
||||
|
||||
|
||||
class ConflictError(ValueError):
|
||||
"""A uniqueness or ownership conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
def utc_today() -> str:
|
||||
return datetime.now(timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
def normalize_account_number(raw: object) -> str:
|
||||
"""Normalize an account number to digits only; raise ValueError if invalid.
|
||||
|
||||
Spaces, dashes and full-width digits entered by cashiers or emitted by
|
||||
bank exports collapse to one canonical form, which is what the UNIQUE
|
||||
constraint and all matching operate on.
|
||||
"""
|
||||
text = str(raw or "").translate(_FULL_WIDTH_DIGITS)
|
||||
text = re.sub(r"[\s\-‐-‒–—]+", "", text)
|
||||
if not _ACCOUNT_NUMBER_PATTERN.fullmatch(text):
|
||||
raise ValueError("银行账号须为 6-32 位数字(可含空格或短横线分隔)。")
|
||||
return text
|
||||
|
||||
|
||||
def mask_account_number(number: str) -> str:
|
||||
"""Masked display form; the full number stays server-side."""
|
||||
if len(number) <= 4:
|
||||
return f"****{number}"
|
||||
return f"****{number[-4:]}"
|
||||
|
||||
|
||||
def validate_date(value: object, field: str, *, required: bool = False) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
if required:
|
||||
raise ValueError(f"{field}不能为空。")
|
||||
return None
|
||||
if not _DATE_PATTERN.fullmatch(text):
|
||||
raise ValueError(f"{field}须为 YYYY-MM-DD 格式。")
|
||||
try:
|
||||
date.fromisoformat(text)
|
||||
except ValueError:
|
||||
raise ValueError(f"{field}不是有效日期。") from None
|
||||
return text
|
||||
|
||||
|
||||
def validate_account_type(value: object) -> str:
|
||||
text = str(value or "").strip() or "一般户"
|
||||
if text not in ACCOUNT_TYPES:
|
||||
raise ValueError(f"账户类型必须是:{'、'.join(ACCOUNT_TYPES)}。")
|
||||
return text
|
||||
|
||||
|
||||
def _snapshot(row: sqlite3.Row | None) -> dict[str, object] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {key: row[key] for key in row.keys()}
|
||||
|
||||
|
||||
def record_change(
|
||||
connection: sqlite3.Connection,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
before: dict[str, object] | None,
|
||||
after: dict[str, object] | None,
|
||||
reason: str | None,
|
||||
actor: sqlite3.Row | None,
|
||||
) -> None:
|
||||
"""Append a before/after audit entry for a master data change."""
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO master_data_changes (
|
||||
entity_type, entity_id, action, before_json, after_json,
|
||||
reason, actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
entity_type,
|
||||
entity_id,
|
||||
action,
|
||||
json.dumps(before, ensure_ascii=False) if before is not None else None,
|
||||
json.dumps(after, ensure_ascii=False) if after is not None else None,
|
||||
reason,
|
||||
actor["id"] if actor is not None else None,
|
||||
actor["username"] if actor is not None else None,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Companies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_company(
|
||||
connection: sqlite3.Connection,
|
||||
name: str,
|
||||
credit_code: str | None,
|
||||
cashier_name: str | None,
|
||||
actor: sqlite3.Row | None,
|
||||
) -> int:
|
||||
"""Create a company master record; returns the new id."""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("公司名称不能为空。")
|
||||
now = utc_now()
|
||||
try:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO companies (
|
||||
name, credit_code, cashier_name, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'active', ?, ?)
|
||||
""",
|
||||
(name, (credit_code or "").strip() or None,
|
||||
(cashier_name or "").strip() or None, now, now),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ConflictError("公司名称已存在。") from exc
|
||||
company_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "company", company_id, "create",
|
||||
None, {"name": name, "credit_code": credit_code or None,
|
||||
"cashier_name": cashier_name or None, "status": "active"},
|
||||
None, actor,
|
||||
)
|
||||
return company_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bank accounts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_account(connection: sqlite3.Connection, account_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"SELECT * FROM bank_accounts WHERE id = ?", (account_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def submit_bank_account(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
bank_name: str,
|
||||
account_type: object,
|
||||
account_number: object,
|
||||
account_name: object = None,
|
||||
start_date: object = None,
|
||||
actor: sqlite3.Row | None,
|
||||
) -> sqlite3.Row:
|
||||
"""Register an account for review; returns the resulting account row.
|
||||
|
||||
A normalized number already present is never duplicated: resubmitting a
|
||||
returned request from the same company reopens that same row as pending;
|
||||
any other existing row is a conflict. The UNIQUE constraint on
|
||||
``account_number`` is the final guard for concurrent submissions.
|
||||
"""
|
||||
number = normalize_account_number(account_number)
|
||||
bank = str(bank_name or "").strip()
|
||||
if not bank:
|
||||
raise ValueError("开户银行不能为空。")
|
||||
kind = validate_account_type(account_type)
|
||||
holder = str(account_name or "").strip() or None
|
||||
requested_from = validate_date(start_date, "启用日期")
|
||||
now = utc_now()
|
||||
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM bank_accounts WHERE account_number = ?", (number,)
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
try:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO bank_accounts (
|
||||
company_id, account_number, account_name, bank_name,
|
||||
account_type, status, effective_from, submitted_by,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
""",
|
||||
(company_id, number, holder, bank, kind,
|
||||
requested_from, actor["id"] if actor else None, now, now),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
# Lost a concurrent-insert race on the UNIQUE constraint.
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。") from exc
|
||||
account_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "bank_account", account_id, "submit", None,
|
||||
{"company_id": company_id, "account_number": number,
|
||||
"bank_name": bank, "account_type": kind, "status": "pending",
|
||||
"effective_from": requested_from},
|
||||
None, actor,
|
||||
)
|
||||
return get_account(connection, account_id)
|
||||
|
||||
if existing["status"] == "returned" and existing["company_id"] == company_id:
|
||||
before = _snapshot(existing)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
SET account_name = ?, bank_name = ?, account_type = ?,
|
||||
status = 'pending', effective_from = ?,
|
||||
submitted_by = ?, reviewed_by = NULL, reviewed_at = NULL,
|
||||
review_reason = NULL, updated_at = ?
|
||||
WHERE id = ? AND status = 'returned'
|
||||
""",
|
||||
(holder, bank, kind, requested_from,
|
||||
actor["id"] if actor else None, now, existing["id"]),
|
||||
)
|
||||
record_change(
|
||||
connection, "bank_account", existing["id"], "resubmit",
|
||||
before, {"account_number": number, "bank_name": bank,
|
||||
"account_type": kind, "status": "pending",
|
||||
"effective_from": requested_from},
|
||||
"退回后重新提交", actor,
|
||||
)
|
||||
return get_account(connection, existing["id"])
|
||||
|
||||
if existing["company_id"] == company_id:
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
|
||||
|
||||
|
||||
def review_bank_account(
|
||||
connection: sqlite3.Connection,
|
||||
account_id: int,
|
||||
decision: str,
|
||||
reason: str | None,
|
||||
actor: sqlite3.Row,
|
||||
*,
|
||||
effective_from: object = None,
|
||||
effective_to: object = None,
|
||||
) -> sqlite3.Row:
|
||||
"""Approve, return or disable a registration; returns the updated row."""
|
||||
account = get_account(connection, account_id)
|
||||
if account is None:
|
||||
raise LookupError("账户不存在。")
|
||||
reason = (reason or "").strip() or None
|
||||
today = utc_today()
|
||||
before = _snapshot(account)
|
||||
|
||||
if decision == "approve":
|
||||
if account["status"] != "pending":
|
||||
raise ConflictError("只有待复核的账户可以审核通过。")
|
||||
start = validate_date(effective_from, "启用日期") or account["effective_from"] or today
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
SET status = 'active', effective_from = ?, effective_to = NULL,
|
||||
reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(start, actor["id"], utc_now(), reason, utc_now(), account_id),
|
||||
)
|
||||
record_change(
|
||||
connection, "bank_account", account_id, "approve", before,
|
||||
{"status": "active", "effective_from": start}, reason, actor,
|
||||
)
|
||||
elif decision == "return":
|
||||
if account["status"] != "pending":
|
||||
raise ConflictError("只有待复核的账户可以退回。")
|
||||
if reason is None:
|
||||
raise ValueError("退回必须填写原因。")
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
SET status = 'returned', reviewed_by = ?, reviewed_at = ?,
|
||||
review_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(actor["id"], utc_now(), reason, utc_now(), account_id),
|
||||
)
|
||||
record_change(
|
||||
connection, "bank_account", account_id, "return", before,
|
||||
{"status": "returned"}, reason, actor,
|
||||
)
|
||||
elif decision == "disable":
|
||||
if account["status"] != "active":
|
||||
raise ConflictError("只有已启用的账户可以停用。")
|
||||
if reason is None:
|
||||
raise ValueError("停用必须填写原因。")
|
||||
end = validate_date(effective_to, "停用日期") or today
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE bank_accounts
|
||||
SET status = 'disabled', effective_to = ?,
|
||||
reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(end, actor["id"], utc_now(), reason, utc_now(), account_id),
|
||||
)
|
||||
record_change(
|
||||
connection, "bank_account", account_id, "disable", before,
|
||||
{"status": "disabled", "effective_to": end}, reason, actor,
|
||||
)
|
||||
else:
|
||||
raise ValueError("审核决定必须是 approve、return 或 disable。")
|
||||
return get_account(connection, account_id)
|
||||
|
||||
|
||||
def in_effective_window(account: sqlite3.Row, on_date: str) -> bool:
|
||||
"""True when ``on_date`` falls inside the account's effective interval.
|
||||
|
||||
``effective_to`` is the last participating day (inclusive).
|
||||
"""
|
||||
if account["effective_from"] and on_date < account["effective_from"]:
|
||||
return False
|
||||
if account["effective_to"] and on_date > account["effective_to"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_usable(account: sqlite3.Row, on_date: str) -> bool:
|
||||
"""True when the account may accept uploads / be selected on a date."""
|
||||
return account["status"] == "active" and in_effective_window(account, on_date)
|
||||
|
||||
|
||||
def is_identifiable(account: sqlite3.Row, on_date: str) -> bool:
|
||||
"""True when the account may resolve ownership for a transaction date.
|
||||
|
||||
A disabled account keeps identifying historical rows inside its effective
|
||||
window — disabling stops future participation, it never rewrites history.
|
||||
"""
|
||||
return account["status"] in ("active", "disabled") and in_effective_window(
|
||||
account, on_date
|
||||
)
|
||||
|
||||
|
||||
def list_accounts(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
conditions.append("a.company_id = ?")
|
||||
params.append(company_id)
|
||||
if status is not None:
|
||||
if status not in ACCOUNT_STATUSES:
|
||||
raise ValueError("无效的账户状态。")
|
||||
conditions.append("a.status = ?")
|
||||
params.append(status)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
return connection.execute(
|
||||
f"""
|
||||
SELECT a.*, c.name AS company_name
|
||||
FROM bank_accounts a
|
||||
JOIN companies c ON c.id = a.company_id
|
||||
{where}
|
||||
ORDER BY a.id
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
|
||||
def usable_accounts(
|
||||
connection: sqlite3.Connection, company_id: int, on_date: str | None = None
|
||||
) -> list[sqlite3.Row]:
|
||||
"""Accounts allowed to upload / identify ownership for the company today."""
|
||||
day = on_date or utc_today()
|
||||
return [
|
||||
account
|
||||
for account in list_accounts(connection, company_id=company_id)
|
||||
if is_usable(account, day)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_alias(
|
||||
connection: sqlite3.Connection,
|
||||
account_id: int,
|
||||
alias_kind: str,
|
||||
alias_value: object,
|
||||
priority: object = None,
|
||||
effective_from: object = None,
|
||||
effective_to: object = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
) -> int:
|
||||
"""Attach a name/account alias with an effective interval; returns the id."""
|
||||
if get_account(connection, account_id) is None:
|
||||
raise LookupError("账户不存在。")
|
||||
if alias_kind not in ALIAS_KINDS:
|
||||
raise ValueError("别名类型必须是 name 或 account。")
|
||||
if alias_kind == "account":
|
||||
value = normalize_account_number(alias_value)
|
||||
else:
|
||||
value = re.sub(r"\s+", "", str(alias_value or ""))
|
||||
if not value:
|
||||
raise ValueError("户名别名不能为空。")
|
||||
try:
|
||||
rank = int(priority) if priority not in (None, "") else 100
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("优先级必须是整数。") from None
|
||||
start = validate_date(effective_from, "别名生效日期")
|
||||
end = validate_date(effective_to, "别名失效日期")
|
||||
if start and end and end < start:
|
||||
raise ValueError("别名失效日期不能早于生效日期。")
|
||||
try:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO account_aliases (
|
||||
bank_account_id, alias_kind, alias_value, priority,
|
||||
effective_from, effective_to, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(account_id, alias_kind, value, rank, start, end,
|
||||
actor["id"] if actor else None, utc_now()),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ConflictError("该账户下相同别名已存在。") from exc
|
||||
alias_id = int(cursor.lastrowid)
|
||||
with connection:
|
||||
record_change(
|
||||
connection, "account_alias", alias_id, "create", None,
|
||||
{"bank_account_id": account_id, "alias_kind": alias_kind,
|
||||
"alias_value": value, "priority": rank,
|
||||
"effective_from": start, "effective_to": end},
|
||||
None, actor,
|
||||
)
|
||||
return alias_id
|
||||
|
||||
|
||||
def list_aliases(connection: sqlite3.Connection, account_id: int) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"SELECT * FROM account_aliases WHERE bank_account_id = ? ORDER BY id",
|
||||
(account_id,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def match_account(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
account_number: object = None,
|
||||
name: object = None,
|
||||
on_date: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Resolve an observed counterparty to operating, in-window accounts.
|
||||
|
||||
Deterministic priority: exact account number < account alias < name
|
||||
alias; within one tier the alias ``priority`` column orders the hits.
|
||||
Pending and returned accounts never match; disabled accounts still match
|
||||
transaction dates inside their effective window (see ``is_identifiable``).
|
||||
"""
|
||||
day = on_date or utc_today()
|
||||
hits: list[tuple[int, sqlite3.Row, str]] = []
|
||||
|
||||
number = str(account_number or "").strip()
|
||||
if number:
|
||||
try:
|
||||
normalized = normalize_account_number(number)
|
||||
except ValueError:
|
||||
normalized = None
|
||||
if normalized is not None:
|
||||
exact = connection.execute(
|
||||
"SELECT * FROM bank_accounts WHERE account_number = ?",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
if exact is not None and is_identifiable(exact, day):
|
||||
hits.append((PRIORITY_EXACT_ACCOUNT, exact, "account_exact"))
|
||||
alias_rows = connection.execute(
|
||||
"""
|
||||
SELECT a.*, al.priority AS alias_priority,
|
||||
al.effective_from AS alias_from, al.effective_to AS alias_to
|
||||
FROM account_aliases al
|
||||
JOIN bank_accounts a ON a.id = al.bank_account_id
|
||||
WHERE al.alias_kind = 'account' AND al.alias_value = ?
|
||||
""",
|
||||
(normalized,),
|
||||
).fetchall()
|
||||
for alias in alias_rows:
|
||||
if _alias_in_window(alias, day) and is_identifiable(alias, day):
|
||||
hits.append(
|
||||
(PRIORITY_ACCOUNT_ALIAS + alias["alias_priority"], alias, "account_alias")
|
||||
)
|
||||
|
||||
normalized_name = re.sub(r"\s+", "", str(name or ""))
|
||||
if normalized_name:
|
||||
alias_rows = connection.execute(
|
||||
"""
|
||||
SELECT a.*, al.priority AS alias_priority,
|
||||
al.effective_from AS alias_from, al.effective_to AS alias_to
|
||||
FROM account_aliases al
|
||||
JOIN bank_accounts a ON a.id = al.bank_account_id
|
||||
WHERE al.alias_kind = 'name' AND al.alias_value = ?
|
||||
""",
|
||||
(normalized_name,),
|
||||
).fetchall()
|
||||
for alias in alias_rows:
|
||||
if _alias_in_window(alias, day) and is_identifiable(alias, day):
|
||||
hits.append(
|
||||
(PRIORITY_NAME_ALIAS + alias["alias_priority"], alias, "name_alias")
|
||||
)
|
||||
|
||||
hits.sort(key=lambda item: (item[0], item[1]["id"]))
|
||||
seen: set[int] = set()
|
||||
results: list[dict[str, object]] = []
|
||||
for rank, account, via in hits:
|
||||
if account["id"] in seen:
|
||||
continue
|
||||
seen.add(account["id"])
|
||||
results.append(
|
||||
{
|
||||
"bank_account_id": account["id"],
|
||||
"company_id": account["company_id"],
|
||||
"via": via,
|
||||
"priority": rank,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _alias_in_window(alias: sqlite3.Row, on_date: str) -> bool:
|
||||
if alias["alias_from"] and on_date < alias["alias_from"]:
|
||||
return False
|
||||
if alias["alias_to"] and on_date > alias["alias_to"]:
|
||||
return False
|
||||
return True
|
||||
@@ -39,3 +39,4 @@ class StatementBatch:
|
||||
period_end: date | None
|
||||
transactions: tuple[NormalizedTransaction, ...]
|
||||
warnings: tuple[str, ...]
|
||||
template_version: int = 1
|
||||
|
||||
@@ -190,6 +190,7 @@ def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
|
||||
else None,
|
||||
transactions=tuple(transactions),
|
||||
warnings=tuple(warnings),
|
||||
template_version=template.version,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class BankTemplate:
|
||||
bank_name: str
|
||||
columns: dict[str, tuple[str, ...]]
|
||||
required: tuple[str, ...]
|
||||
version: int = 1
|
||||
|
||||
|
||||
def normalize_header(value: object) -> str:
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
param(
|
||||
[ValidateRange(1, 65535)]
|
||||
[int]$Port = 4173
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
docker compose version | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker Compose is not available."
|
||||
}
|
||||
|
||||
docker info | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker is not running."
|
||||
}
|
||||
|
||||
$env:APP_PORT = $Port.ToString()
|
||||
docker compose up --build --detach
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker Compose failed to start the application."
|
||||
}
|
||||
|
||||
$containerId = (docker compose ps --quiet app).Trim()
|
||||
if (-not $containerId) {
|
||||
throw "The application container was not created."
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt -lt 60; $attempt++) {
|
||||
$state = docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $containerId
|
||||
if ($state -eq "healthy") {
|
||||
Write-Host "Application is ready: http://localhost:$Port"
|
||||
exit 0
|
||||
}
|
||||
if ($state -eq "unhealthy" -or $state -eq "exited" -or $state -eq "dead") {
|
||||
docker compose logs --no-color --tail 100 app
|
||||
throw "The application container entered state: $state"
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
docker compose logs --no-color --tail 100 app
|
||||
throw "Timed out waiting for the application health check."
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
APP_PORT="${APP_PORT:-${1:-4173}}"
|
||||
export APP_PORT
|
||||
|
||||
case "$APP_PORT" in
|
||||
''|*[!0-9]*)
|
||||
echo "APP_PORT must be an integer between 1 and 65535." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then
|
||||
echo "APP_PORT must be an integer between 1 and 65535." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose version >/dev/null
|
||||
docker info >/dev/null
|
||||
docker compose up --build --detach
|
||||
|
||||
container_id="$(docker compose ps --quiet app)"
|
||||
if [ -z "$container_id" ]; then
|
||||
echo "The application container was not created." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 60 ]; do
|
||||
state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
|
||||
case "$state" in
|
||||
healthy)
|
||||
echo "Application is ready: http://localhost:${APP_PORT}"
|
||||
exit 0
|
||||
;;
|
||||
unhealthy|exited|dead)
|
||||
docker compose logs --no-color --tail 100 app
|
||||
echo "The application container entered state: $state" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker compose logs --no-color --tail 100 app
|
||||
echo "Timed out waiting for the application health check." >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
class AuthTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.company_id = int(self.connection.execute("SELECT id FROM companies").fetchone()["id"])
|
||||
|
||||
def create_company_user(self, username: str = "cashier-a", password: str = "Init1234") -> int:
|
||||
return auth.create_user(
|
||||
self.connection, username, password, "company", company_id=self.company_id
|
||||
)
|
||||
|
||||
def create_admin(self, username: str = "group-admin", password: str = "Admin1234") -> int:
|
||||
return auth.create_user(self.connection, username, password, "admin")
|
||||
|
||||
|
||||
class PasswordHashTests(unittest.TestCase):
|
||||
def test_hash_format_and_verify_roundtrip(self) -> None:
|
||||
stored = auth.hash_password("Secret123")
|
||||
scheme, iterations, salt_hex, hash_hex = stored.split("$")
|
||||
self.assertEqual("pbkdf2_sha256", scheme)
|
||||
self.assertEqual(str(auth.PBKDF2_ITERATIONS), iterations)
|
||||
self.assertEqual(32, len(salt_hex))
|
||||
self.assertEqual(64, len(hash_hex))
|
||||
self.assertTrue(auth.verify_password("Secret123", stored))
|
||||
|
||||
def test_wrong_password_is_rejected(self) -> None:
|
||||
stored = auth.hash_password("Secret123")
|
||||
self.assertFalse(auth.verify_password("Secret124", stored))
|
||||
|
||||
def test_same_password_gets_different_salts(self) -> None:
|
||||
self.assertNotEqual(auth.hash_password("Secret123"), auth.hash_password("Secret123"))
|
||||
|
||||
def test_malformed_stored_hash_is_rejected(self) -> None:
|
||||
for stored in ("", "plain", "pbkdf2_sha256$x$y$z", "bcrypt$1$aa$bb"):
|
||||
with self.subTest(stored=stored):
|
||||
self.assertFalse(auth.verify_password("Secret123", stored))
|
||||
|
||||
|
||||
class PasswordPolicyTests(unittest.TestCase):
|
||||
def test_policy_rejects_short_username_like_and_single_charset(self) -> None:
|
||||
self.assertIsNotNone(auth.validate_password_policy("Ab1", "cashier"))
|
||||
self.assertIsNotNone(auth.validate_password_policy("Cashier99", "cashier99"))
|
||||
self.assertIsNotNone(auth.validate_password_policy("abcdefgh", "cashier"))
|
||||
self.assertIsNotNone(auth.validate_password_policy("12345678", "cashier"))
|
||||
self.assertIsNone(auth.validate_password_policy("GoodPass1", "cashier"))
|
||||
|
||||
def test_initial_password_generator_guarantees_charset(self) -> None:
|
||||
for _ in range(50):
|
||||
password = auth.generate_initial_password()
|
||||
self.assertEqual(12, len(password))
|
||||
self.assertTrue(any(char.isupper() for char in password))
|
||||
self.assertTrue(any(char.islower() for char in password))
|
||||
self.assertTrue(any(char.isdigit() for char in password))
|
||||
self.assertIsNone(auth.validate_password_policy(auth.generate_initial_password(), "x"))
|
||||
|
||||
def test_initial_password_exclude_never_equals_username(self) -> None:
|
||||
for _ in range(50):
|
||||
password = auth.generate_initial_password(exclude="Cashier99")
|
||||
self.assertNotEqual(password.lower(), "cashier99")
|
||||
|
||||
|
||||
class CreateUserTests(AuthTestCase):
|
||||
def test_company_role_requires_company(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
auth.create_user(self.connection, "cashier-x", "Init1234", "company")
|
||||
|
||||
def test_company_must_exist(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
auth.create_user(self.connection, "cashier-x", "Init1234", "company", company_id=999)
|
||||
|
||||
def test_admin_cannot_bind_company(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
auth.create_user(
|
||||
self.connection, "admin-x", "Init1234", "admin", company_id=self.company_id
|
||||
)
|
||||
|
||||
def test_invalid_role_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
auth.create_user(self.connection, "nobody", "Init1234", "superuser")
|
||||
|
||||
def test_duplicate_username_rejected(self) -> None:
|
||||
self.create_company_user()
|
||||
with self.assertRaises(ValueError):
|
||||
self.create_company_user()
|
||||
|
||||
def test_new_company_user_must_change_password_by_default(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
user = self.connection.execute(
|
||||
"SELECT must_change_password, status FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(1, user["must_change_password"])
|
||||
self.assertEqual("active", user["status"])
|
||||
|
||||
|
||||
class AuthenticateTests(AuthTestCase):
|
||||
def test_success_returns_user_and_records_attempt(self) -> None:
|
||||
self.create_company_user()
|
||||
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1")
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsNone(reason)
|
||||
attempt = self.connection.execute("SELECT success FROM login_attempts").fetchone()
|
||||
self.assertEqual(1, attempt["success"])
|
||||
entry = self.connection.execute(
|
||||
"SELECT action FROM audit_log WHERE action = 'login_success'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(entry)
|
||||
|
||||
def test_bad_credentials_do_not_leak_which_part_failed(self) -> None:
|
||||
self.create_company_user()
|
||||
for username, password in (("cashier-a", "Wrong999"), ("no-such-user", "Init1234")):
|
||||
with self.subTest(username=username):
|
||||
user, reason = auth.authenticate(self.connection, username, password, "127.0.0.1")
|
||||
self.assertIsNone(user)
|
||||
self.assertEqual("bad_credentials", reason)
|
||||
|
||||
def test_disabled_user_gets_disabled_reason(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
|
||||
(utc_now(), user_id),
|
||||
)
|
||||
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1")
|
||||
self.assertIsNone(user)
|
||||
self.assertEqual("disabled", reason)
|
||||
|
||||
def test_five_failures_within_window_trigger_rate_limit(self) -> None:
|
||||
self.create_company_user()
|
||||
for _ in range(auth.RATE_LIMIT_MAX_FAILURES):
|
||||
user, reason = auth.authenticate(self.connection, "cashier-a", "Wrong999", "10.0.0.1")
|
||||
self.assertEqual("bad_credentials", reason)
|
||||
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.1")
|
||||
self.assertIsNone(user)
|
||||
self.assertEqual("rate_limited", reason)
|
||||
# A different IP is not limited.
|
||||
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.2")
|
||||
self.assertIsNotNone(user)
|
||||
|
||||
def test_failures_outside_window_do_not_count(self) -> None:
|
||||
self.create_company_user()
|
||||
old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
|
||||
with self.connection:
|
||||
for _ in range(auth.RATE_LIMIT_MAX_FAILURES):
|
||||
self.connection.execute(
|
||||
"INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?, ?, 0, ?)",
|
||||
("cashier-a", "10.0.0.9", old),
|
||||
)
|
||||
user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.9")
|
||||
self.assertIsNotNone(user)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
|
||||
class SessionTests(AuthTestCase):
|
||||
def test_create_and_resolve_roundtrip(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
token = auth.create_session(self.connection, user_id)
|
||||
user = auth.resolve_session(self.connection, token)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertEqual(user_id, user["id"])
|
||||
# Only the digest is stored, never the raw token.
|
||||
row = self.connection.execute("SELECT token_hash FROM sessions").fetchone()
|
||||
self.assertEqual(hashlib.sha256(token.encode()).hexdigest(), row["token_hash"])
|
||||
self.assertNotIn(token, row["token_hash"])
|
||||
|
||||
def test_expired_session_is_rejected(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
token = auth.create_session(self.connection, user_id)
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE sessions SET expires_at = ? WHERE token_hash = ?",
|
||||
(past, hashlib.sha256(token.encode()).hexdigest()),
|
||||
)
|
||||
self.assertIsNone(auth.resolve_session(self.connection, token))
|
||||
|
||||
def test_revoked_session_is_rejected(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
token = auth.create_session(self.connection, user_id)
|
||||
auth.revoke_session(self.connection, token)
|
||||
self.assertIsNone(auth.resolve_session(self.connection, token))
|
||||
|
||||
def test_revoke_user_sessions_kills_all_sessions(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
first = auth.create_session(self.connection, user_id)
|
||||
second = auth.create_session(self.connection, user_id)
|
||||
auth.revoke_user_sessions(self.connection, user_id)
|
||||
self.assertIsNone(auth.resolve_session(self.connection, first))
|
||||
self.assertIsNone(auth.resolve_session(self.connection, second))
|
||||
|
||||
def test_disabled_user_session_is_rejected(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
token = auth.create_session(self.connection, user_id)
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
|
||||
(utc_now(), user_id),
|
||||
)
|
||||
self.assertIsNone(auth.resolve_session(self.connection, token))
|
||||
|
||||
|
||||
class ChangePasswordTests(AuthTestCase):
|
||||
def test_change_password_clears_flag_and_updates_hash(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
error = auth.change_password(self.connection, user_id, "Init1234", "NewPass99")
|
||||
self.assertIsNone(error)
|
||||
user = self.connection.execute(
|
||||
"SELECT password_hash, must_change_password FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(0, user["must_change_password"])
|
||||
self.assertTrue(auth.verify_password("NewPass99", user["password_hash"]))
|
||||
self.assertFalse(auth.verify_password("Init1234", user["password_hash"]))
|
||||
entry = self.connection.execute(
|
||||
"SELECT detail FROM audit_log WHERE action = 'password_change'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertNotIn("NewPass99", entry["detail"] or "")
|
||||
|
||||
def test_wrong_old_password_rejected(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
error = auth.change_password(self.connection, user_id, "Wrong999", "NewPass99")
|
||||
self.assertIsNotNone(error)
|
||||
user = self.connection.execute(
|
||||
"SELECT must_change_password FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(1, user["must_change_password"])
|
||||
|
||||
def test_policy_violation_rejected(self) -> None:
|
||||
user_id = self.create_company_user()
|
||||
error = auth.change_password(self.connection, user_id, "Init1234", "short")
|
||||
self.assertIsNotNone(error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Tests for dynamic master data (companies, bank accounts, aliases).
|
||||
|
||||
Unit tests exercise normalization, masking, effective-window boundaries and
|
||||
alias-match priority against an in-memory database; the HTTP integration
|
||||
class drives a live server to cover the submission -> review -> disable
|
||||
workflow, uniqueness under concurrency, masking boundaries and RBAC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import auth, master_data
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "Cashier123"
|
||||
|
||||
|
||||
class MasterDataUnitTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
migrate(self.connection)
|
||||
now = master_data.utc_now()
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.company_id = int(cursor.lastrowid)
|
||||
self.connection.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def _active_account(self, number: str = "1234567890123") -> object:
|
||||
return master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=self.company_id,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number=number,
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
|
||||
def test_normalize_account_number(self) -> None:
|
||||
self.assertEqual("1234567890", master_data.normalize_account_number("1234 5678-90"))
|
||||
self.assertEqual("1234567890", master_data.normalize_account_number("1234567890"))
|
||||
for invalid in ("", "123", "abc123456", "12345678901234567890123456789012345"):
|
||||
with self.assertRaises(ValueError):
|
||||
master_data.normalize_account_number(invalid)
|
||||
|
||||
def test_mask_account_number(self) -> None:
|
||||
self.assertEqual("****9012", master_data.mask_account_number("123456789012"))
|
||||
self.assertEqual("****123", master_data.mask_account_number("123"))
|
||||
|
||||
def test_is_usable_window_boundaries(self) -> None:
|
||||
account = self._active_account()
|
||||
account = master_data.review_bank_account(
|
||||
self.connection, account["id"], "approve", None, self._admin(),
|
||||
effective_from="2026-02-01",
|
||||
)
|
||||
self.assertFalse(master_data.is_usable(account, "2026-01-31"))
|
||||
self.assertTrue(master_data.is_usable(account, "2026-02-01"))
|
||||
# A scheduled effective_to bounds the usable window (inclusive).
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE bank_accounts SET effective_to = '2026-06-30' WHERE id = ?",
|
||||
(account["id"],),
|
||||
)
|
||||
account = master_data.get_account(self.connection, account["id"])
|
||||
self.assertTrue(master_data.is_usable(account, "2026-06-30"))
|
||||
self.assertFalse(master_data.is_usable(account, "2026-07-01"))
|
||||
# Disabled accounts never accept new uploads, even inside the window.
|
||||
account = master_data.review_bank_account(
|
||||
self.connection, account["id"], "disable", "账户销户", self._admin(),
|
||||
effective_to="2026-06-30",
|
||||
)
|
||||
self.assertFalse(master_data.is_usable(account, "2026-06-30"))
|
||||
# …but they still identify historical rows inside the window.
|
||||
self.assertTrue(master_data.is_identifiable(account, "2026-06-30"))
|
||||
self.assertFalse(master_data.is_identifiable(account, "2026-07-01"))
|
||||
|
||||
def _admin(self):
|
||||
user = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'admin-u'"
|
||||
).fetchone()
|
||||
if user is None:
|
||||
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
|
||||
user = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'admin-u'"
|
||||
).fetchone()
|
||||
return user
|
||||
|
||||
def test_pending_and_returned_accounts_never_usable(self) -> None:
|
||||
account = master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=self.company_id,
|
||||
bank_name="中信银行",
|
||||
account_type="一般户",
|
||||
account_number="9988776655",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
self.assertFalse(master_data.is_usable(account, "2026-06-01"))
|
||||
account = master_data.review_bank_account(
|
||||
self.connection, account["id"], "return", "资料不全", self._admin()
|
||||
)
|
||||
self.assertEqual("returned", account["status"])
|
||||
self.assertFalse(master_data.is_usable(account, "2026-06-01"))
|
||||
|
||||
def test_alias_match_priority_and_window(self) -> None:
|
||||
exact_account = self._active_account("1111222233334")
|
||||
exact_account = master_data.review_bank_account(
|
||||
self.connection, exact_account["id"], "approve", None, self._admin(),
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
alias_account = master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=self.company_id,
|
||||
bank_name="建设银行",
|
||||
account_type="一般户",
|
||||
account_number="5555666677778",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
alias_account = master_data.review_bank_account(
|
||||
self.connection, alias_account["id"], "approve", None, self._admin(),
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
master_data.add_alias(
|
||||
self.connection, alias_account["id"], "account", "9999000011112",
|
||||
priority=5, actor=None,
|
||||
)
|
||||
master_data.add_alias(
|
||||
self.connection, alias_account["id"], "name", "甲公司郑州分部",
|
||||
effective_from="2026-03-01", effective_to="2026-03-31", actor=None,
|
||||
)
|
||||
|
||||
# Exact account number beats the account alias tier.
|
||||
master_data.add_alias(
|
||||
self.connection, alias_account["id"], "account", "1111222233334",
|
||||
priority=1, actor=None,
|
||||
)
|
||||
hits = master_data.match_account(
|
||||
self.connection, account_number="1111-2222 3333 4", on_date="2026-06-01"
|
||||
)
|
||||
self.assertEqual(exact_account["id"], hits[0]["bank_account_id"])
|
||||
self.assertEqual("account_exact", hits[0]["via"])
|
||||
self.assertEqual(alias_account["id"], hits[1]["bank_account_id"])
|
||||
self.assertEqual("account_alias", hits[1]["via"])
|
||||
|
||||
# Name alias only matches inside its effective window.
|
||||
hits = master_data.match_account(
|
||||
self.connection, name="甲公司郑州分部 ", on_date="2026-03-15"
|
||||
)
|
||||
self.assertEqual(alias_account["id"], hits[0]["bank_account_id"])
|
||||
self.assertEqual("name_alias", hits[0]["via"])
|
||||
self.assertEqual(
|
||||
[],
|
||||
master_data.match_account(
|
||||
self.connection, name="甲公司郑州分部", on_date="2026-04-01"
|
||||
),
|
||||
)
|
||||
|
||||
def test_match_never_returns_pending_or_disabled(self) -> None:
|
||||
pending = master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=self.company_id,
|
||||
bank_name="郑州银行",
|
||||
account_type="一般户",
|
||||
account_number="7777888899990",
|
||||
actor=None,
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
master_data.match_account(self.connection, account_number="7777888899990"),
|
||||
)
|
||||
account = master_data.review_bank_account(
|
||||
self.connection, pending["id"], "approve", None, self._admin(),
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
self.assertTrue(
|
||||
master_data.match_account(self.connection, account_number="7777888899990")
|
||||
)
|
||||
master_data.review_bank_account(
|
||||
self.connection, account["id"], "disable", "销户", self._admin(),
|
||||
effective_to="2026-06-30",
|
||||
)
|
||||
# Disabled accounts still resolve dates inside their effective window…
|
||||
self.assertTrue(
|
||||
master_data.match_account(
|
||||
self.connection, account_number="7777888899990", on_date="2026-06-30"
|
||||
)
|
||||
)
|
||||
# …and never resolve dates after it.
|
||||
self.assertEqual(
|
||||
[],
|
||||
master_data.match_account(
|
||||
self.connection, account_number="7777888899990", on_date="2026-07-01"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MasterDataApiTests(unittest.TestCase):
|
||||
"""Live-server workflow tests for account registration and review."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
import os
|
||||
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
server.ensure_bootstrap_admin(connection)
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
# Company A created together with its cashier login in one call.
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/companies",
|
||||
{"name": "甲公司", "credit_code": "91410100TEST", "cashier_name": "牛女士",
|
||||
"username": "cashier-a"},
|
||||
)
|
||||
assert status == 200, data
|
||||
payload = as_json(data)
|
||||
cls.company_a = payload["company_id"]
|
||||
cls.initial_a = payload["initial_password"]
|
||||
assert cls.initial_a != "cashier-a"
|
||||
assert len(cls.initial_a) >= 12
|
||||
|
||||
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
|
||||
assert status == 200, data
|
||||
cls.company_b = as_json(data)["company_id"]
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/users", {"username": "cashier-b", "company_id": cls.company_b}
|
||||
)
|
||||
assert status == 200, data
|
||||
cls.initial_b = as_json(data)["initial_password"]
|
||||
assert cls.initial_b != "cashier-b"
|
||||
|
||||
cls.cashier_a = cls._login_company_user("cashier-a", cls.initial_a)
|
||||
cls.cashier_b = cls._login_company_user("cashier-b", cls.initial_b)
|
||||
|
||||
@classmethod
|
||||
def _login_company_user(cls, username: str, initial: str) -> Client:
|
||||
client = Client("127.0.0.1", cls.port)
|
||||
status, _, data = client.post_json(
|
||||
"/api/login", {"username": username, "password": initial, "portal": "company"}
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = client.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": CASHIER_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
import os
|
||||
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def submit_account(self, client: Client, number: str, bank: str = "郑州银行"):
|
||||
return client.post_json(
|
||||
"/api/company/accounts",
|
||||
{"bank_name": bank, "account_type": "一般户",
|
||||
"account_number": number, "start_date": "2026-08-01"},
|
||||
)
|
||||
|
||||
def review(self, account_id: int, decision: str, reason: str = ""):
|
||||
return self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": decision, "reason": reason},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Company management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_new_company_appears_in_admin_list_with_user(self) -> None:
|
||||
status, _, data = self.admin.get("/api/admin/companies")
|
||||
self.assertEqual(200, status)
|
||||
companies = {c["name"]: c for c in as_json(data)["companies"]}
|
||||
self.assertIn("甲公司", companies)
|
||||
self.assertEqual("91410100TEST", companies["甲公司"]["credit_code"])
|
||||
self.assertEqual("牛女士", companies["甲公司"]["cashier_name"])
|
||||
self.assertIn("cashier-a", companies["甲公司"]["usernames"])
|
||||
self.assertEqual(0, companies["乙公司"]["account_count"])
|
||||
|
||||
def test_company_user_cannot_change_own_company_binding(self) -> None:
|
||||
# A company_id in the submission body is ignored: the account is
|
||||
# always bound to the session company.
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/accounts",
|
||||
{"bank_name": "民生银行", "account_type": "一般户",
|
||||
"account_number": "6001000100010001", "company_id": self.company_b},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual(self.company_a, as_json(data)["account"]["company_id"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Account registration -> review workflow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_full_registration_workflow(self) -> None:
|
||||
status, _, data = self.submit_account(self.cashier_a, "6222 0210-0100 8888")
|
||||
self.assertEqual(200, status, data)
|
||||
account = as_json(data)["account"]
|
||||
account_id = account["id"]
|
||||
self.assertEqual("pending", account["status"])
|
||||
# Company view is masked only.
|
||||
self.assertEqual("****8888", account["account_number_masked"])
|
||||
self.assertNotIn("account_number", account)
|
||||
self.assertFalse(account.get("usable", True))
|
||||
|
||||
# Admin audit view shows the full normalized number.
|
||||
status, _, data = self.admin.get("/api/admin/accounts")
|
||||
self.assertEqual(200, status)
|
||||
full = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
|
||||
self.assertEqual("62220210010088 88".replace(" ", ""), full["account_number"])
|
||||
self.assertEqual("甲公司", full["company_name"])
|
||||
|
||||
# Pending account cannot upload.
|
||||
sample = Path("流水模板/中信银行账户流水.xlsx")
|
||||
status, _, data = self.cashier_a.post_multipart(
|
||||
"/api/parse", {"bank_account_id": str(account_id)},
|
||||
sample.name, sample.read_bytes(),
|
||||
)
|
||||
self.assertEqual(409, status, data)
|
||||
|
||||
# Approve -> active and usable.
|
||||
status, _, data = self.review(account_id, "approve")
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("active", as_json(data)["account"]["status"])
|
||||
self.assertEqual("2026-08-01", as_json(data)["account"]["effective_from"])
|
||||
|
||||
status, _, data = self.cashier_a.get("/api/company/accounts")
|
||||
mine = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
|
||||
self.assertTrue(mine["usable"])
|
||||
|
||||
# Approved account accepts uploads.
|
||||
status, _, data = self.cashier_a.post_multipart(
|
||||
"/api/parse", {"bank_account_id": str(account_id)},
|
||||
sample.name, sample.read_bytes(),
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
# Disable -> leaves the usable window.
|
||||
status, _, data = self.review(account_id, "disable", "账户销户")
|
||||
self.assertEqual(200, status, data)
|
||||
disabled = as_json(data)["account"]
|
||||
self.assertEqual("disabled", disabled["status"])
|
||||
self.assertIsNotNone(disabled["effective_to"])
|
||||
|
||||
status, _, data = self.cashier_a.get("/api/company/accounts")
|
||||
mine = [a for a in as_json(data)["accounts"] if a["id"] == account_id][0]
|
||||
self.assertFalse(mine["usable"])
|
||||
|
||||
def test_return_and_resubmit_reuses_same_row(self) -> None:
|
||||
status, _, data = self.submit_account(self.cashier_a, "3100998877665")
|
||||
self.assertEqual(200, status, data)
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
|
||||
status, _, data = self.review(account_id, "return", "开户许可证模糊")
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("returned", as_json(data)["account"]["status"])
|
||||
|
||||
# Return requires a reason.
|
||||
status, _, data = self.submit_account(self.cashier_a, "3100998877666")
|
||||
other_id = as_json(data)["account"]["id"]
|
||||
status, _, _ = self.review(other_id, "return")
|
||||
self.assertEqual(400, status)
|
||||
|
||||
# Resubmission reopens the same row as pending.
|
||||
status, _, data = self.submit_account(self.cashier_a, "3100 9988-7766 5")
|
||||
self.assertEqual(200, status, data)
|
||||
resubmitted = as_json(data)["account"]
|
||||
self.assertEqual(account_id, resubmitted["id"])
|
||||
self.assertEqual("pending", resubmitted["status"])
|
||||
|
||||
# The whole trail is auditable with actor, time and reason.
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/master-changes?entity_type=bank_account&entity_id={account_id}"
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
changes = as_json(data)["changes"]
|
||||
actions = [change["action"] for change in changes]
|
||||
self.assertEqual(["resubmit", "return", "submit"], actions)
|
||||
returned = [c for c in changes if c["action"] == "return"][0]
|
||||
self.assertEqual("开户许可证模糊", returned["reason"])
|
||||
self.assertEqual("group-admin", returned["actor_username"])
|
||||
self.assertIn('"pending"', returned["before_json"])
|
||||
self.assertIn('"returned"', returned["after_json"])
|
||||
|
||||
def test_duplicate_normalized_number_conflicts(self) -> None:
|
||||
status, _, _ = self.submit_account(self.cashier_a, "4501111222233")
|
||||
self.assertEqual(200, status)
|
||||
# Same company, same number with different separators.
|
||||
status, _, data = self.submit_account(self.cashier_a, "4501-1112 2223-3")
|
||||
self.assertEqual(409, status, data)
|
||||
# Another company registering the same number conflicts too.
|
||||
status, _, data = self.submit_account(self.cashier_b, "4501111222233")
|
||||
self.assertEqual(409, status, data)
|
||||
self.assertIn("其他公司", as_json(data)["message"])
|
||||
|
||||
def test_concurrent_submissions_create_only_one_account(self) -> None:
|
||||
number = "8800123456789"
|
||||
results: list[int] = []
|
||||
|
||||
def submit() -> None:
|
||||
client = Client("127.0.0.1", self.port)
|
||||
client.cookies.update(self.cashier_a.cookies)
|
||||
status, _, _ = client.post_json(
|
||||
"/api/company/accounts",
|
||||
{"bank_name": "中信银行", "account_type": "基本户",
|
||||
"account_number": number},
|
||||
)
|
||||
results.append(status)
|
||||
|
||||
threads = [threading.Thread(target=submit) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(sorted(results), [200, 409])
|
||||
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM bank_accounts WHERE account_number = ?",
|
||||
(number,),
|
||||
).fetchone()["n"]
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(1, count)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RBAC and masking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_company_user_forbidden_on_admin_account_endpoints(self) -> None:
|
||||
for call in (
|
||||
lambda: self.cashier_a.get("/api/admin/accounts"),
|
||||
lambda: self.cashier_a.post_json(
|
||||
"/api/admin/accounts/1/review", {"decision": "approve"}
|
||||
),
|
||||
lambda: self.cashier_a.get("/api/admin/accounts/1/aliases"),
|
||||
lambda: self.cashier_a.post_json(
|
||||
"/api/admin/accounts/1/aliases",
|
||||
{"alias_kind": "name", "alias_value": "x"},
|
||||
),
|
||||
lambda: self.cashier_a.get("/api/admin/master-changes"),
|
||||
):
|
||||
status, _, data = call()
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
def test_admin_cannot_use_company_account_endpoint(self) -> None:
|
||||
status, _, _ = self.admin.get("/api/company/accounts")
|
||||
self.assertEqual(403, status)
|
||||
status, _, _ = self.submit_account(self.admin, "1000200030004")
|
||||
self.assertEqual(403, status)
|
||||
|
||||
def test_unauthenticated_master_data_calls_return_401(self) -> None:
|
||||
anon = Client("127.0.0.1", self.port)
|
||||
for call in (
|
||||
lambda: anon.get("/api/company/accounts"),
|
||||
lambda: anon.get("/api/admin/accounts"),
|
||||
lambda: anon.post_json("/api/company/accounts", {}),
|
||||
lambda: anon.get("/api/admin/master-changes"),
|
||||
):
|
||||
status, _, _ = call()
|
||||
self.assertEqual(401, status)
|
||||
|
||||
def test_company_account_list_scoped_and_masked(self) -> None:
|
||||
status, _, data = self.cashier_b.get("/api/company/accounts")
|
||||
self.assertEqual(200, status)
|
||||
for account in as_json(data)["accounts"]:
|
||||
self.assertEqual(self.company_b, account["company_id"])
|
||||
self.assertNotIn("account_number", account)
|
||||
self.assertTrue(account["account_number_masked"].startswith("****"))
|
||||
|
||||
def test_upload_with_other_companys_account_returns_404(self) -> None:
|
||||
status, _, data = self.submit_account(self.cashier_a, "5550001112223")
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
status, _, _ = self.review(account_id, "approve")
|
||||
self.assertEqual(200, status)
|
||||
sample = Path("流水模板/中信银行账户流水.xlsx")
|
||||
status, _, data = self.cashier_b.post_multipart(
|
||||
"/api/parse", {"bank_account_id": str(account_id)},
|
||||
sample.name, sample.read_bytes(),
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aliases via API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_alias_crud_and_audit(self) -> None:
|
||||
status, _, data = self.submit_account(self.cashier_a, "6601234509876")
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
status, _, _ = self.review(account_id, "approve")
|
||||
self.assertEqual(200, status)
|
||||
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/aliases",
|
||||
{"alias_kind": "name", "alias_value": "甲公司工会",
|
||||
"effective_from": "2026-01-01", "effective_to": "2026-12-31",
|
||||
"priority": 10},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
alias_id = as_json(data)["alias_id"]
|
||||
|
||||
# Duplicate alias on the same account conflicts.
|
||||
status, _, _ = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/aliases",
|
||||
{"alias_kind": "name", "alias_value": "甲公司工会"},
|
||||
)
|
||||
self.assertEqual(409, status)
|
||||
|
||||
status, _, data = self.admin.get(f"/api/admin/accounts/{account_id}/aliases")
|
||||
self.assertEqual(200, status)
|
||||
aliases = as_json(data)["aliases"]
|
||||
self.assertEqual(1, len(aliases))
|
||||
self.assertEqual("甲公司工会", aliases[0]["alias_value"])
|
||||
self.assertEqual(10, aliases[0]["priority"])
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/master-changes?entity_type=account_alias&entity_id={alias_id}"
|
||||
)
|
||||
changes = as_json(data)["changes"]
|
||||
self.assertEqual("create", changes[0]["action"])
|
||||
self.assertIn("2026-12-31", changes[0]["after_json"])
|
||||
|
||||
# Invalid alias input is rejected, unknown account is 404.
|
||||
status, _, _ = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/aliases",
|
||||
{"alias_kind": "account", "alias_value": "abc"},
|
||||
)
|
||||
self.assertEqual(400, status)
|
||||
status, _, _ = self.admin.post_json(
|
||||
"/api/admin/accounts/99999/aliases",
|
||||
{"alias_kind": "name", "alias_value": "x"},
|
||||
)
|
||||
self.assertEqual(404, status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from bank_importer.db import applied_versions, connect, migrate, rollback, utc_now
|
||||
from bank_importer.importing import import_statement
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SAMPLES = ROOT / "流水模板"
|
||||
SAMPLE_FILE = SAMPLES / "中国建设银行账户流水.xls"
|
||||
|
||||
|
||||
class PersistenceTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.db_path = root / "app.db"
|
||||
self.storage = root / "files"
|
||||
self.connection = connect(self.db_path)
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
|
||||
def import_sample(self) -> object:
|
||||
return import_statement(
|
||||
self.connection,
|
||||
self.storage,
|
||||
SAMPLE_FILE.name,
|
||||
SAMPLE_FILE.read_bytes(),
|
||||
)
|
||||
|
||||
|
||||
class MigrationTests(PersistenceTestCase):
|
||||
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
|
||||
first = applied_versions(self.connection)
|
||||
self.assertEqual([1, 2, 3], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
row["name"]
|
||||
for row in self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
for table in (
|
||||
"companies",
|
||||
"bank_accounts",
|
||||
"account_aliases",
|
||||
"master_data_changes",
|
||||
"source_files",
|
||||
"import_batches",
|
||||
"sheet_batches",
|
||||
"source_rows",
|
||||
"import_exceptions",
|
||||
"users",
|
||||
"sessions",
|
||||
"login_attempts",
|
||||
"audit_log",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([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], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3], applied_versions(self.connection))
|
||||
|
||||
|
||||
class ImportPersistenceTests(PersistenceTestCase):
|
||||
def test_import_persists_batch_sheets_and_rows(self) -> None:
|
||||
result = self.import_sample()
|
||||
self.assertEqual("parsed", result.status)
|
||||
|
||||
batch = self.connection.execute(
|
||||
"SELECT status FROM import_batches WHERE id = ?", (result.batch_id,)
|
||||
).fetchone()
|
||||
self.assertEqual("parsed", batch["status"])
|
||||
|
||||
sheets = self.connection.execute(
|
||||
"SELECT * FROM sheet_batches WHERE import_batch_id = ?", (result.batch_id,)
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(sheets))
|
||||
self.assertEqual("中国建设银行", sheets[0]["bank_name"])
|
||||
self.assertEqual("ccb-account-detail-v1", sheets[0]["template_id"])
|
||||
self.assertEqual(1, sheets[0]["template_version"])
|
||||
|
||||
rows = self.connection.execute(
|
||||
"SELECT * FROM source_rows WHERE sheet_batch_id = ?", (sheets[0]["id"],)
|
||||
).fetchall()
|
||||
self.assertEqual(sheets[0]["transaction_count"], len(rows))
|
||||
|
||||
def test_amounts_keep_decimal_precision(self) -> None:
|
||||
result = self.import_sample()
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT income, expense FROM source_rows
|
||||
WHERE sheet_batch_id IN (
|
||||
SELECT id FROM sheet_batches WHERE import_batch_id = ?
|
||||
)
|
||||
""",
|
||||
(result.batch_id,),
|
||||
).fetchall()
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertEqual(row["income"], str(Decimal(row["income"])))
|
||||
self.assertEqual(row["expense"], str(Decimal(row["expense"])))
|
||||
|
||||
def test_every_row_traces_back_to_file_sheet_and_template(self) -> None:
|
||||
result = self.import_sample()
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT r.source_row, s.sheet_name, s.template_id, s.template_version, f.sha256
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
JOIN source_files f ON f.id = b.source_file_id
|
||||
WHERE b.id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(result.batch_id,),
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(SAMPLE_FILE.read_bytes()).hexdigest(), row["sha256"]
|
||||
)
|
||||
self.assertEqual("ccb-account-detail-v1", row["template_id"])
|
||||
self.assertEqual(1, row["template_version"])
|
||||
self.assertGreater(row["source_row"], 0)
|
||||
self.assertTrue(row["sheet_name"])
|
||||
|
||||
def test_source_file_is_stored_immutably_by_content_hash(self) -> None:
|
||||
self.import_sample()
|
||||
stored = self.connection.execute(
|
||||
"SELECT storage_path FROM source_files"
|
||||
).fetchone()
|
||||
stored_path = Path(stored["storage_path"])
|
||||
self.assertTrue(stored_path.is_file())
|
||||
self.assertEqual(SAMPLE_FILE.read_bytes(), stored_path.read_bytes())
|
||||
self.assertIn(hashlib.sha256(SAMPLE_FILE.read_bytes()).hexdigest(), stored_path.name)
|
||||
|
||||
def test_data_survives_reconnect(self) -> None:
|
||||
result = self.import_sample()
|
||||
self.connection.close()
|
||||
|
||||
reopened = connect(self.db_path)
|
||||
self.addCleanup(reopened.close)
|
||||
batch = reopened.execute(
|
||||
"SELECT status FROM import_batches WHERE id = ?", (result.batch_id,)
|
||||
).fetchone()
|
||||
self.assertEqual("parsed", batch["status"])
|
||||
files = reopened.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
|
||||
self.assertEqual(1, files["n"])
|
||||
rows = reopened.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()
|
||||
self.assertGreater(rows["n"], 0)
|
||||
|
||||
|
||||
class IdempotencyTests(PersistenceTestCase):
|
||||
def test_duplicate_upload_creates_no_second_facts(self) -> None:
|
||||
first = self.import_sample()
|
||||
second = self.import_sample()
|
||||
|
||||
self.assertEqual("parsed", first.status)
|
||||
self.assertEqual("duplicate", second.status)
|
||||
self.assertEqual(first.batch_id, second.batch_id)
|
||||
self.assertTrue(second.duplicate_same_company)
|
||||
|
||||
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
|
||||
self.assertEqual(1, files["n"])
|
||||
sheets = self.connection.execute("SELECT COUNT(*) AS n FROM sheet_batches").fetchone()
|
||||
self.assertEqual(1, sheets["n"])
|
||||
rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()
|
||||
self.assertEqual(first.batches[0].transactions.__len__(), rows["n"])
|
||||
|
||||
duplicate = self.connection.execute(
|
||||
"SELECT status, duplicate_of_id FROM import_batches WHERE status = 'duplicate'"
|
||||
).fetchone()
|
||||
self.assertEqual(first.batch_id, duplicate["duplicate_of_id"])
|
||||
|
||||
def test_cross_company_duplicate_never_returns_other_companys_batch(self) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id").fetchall()
|
||||
]
|
||||
|
||||
first = import_statement(
|
||||
self.connection, self.storage, SAMPLE_FILE.name,
|
||||
SAMPLE_FILE.read_bytes(), company_id=company_a,
|
||||
)
|
||||
self.assertEqual("parsed", first.status)
|
||||
|
||||
second = import_statement(
|
||||
self.connection, self.storage, SAMPLE_FILE.name,
|
||||
SAMPLE_FILE.read_bytes(), company_id=company_b,
|
||||
)
|
||||
self.assertEqual("duplicate", second.status)
|
||||
self.assertFalse(second.duplicate_same_company)
|
||||
# The returned batch id is the uploader's own duplicate batch, never
|
||||
# the other company's original batch.
|
||||
self.assertNotEqual(first.batch_id, second.batch_id)
|
||||
self.assertNotEqual(second.batch_id, first.batch_id)
|
||||
own = self.connection.execute(
|
||||
"SELECT company_id FROM import_batches WHERE id = ?", (second.batch_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(company_b, own["company_id"])
|
||||
|
||||
def test_repeated_upload_under_a_different_filename_is_still_duplicate(self) -> None:
|
||||
first = self.import_sample()
|
||||
second = import_statement(
|
||||
self.connection,
|
||||
self.storage,
|
||||
"改名后的流水.xls",
|
||||
SAMPLE_FILE.read_bytes(),
|
||||
)
|
||||
self.assertEqual("duplicate", second.status)
|
||||
self.assertEqual(first.batch_id, second.batch_id)
|
||||
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
|
||||
self.assertEqual(1, files["n"])
|
||||
|
||||
def test_immutability_triggers_block_updates_and_deletes(self) -> None:
|
||||
self.import_sample()
|
||||
for statement in (
|
||||
"UPDATE source_rows SET income = '0'",
|
||||
"DELETE FROM source_rows",
|
||||
"UPDATE source_files SET sha256 = 'x'",
|
||||
"DELETE FROM source_files",
|
||||
"UPDATE sheet_batches SET bank_name = 'x'",
|
||||
"DELETE FROM sheet_batches",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_row_level_unique_constraint_blocks_duplicate_rows(self) -> None:
|
||||
self.import_sample()
|
||||
row = self.connection.execute(
|
||||
"SELECT sheet_batch_id, source_row FROM source_rows LIMIT 1"
|
||||
).fetchone()
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense, created_at
|
||||
) VALUES (?, ?, '2026-01-01T00:00:00', '1', '0', '2026-01-01T00:00:00Z')
|
||||
""",
|
||||
(row["sheet_batch_id"], row["source_row"]),
|
||||
)
|
||||
self.connection.rollback()
|
||||
|
||||
|
||||
class FailedImportTests(PersistenceTestCase):
|
||||
def _unknown_template_bytes(self) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "流水"
|
||||
sheet.append(["日期", "金额", "备注"])
|
||||
sheet.append(["2026-01-01", "100.00", "测试"])
|
||||
target = Path(self.temp_dir.name) / "unknown.xlsx"
|
||||
workbook.save(target)
|
||||
return target.read_bytes()
|
||||
|
||||
def test_parse_failure_keeps_exception_batch_without_rows(self) -> None:
|
||||
result = import_statement(
|
||||
self.connection, self.storage, "未知银行.xlsx", self._unknown_template_bytes()
|
||||
)
|
||||
self.assertEqual("exception", result.status)
|
||||
self.assertIsNotNone(result.message)
|
||||
self.assertIn("未知银行.xlsx", result.message)
|
||||
self.assertNotIn(result.sha256, result.message)
|
||||
|
||||
batch = self.connection.execute(
|
||||
"SELECT status FROM import_batches WHERE id = ?", (result.batch_id,)
|
||||
).fetchone()
|
||||
self.assertEqual("exception", batch["status"])
|
||||
|
||||
exceptions = self.connection.execute(
|
||||
"SELECT * FROM import_exceptions WHERE import_batch_id = ?",
|
||||
(result.batch_id,),
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(exceptions))
|
||||
self.assertEqual("parse", exceptions[0]["stage"])
|
||||
|
||||
rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()
|
||||
self.assertEqual(0, rows["n"])
|
||||
|
||||
# The source file is still preserved as evidence for later diagnosis.
|
||||
files = self.connection.execute("SELECT COUNT(*) AS n FROM source_files").fetchone()
|
||||
self.assertEqual(1, files["n"])
|
||||
|
||||
def test_reupload_after_failure_is_tracked_as_duplicate(self) -> None:
|
||||
content = self._unknown_template_bytes()
|
||||
first = import_statement(self.connection, self.storage, "未知银行.xlsx", content)
|
||||
second = import_statement(self.connection, self.storage, "未知银行.xlsx", content)
|
||||
self.assertEqual("exception", first.status)
|
||||
self.assertEqual("duplicate", second.status)
|
||||
self.assertEqual(first.batch_id, second.batch_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,17 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
import server
|
||||
|
||||
|
||||
class ServerConfigurationTests(TestCase):
|
||||
@patch.dict(os.environ, {"APP_HOST": "0.0.0.0", "APP_PORT": "8080"})
|
||||
@patch("server.ThreadingHTTPServer")
|
||||
def test_main_uses_configured_host_and_port(self, http_server) -> None:
|
||||
server.main()
|
||||
|
||||
http_server.assert_called_once_with(("0.0.0.0", 8080), server.AppHandler)
|
||||
http_server.return_value.serve_forever.assert_called_once_with()
|
||||
@@ -0,0 +1,543 @@
|
||||
"""HTTP integration tests for authentication, RBAC and tenant isolation.
|
||||
|
||||
Spins up a real ``ThreadingHTTPServer`` with a temp database/storage and
|
||||
drives it with stdlib ``http.client`` (cookies handled by hand). The server
|
||||
module reads ``APP_DB_PATH`` / ``APP_STORAGE_DIR`` from module globals at
|
||||
request time, so tests patch them per class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
import server
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SAMPLES = ROOT / "流水模板"
|
||||
CCB_SAMPLE = SAMPLES / "中国建设银行账户流水.xls"
|
||||
CITIC_SAMPLE = SAMPLES / "中信银行账户流水.xlsx"
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_A_PASSWORD = "CashierA123"
|
||||
|
||||
|
||||
class Client:
|
||||
"""Minimal HTTP client with a cookie jar."""
|
||||
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.cookies: dict[str, str] = {}
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[int, dict[str, str], bytes]:
|
||||
connection = HTTPConnection(self.host, self.port)
|
||||
request_headers = dict(headers or {})
|
||||
if self.cookies:
|
||||
request_headers["Cookie"] = "; ".join(
|
||||
f"{key}={value}" for key, value in self.cookies.items()
|
||||
)
|
||||
connection.request(method, path, body=body, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
response_headers = {key.lower(): value for key, value in response.getheaders()}
|
||||
set_cookie = response_headers.get("set-cookie")
|
||||
if set_cookie:
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(set_cookie)
|
||||
for key, morsel in cookie.items():
|
||||
if morsel.value:
|
||||
self.cookies[key] = morsel.value
|
||||
else:
|
||||
self.cookies.pop(key, None)
|
||||
status = response.status
|
||||
connection.close()
|
||||
return status, response_headers, data
|
||||
|
||||
def get(self, path: str) -> tuple[int, dict[str, str], bytes]:
|
||||
return self.request("GET", path)
|
||||
|
||||
def post_json(self, path: str, payload: dict) -> tuple[int, dict, bytes]:
|
||||
return self.request(
|
||||
"POST",
|
||||
path,
|
||||
body=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def post_multipart(
|
||||
self, path: str, fields: dict[str, str], filename: str, content: bytes
|
||||
) -> tuple[int, dict, bytes]:
|
||||
boundary = "----cwtestboundary7f3a9c1e"
|
||||
parts: list[bytes] = []
|
||||
for name, value in fields.items():
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
|
||||
)
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
||||
"Content-Type: application/octet-stream\r\n\r\n".encode()
|
||||
+ content
|
||||
+ b"\r\n"
|
||||
)
|
||||
parts.append(f"--{boundary}--\r\n".encode())
|
||||
return self.request(
|
||||
"POST",
|
||||
path,
|
||||
body=b"".join(parts),
|
||||
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||
)
|
||||
|
||||
|
||||
def as_json(data: bytes) -> dict:
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
class ServerAuthMatrixTests(unittest.TestCase):
|
||||
"""One live server; setUpClass builds the shared fixture via the API."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
generated = server.ensure_bootstrap_admin(connection)
|
||||
assert generated is None, "env password set, nothing should be generated"
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None: # silence per-request logs
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.known_passwords = {BOOTSTRAP_PASSWORD, ADMIN_PASSWORD, CASHIER_A_PASSWORD}
|
||||
|
||||
# --- Admin bootstrap: must_change_password gate, then change. ---
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
assert as_json(data)["must_change_password"] is True
|
||||
status, _, data = cls.admin.get("/api/batches")
|
||||
assert status == 403, "must_change_password must block API access"
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, _ = cls.admin.get("/api/batches")
|
||||
assert status == 200
|
||||
|
||||
# --- Companies A and B. ---
|
||||
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "甲公司"})
|
||||
assert status == 200, data
|
||||
cls.company_a = as_json(data)["company_id"]
|
||||
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
|
||||
assert status == 200, data
|
||||
cls.company_b = as_json(data)["company_id"]
|
||||
|
||||
# --- Company user A: random one-time initial password, forced change. ---
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a}
|
||||
)
|
||||
assert status == 200, data
|
||||
payload = as_json(data)
|
||||
# Security: the initial password is random and never equals the username.
|
||||
assert payload["initial_password"] != "cashier-a"
|
||||
assert len(payload["initial_password"]) >= 12
|
||||
cls.initial_password_a = payload["initial_password"]
|
||||
cls.known_passwords.add(cls.initial_password_a)
|
||||
|
||||
cls.cashier_a = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.cashier_a.post_json(
|
||||
"/api/login",
|
||||
{
|
||||
"username": "cashier-a",
|
||||
"password": cls.initial_password_a,
|
||||
"portal": "company",
|
||||
},
|
||||
)
|
||||
assert status == 200, data
|
||||
assert as_json(data)["must_change_password"] is True
|
||||
status, _, _ = cls.cashier_a.get("/api/batches")
|
||||
assert status == 403, "must_change_password must block company API access"
|
||||
status, _, data = cls.cashier_a.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": cls.initial_password_a, "new_password": CASHIER_A_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
# --- B gets a batch (admin upload), A gets its own batch. ---
|
||||
status, _, data = cls.admin.post_multipart(
|
||||
"/api/parse",
|
||||
{"company_id": str(cls.company_b)},
|
||||
CCB_SAMPLE.name,
|
||||
CCB_SAMPLE.read_bytes(),
|
||||
)
|
||||
assert status == 200, data
|
||||
cls.b_batch_id = as_json(data)["batch_id"]
|
||||
|
||||
status, _, data = cls.cashier_a.post_multipart(
|
||||
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
|
||||
)
|
||||
assert status == 200, data
|
||||
cls.a_batch_id = as_json(data)["batch_id"]
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def fresh_client(self) -> Client:
|
||||
return Client("127.0.0.1", self.port)
|
||||
|
||||
def create_company_user(self, username: str) -> tuple[Client, str, int]:
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/users", {"username": username, "company_id": self.company_a}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
initial = payload["initial_password"]
|
||||
self.known_passwords.add(initial)
|
||||
client = self.fresh_client()
|
||||
status, _, data = client.post_json(
|
||||
"/api/login",
|
||||
{"username": username, "password": initial, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
new_password = "Changed123"
|
||||
self.known_passwords.add(new_password)
|
||||
status, _, data = client.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": new_password},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
return client, new_password, payload["user_id"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Unauthenticated matrix
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_unauthenticated_api_calls_return_401(self) -> None:
|
||||
anon = self.fresh_client()
|
||||
for method_check in (
|
||||
lambda: anon.post_multipart("/api/parse", {}, "x.xls", b"data"),
|
||||
lambda: anon.get("/api/batches"),
|
||||
lambda: anon.get(f"/api/batches/{self.a_batch_id}/rows"),
|
||||
lambda: anon.get("/api/export.csv"),
|
||||
lambda: anon.get("/api/admin/users"),
|
||||
lambda: anon.get("/api/admin/companies"),
|
||||
lambda: anon.get("/api/admin/audit-log"),
|
||||
lambda: anon.get("/api/me"),
|
||||
):
|
||||
status, _, data = method_check()
|
||||
self.assertEqual(401, status, data)
|
||||
self.assertEqual("error", as_json(data)["status"])
|
||||
|
||||
def test_unauthenticated_portal_pages_redirect(self) -> None:
|
||||
anon = self.fresh_client()
|
||||
for page in ("/admin.html", "/company.html"):
|
||||
status, headers, _ = anon.get(page)
|
||||
self.assertEqual(302, status, page)
|
||||
self.assertEqual("/", headers.get("location"))
|
||||
|
||||
def test_wrong_password_returns_generic_401(self) -> None:
|
||||
anon = self.fresh_client()
|
||||
status, _, data = anon.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": "WrongPass1", "portal": "admin"},
|
||||
)
|
||||
self.assertEqual(401, status)
|
||||
message = as_json(data)["message"]
|
||||
self.assertNotIn("密码不正确", message.replace("账号或密码不正确", ""))
|
||||
|
||||
def test_rate_limit_after_five_failures(self) -> None:
|
||||
anon = self.fresh_client()
|
||||
for _ in range(5):
|
||||
status, _, _ = anon.post_json(
|
||||
"/api/login",
|
||||
{"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
|
||||
)
|
||||
self.assertEqual(401, status)
|
||||
status, _, _ = anon.post_json(
|
||||
"/api/login",
|
||||
{"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
|
||||
)
|
||||
self.assertEqual(429, status)
|
||||
|
||||
def test_portal_mismatch_returns_403(self) -> None:
|
||||
anon = self.fresh_client()
|
||||
status, _, data = anon.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": ADMIN_PASSWORD, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(403, status)
|
||||
self.assertIn("端口", as_json(data)["message"])
|
||||
|
||||
def test_logout_revokes_session(self) -> None:
|
||||
client, _, _ = self.create_company_user("cashier-logout")
|
||||
status, _, _ = client.get("/api/me")
|
||||
self.assertEqual(200, status)
|
||||
status, _, _ = client.request("POST", "/api/logout")
|
||||
self.assertEqual(200, status)
|
||||
self.assertNotIn("cw_session", client.cookies)
|
||||
status, _, _ = client.get("/api/me")
|
||||
self.assertEqual(401, status)
|
||||
|
||||
def test_expired_session_returns_401(self) -> None:
|
||||
client, _, user_id = self.create_company_user("cashier-expired")
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE sessions SET expires_at = ? WHERE user_id = ?",
|
||||
("2000-01-01T00:00:00+00:00", user_id),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
status, _, _ = client.get("/api/me")
|
||||
self.assertEqual(401, status)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin and company lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_duplicate_company_name_returns_409(self) -> None:
|
||||
status, _, _ = self.admin.post_json("/api/admin/companies", {"name": "甲公司"})
|
||||
self.assertEqual(409, status)
|
||||
|
||||
def test_me_returns_profile_without_password_material(self) -> None:
|
||||
status, _, data = self.cashier_a.get("/api/me")
|
||||
self.assertEqual(200, status)
|
||||
payload = as_json(data)
|
||||
self.assertEqual("cashier-a", payload["username"])
|
||||
self.assertEqual("company", payload["role"])
|
||||
self.assertEqual(self.company_a, payload["company_id"])
|
||||
self.assertEqual("甲公司", payload["company_name"])
|
||||
self.assertFalse(payload["must_change_password"])
|
||||
|
||||
def test_company_user_forbidden_on_all_admin_endpoints(self) -> None:
|
||||
calls = (
|
||||
lambda: self.cashier_a.get("/api/admin/companies"),
|
||||
lambda: self.cashier_a.post_json("/api/admin/companies", {"name": "丙公司"}),
|
||||
lambda: self.cashier_a.get("/api/admin/users"),
|
||||
lambda: self.cashier_a.post_json(
|
||||
"/api/admin/users", {"username": "x", "company_id": self.company_a}
|
||||
),
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/disable"),
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
|
||||
lambda: self.cashier_a.get("/api/admin/audit-log"),
|
||||
)
|
||||
for call in calls:
|
||||
status, _, data = call()
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
def test_admin_upload_requires_company_id(self) -> None:
|
||||
status, _, data = self.admin.post_multipart(
|
||||
"/api/parse", {}, CCB_SAMPLE.name, CCB_SAMPLE.read_bytes()
|
||||
)
|
||||
self.assertEqual(400, status, data)
|
||||
|
||||
def test_admin_batches_filter_by_company(self) -> None:
|
||||
status, _, data = self.admin.get(f"/api/batches?company_id={self.company_b}")
|
||||
self.assertEqual(200, status)
|
||||
batches = as_json(data)["batches"]
|
||||
self.assertTrue(batches)
|
||||
for batch in batches:
|
||||
self.assertEqual(self.company_b, batch["company_id"])
|
||||
self.assertEqual("乙公司", batch["company_name"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tenant isolation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_batches_scoped_to_own_company(self) -> None:
|
||||
status, _, data = self.cashier_a.get("/api/batches")
|
||||
self.assertEqual(200, status)
|
||||
batches = as_json(data)["batches"]
|
||||
self.assertTrue(batches)
|
||||
for batch in batches:
|
||||
self.assertEqual(self.company_a, batch["company_id"])
|
||||
self.assertNotIn(self.b_batch_id, [batch["id"] for batch in batches])
|
||||
|
||||
def test_idor_batch_rows_of_other_company_return_404(self) -> None:
|
||||
status, _, _ = self.cashier_a.get(f"/api/batches/{self.b_batch_id}/rows")
|
||||
self.assertEqual(404, status)
|
||||
status, _, data = self.cashier_a.get(f"/api/batches/{self.a_batch_id}/rows")
|
||||
self.assertEqual(200, status)
|
||||
self.assertTrue(as_json(data)["rows"])
|
||||
|
||||
def test_export_csv_forced_to_own_company(self) -> None:
|
||||
status, _, _ = self.cashier_a.get(f"/api/export.csv?company_id={self.company_b}")
|
||||
self.assertEqual(403, status)
|
||||
|
||||
status, headers, data = self.cashier_a.get("/api/export.csv")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
|
||||
text = data.decode("utf-8-sig")
|
||||
lines = [line for line in text.splitlines() if line]
|
||||
self.assertGreater(len(lines), 1)
|
||||
for line in lines[1:]:
|
||||
self.assertEqual(str(self.a_batch_id), line.split(",", 1)[0])
|
||||
|
||||
def test_cross_company_upload_is_recorded_under_own_company(self) -> None:
|
||||
# A uploads B's file bytes while claiming company B in the form; the
|
||||
# server must bind the new (duplicate) batch to A from the session.
|
||||
status, _, data = self.cashier_a.post_multipart(
|
||||
"/api/parse",
|
||||
{"company_id": str(self.company_b)},
|
||||
CCB_SAMPLE.name,
|
||||
CCB_SAMPLE.read_bytes(),
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertEqual("duplicate", payload["status"])
|
||||
# Cross-company duplicate: no original batch id, bank, template,
|
||||
# period, transaction count or diagnostics may be exposed.
|
||||
self.assertNotEqual(self.b_batch_id, payload.get("batch_id"))
|
||||
for leaked_key in ("bank", "template", "header_row", "period_start", "period_end", "transactions", "warnings"):
|
||||
self.assertNotIn(leaked_key, payload, leaked_key)
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
duplicate = connection.execute(
|
||||
"SELECT company_id FROM import_batches WHERE status = 'duplicate'"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIsNotNone(duplicate)
|
||||
self.assertEqual(self.company_a, duplicate["company_id"])
|
||||
|
||||
def test_same_company_duplicate_keeps_idempotent_summary(self) -> None:
|
||||
# A re-uploads its own file; the idempotent duplicate response keeps
|
||||
# the original batch id and the parsed summary.
|
||||
status, _, data = self.cashier_a.post_multipart(
|
||||
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertEqual("duplicate", payload["status"])
|
||||
self.assertEqual(self.a_batch_id, payload["batch_id"])
|
||||
self.assertEqual("中信银行", payload.get("bank"))
|
||||
self.assertTrue(payload.get("transactions", 0) > 0)
|
||||
self.assertIn("period_start", payload)
|
||||
self.assertIn("warnings", payload)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Disable / reset flows
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_disabled_user_session_and_login_rejected(self) -> None:
|
||||
client, password, user_id = self.create_company_user("cashier-disable")
|
||||
status, _, _ = client.get("/api/me")
|
||||
self.assertEqual(200, status)
|
||||
|
||||
status, _, data = self.admin.request("POST", f"/api/admin/users/{user_id}/disable")
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, _ = client.get("/api/me")
|
||||
self.assertEqual(401, status)
|
||||
|
||||
fresh = self.fresh_client()
|
||||
status, _, data = fresh.post_json(
|
||||
"/api/login",
|
||||
{"username": "cashier-disable", "password": password, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
def test_reset_password_returns_once_and_revokes_sessions(self) -> None:
|
||||
client, old_password, user_id = self.create_company_user("cashier-reset")
|
||||
status, _, data = self.admin.request(
|
||||
"POST", f"/api/admin/users/{user_id}/reset-password"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
new_password = as_json(data)["initial_password"]
|
||||
self.known_passwords.add(new_password)
|
||||
|
||||
status, _, _ = client.get("/api/me")
|
||||
self.assertEqual(401, status)
|
||||
|
||||
fresh = self.fresh_client()
|
||||
status, _, data = fresh.post_json(
|
||||
"/api/login",
|
||||
{"username": "cashier-reset", "password": new_password, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertTrue(as_json(data)["must_change_password"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Secrets hygiene
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_response_contains_password_hash(self) -> None:
|
||||
bodies = []
|
||||
status, _, data = self.admin.get("/api/admin/users")
|
||||
self.assertEqual(200, status)
|
||||
bodies.append(data)
|
||||
status, _, data = self.admin.get("/api/admin/audit-log?limit=100")
|
||||
self.assertEqual(200, status)
|
||||
bodies.append(data)
|
||||
status, _, data = self.cashier_a.get("/api/me")
|
||||
bodies.append(data)
|
||||
status, _, data = self.cashier_a.get("/api/batches")
|
||||
bodies.append(data)
|
||||
for body in bodies:
|
||||
self.assertNotIn("password_hash", body.decode("utf-8"))
|
||||
|
||||
def test_audit_log_contains_no_plaintext_passwords(self) -> None:
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT detail, target FROM audit_log"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
for password in self.known_passwords:
|
||||
for row in rows:
|
||||
self.assertNotIn(password, row["detail"] or "")
|
||||
self.assertNotIn(password, row["target"] or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+47
-50
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="往来台账总账管理端" />
|
||||
<title>总账管理端 · 往来台账</title>
|
||||
<meta name="description" content="金牛集团总账管理端" />
|
||||
<title>总账管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
@@ -19,7 +19,7 @@
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="brand"><span class="brand-mark">往</span><span class="brand-copy"><strong>往来台账</strong><small>总账管理端</small></span></div>
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" data-view="dashboard"><svg><use href="icons.svg#layout-dashboard"/></svg><span>管理总览</span></button>
|
||||
<button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来查询</span></button>
|
||||
@@ -36,20 +36,18 @@
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
|
||||
<div class="workspace-name"><span>集团财务中心</span><strong id="currentViewName">管理总览</strong></div>
|
||||
<span class="demo-badge">演示数据</span>
|
||||
<div class="topbar-actions">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
|
||||
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active" data-page="dashboard">
|
||||
<header class="page-heading"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div><button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button></header>
|
||||
<header class="page-heading dashboard-head"><div><h1>管理总览</h1><p>集团账务口径与高优先级事项</p></div>
|
||||
<div class="content-toolbar">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">全局检索</span><input id="globalSearch" type="search" placeholder="检索公司、账号或流水" /></label>
|
||||
<div class="toolbar-group">
|
||||
<button class="icon-button" data-view-link="reminders" aria-label="提醒管理,4 项未处理" title="提醒管理"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">4</i></button>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>截至 2026.07.31</span></button>
|
||||
<button class="button secondary" data-view-link="settings"><svg><use href="icons.svg#settings"/></svg>账期设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="总账关键指标">
|
||||
<article class="metric-card danger" data-metric-link="audit" tabindex="0" role="button" aria-label="查看 7 项待审核事项">
|
||||
@@ -81,27 +79,13 @@
|
||||
<div class="period-warning"><svg><use href="icons.svg#circle-alert"/></svg><span><strong>2 家公司存在起算缺口</strong><small>最早缺口始于 2026.01.01</small></span><button class="text-button" data-view-link="audit">去审核</button></div>
|
||||
</section>
|
||||
|
||||
<section class="panel deck-panel" aria-label="重点公司状态卡栈">
|
||||
<div class="panel-heading"><div><h2>重点公司</h2><p>集团账户状态总览 · 按风险等级排列,荧光绿为公司端第一视角</p></div><button class="text-button" data-view-link="companies">查看全部 6 家</button></div>
|
||||
<div class="stack-deck" role="list">
|
||||
<div class="deck-stage">
|
||||
<article class="deck-card" role="listitem" data-view-link="companies" tabindex="0" aria-label="C公司 全部账户连续"><span class="deck-pos">05</span><span class="deck-mark">C</span><div class="deck-name"><strong>C公司</strong><small>全部账户连续</small></div><div class="deck-meta"><strong>2 个账户</strong><small>01.01—07.31</small></div><div class="deck-foot"><span>连续 7 个月</span><em class="status success">正常</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="audit" tabindex="0" aria-label="E公司 科目待确认"><span class="deck-pos">04</span><span class="deck-mark">E</span><div class="deck-name"><strong>E公司</strong><small>科目待确认</small></div><div class="deck-meta"><strong>88.00 万元</strong><small>07.09 · 应收与其他应收</small></div><div class="deck-foot"><span>等待公司确认</span><em class="status warning">中</em></div></article>
|
||||
<article class="deck-card is-active is-danger" role="listitem" data-view-link="audit" tabindex="0" aria-label="A公司 工商银行账户断档 高风险"><span class="deck-pos">01</span><span class="deck-mark">A</span><div class="deck-name"><strong>A公司</strong><small>工商银行账户断档</small></div><div class="deck-meta"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><div class="deck-foot"><span>影响 7 月结账</span><em class="status danger">高</em></div></article>
|
||||
<article class="deck-card is-warning" role="listitem" data-view-link="audit" tabindex="0" aria-label="B公司 单边流水待匹配"><span class="deck-pos">02</span><span class="deck-mark">B</span><div class="deck-name"><strong>B公司</strong><small>单边流水待匹配</small></div><div class="deck-meta"><strong>312.00 万元</strong><small>6 笔 · B↔D</small></div><div class="deck-foot"><span>今日新增</span><em class="status warning">中</em></div></article>
|
||||
<article class="deck-card is-warning" role="listitem" data-view-link="audit" tabindex="0" aria-label="F公司 起算区间待校准"><span class="deck-pos">03</span><span class="deck-mark">F</span><div class="deck-name"><strong>F公司</strong><small>起算区间待校准</small></div><div class="deck-meta"><strong>缺口 16 天</strong><small>01.01—01.16</small></div><div class="deck-foot"><span>待公司确认</span><em class="status warning">中</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="companies" tabindex="0" aria-label="D公司 全部账户连续"><span class="deck-pos">06</span><span class="deck-mark">D</span><div class="deck-name"><strong>D公司</strong><small>全部账户连续</small></div><div class="deck-meta"><strong>1 个账户</strong><small>01.01—07.31</small></div><div class="deck-foot"><span>连续 7 个月</span><em class="status success">正常</em></div></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-dashboard-grid">
|
||||
<section class="panel priority-panel">
|
||||
<div class="panel-heading"><div><h2>优先处理</h2><p>影响当前账期可靠性的事项</p></div><button class="text-button" data-view-link="audit">查看全部 7 项</button></div>
|
||||
<div class="task-list compact-task-list">
|
||||
<button data-view-link="audit"><span class="task-level danger">高</span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-view-link="audit"><span class="task-level warning">中</span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-view-link="audit"><span class="task-level warning">中</span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-detail="gap-a"><span class="task-level danger">高</span><span><strong>A公司 · 工行账户断档</strong><small>缺少 07.01—07.21,影响 7 月结账</small></span><b>已逾期 2 天</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-detail="match-bd"><span class="task-level warning">中</span><span><strong>B公司 ↔ D公司 · 单边待匹配</strong><small>6 笔,合计 312.00 万元</small></span><b>今日新增</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
<button data-detail="calib-f"><span class="task-level warning">中</span><span><strong>F公司 · 起算区间待校准</strong><small>01.01—01.16 无银行流水覆盖</small></span><b>待公司确认</b><svg><use href="icons.svg#chevron-right"/></svg></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -117,21 +101,6 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="timeline-strip panel" aria-label="覆盖连续性时间轴">
|
||||
<div class="timeline-legend"><strong>覆盖连续性</strong><span>2026.01.01 — 2026.07.31</span><small>11 条账户 · 1 个断档 · 今日 07.24</small></div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-axis"><span>1月</span><span>2月</span><span>3月</span><span>4月</span><span>5月</span><span>6月</span><span>7月</span><span>8月</span><span>9月</span><span>10月</span><span>11月</span><span>12月</span></div>
|
||||
<div class="timeline-rows">
|
||||
<div class="timeline-row"><span>A公司 · 工行 9481</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><i class="danger" style="left:33%;right:67%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>A公司 · 中信 5316</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>A公司 · 建行 0845</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>A公司 · 农行 3650</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>B公司 · 工行 1234</span><div class="timeline-track"><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
<div class="timeline-row"><span>F公司 · 农行 3650</span><div class="timeline-track"><i class="warning" style="left:0%;right:33%"></i><i class="coverage" style="left:0%;right:33%"></i><span class="timeline-today" style="left:67%"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel company-ledger-panel">
|
||||
<div class="panel-heading"><div><h2>公司借贷目录</h2><p>统计区间 2026.01.01—2026.07.31 · 单位:万元</p></div><label class="inline-search"><svg><use href="icons.svg#search"/></svg><input data-filter-target="companyLedgers" placeholder="快速检索公司" /></label></div>
|
||||
<div class="ledger-head"><span>公司</span><span>借方合计</span><span>贷方合计</span><span>期末方向</span><span>待审核</span><span></span></div>
|
||||
@@ -147,6 +116,34 @@
|
||||
<details class="company-ledger"><summary><span class="company-name"><i>C</i><b>C公司</b><small>2 个银行账户</small></span><strong class="amount debit">1,894.00</strong><strong class="amount credit">1,052.00</strong><span><em class="status success">应收</em><b class="amount">842.00</b></span><span class="status neutral">0 笔</span><svg><use href="icons.svg#chevron-down"/></svg></summary><div class="ledger-breakdown"><section><header><h3>借方明细</h3><strong>1,894.00</strong></header><button class="subject-row" data-pair-link="C公司|E公司"><span><b>E公司</b><small>应收 · 5 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section><section><header><h3>贷方明细</h3><strong>1,052.00</strong></header><button class="subject-row" data-pair-link="C公司|A公司"><span><b>A公司</b><small>其他应付 · 3 笔</small></span><strong>1,052.00</strong><svg><use href="icons.svg#chevron-right"/></svg></button></section></div></details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel" aria-label="账期时间轴">
|
||||
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status danger">1 处断档 · 7 月结账日 08.05</span></div>
|
||||
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
|
||||
<div class="timeline-bar">
|
||||
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
|
||||
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
|
||||
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
|
||||
<i class="timeline-today" style="left:99.1%"></i>
|
||||
</div>
|
||||
<div class="timeline-ticks" aria-hidden="true">
|
||||
<span style="left:0">01.01<small>起算日</small></span>
|
||||
<span style="left:14%">2月</span>
|
||||
<span style="left:26.7%">3月</span>
|
||||
<span style="left:40.7%">4月</span>
|
||||
<span style="left:54.3%">5月</span>
|
||||
<span style="left:68.3%">6月</span>
|
||||
<span style="left:81.9%">7月</span>
|
||||
<span style="left:95.9%">8月</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="timeline-legend">
|
||||
<li><i class="dot success"></i>1—6 月已结账</li>
|
||||
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
|
||||
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
|
||||
<li><i class="dot today"></i>今天 08.08</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="pair">
|
||||
@@ -171,7 +168,7 @@
|
||||
<section class="app-view" data-page="audit">
|
||||
<header class="page-heading"><div><h1>审核中心</h1><p>统一处理核算异常、账户登记与公司手工记录</p></div></header>
|
||||
<section class="filter-bar"><div class="segmented" role="group" aria-label="审核类型"><button class="is-active" data-audit-filter="all" data-label="全部" aria-pressed="true">全部 6</button><button data-audit-filter="断档" data-label="流水断档" aria-pressed="false">流水断档 1</button><button data-audit-filter="单边" data-label="单边匹配" aria-pressed="false">单边匹配 1</button><button data-audit-filter="科目" data-label="科目待确认" aria-pressed="false">科目待确认 1</button><button data-audit-filter="起算" data-label="起算校准" aria-pressed="false">起算校准 1</button><button data-audit-filter="账户" data-label="账户登记" aria-pressed="false">账户登记 1</button><button data-audit-filter="手工" data-label="手工记录" aria-pressed="false">手工记录 1</button></div><label class="field compact"><span>公司</span><select id="auditCompany"><option>全部公司</option><option>A公司</option><option>B公司</option><option>E公司</option><option>F公司</option></select></label></section>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger">高</span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning">中</span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning">中</span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning">中</span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="账户" data-company="A公司" data-record-kind="account" data-evidence="公司提交资料、开户行、账号、账户类型与启用日期"><td><span class="task-level warning">中</span></td><td><strong>A公司 · 郑州银行 3028</strong><small>一般户 · 申请启用日 2026.08.01</small></td><td>账户登记</td><td>2026.08.01</td><td>账户识别与流水上传</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning">中</span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table audit-table"><thead><tr><th>风险</th><th>公司 / 事项</th><th>类型</th><th>涉及期间</th><th>影响</th><th>状态</th><th>操作</th></tr></thead><tbody id="auditRows"><tr data-audit-type="断档" data-company="A公司"><td><span class="task-level danger">高</span></td><td><strong>A公司 · 工商银行 9481</strong><small>上期截止 06.30,下期始于 07.22</small></td><td>流水断档</td><td>07.01—07.21</td><td>7 月结账</td><td><span class="status danger">已逾期</span></td><td><button class="text-button" data-audit-action>审核</button></td></tr><tr data-audit-type="单边" data-company="B公司"><td><span class="task-level warning">中</span></td><td><strong>B公司 ↔ D公司</strong><small>金额与日期存在 2 个候选</small></td><td>单边匹配</td><td>07.18</td><td>312.00 万元</td><td><span class="status warning">待审核</span></td><td><button class="text-button" data-audit-action>匹配</button></td></tr><tr data-audit-type="科目" data-company="E公司"><td><span class="task-level warning">中</span></td><td><strong>E公司 ↔ F公司</strong><small>规则无法区分应收与其他应收</small></td><td>科目待确认</td><td>07.09</td><td>88.00 万元</td><td><span class="status warning">待确认</span></td><td><button class="text-button" data-audit-action>确认</button></td></tr><tr data-audit-type="起算" data-company="F公司"><td><span class="task-level warning">中</span></td><td><strong>F公司 · 农业银行 3650</strong><small>公司已提交无业务说明</small></td><td>起算校准</td><td>01.01—01.16</td><td>期初口径</td><td><span class="status neutral">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr><tr data-audit-type="手工" data-company="A公司" data-record-kind="manual" data-evidence="公司手工记录、关联银行流水号、证明附件与提交说明"><td><span class="task-level warning">中</span></td><td><strong>A公司 · MR-DEMO-001</strong><small>付款 80,000.00 元 · B公司 · 其他应收</small></td><td>手工记录</td><td>2026.07.29</td><td>待确认往来 8.00 万元</td><td><span class="status warning">待复核</span></td><td><button class="text-button" data-audit-action>复核</button></td></tr></tbody></table></div></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="flows">
|
||||
@@ -182,7 +179,7 @@
|
||||
|
||||
<section class="app-view" data-page="companies">
|
||||
<header class="page-heading"><div><h1>公司与账号</h1><p>维护公司主档、银行账户和一对一公司账号</p></div><button class="button primary" id="openCompanyDialog"><svg><use href="icons.svg#plus"/></svg>新增公司</button></header>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>A公司</strong><small>COMP-001</small></td><td>91410100MA******</td><td>4 个</td><td>a-cashier</td><td>牛女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 A 公司主档">管理</button></td></tr><tr><td><strong>B公司</strong><small>COMP-002</small></td><td>91410100MB******</td><td>3 个</td><td>b-cashier</td><td>王女士</td><td><span class="status success">正常</span></td><td><button class="text-button" data-toast="已打开 B 公司主档">管理</button></td></tr><tr><td><strong>筹备公司</strong><small>COMP-006</small></td><td>注册中</td><td>0 个</td><td>未创建</td><td>未指定</td><td><span class="status neutral">筹备中</span></td><td><button class="text-button" data-toast="已打开筹备公司主档">管理</button></td></tr></tbody></table></div></section>
|
||||
<section class="panel"><div class="table-scroll"><table class="data-table" id="companyTable"><thead><tr><th>公司全称</th><th>统一社会信用代码</th><th>银行账户</th><th>公司账号</th><th>出纳人员</th><th>状态</th><th>操作</th></tr></thead><tbody></tbody></table></div></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="settings">
|
||||
@@ -208,7 +205,7 @@
|
||||
<dialog id="companyDialog" class="dialog">
|
||||
<form method="dialog" id="companyForm">
|
||||
<header><div><h2>新增公司</h2><p>公司主档与公司账号可一次创建</p></div><button class="icon-button" value="cancel" aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>初始密码将在创建后显示一次,首次登录必须修改。</p></div>
|
||||
<div class="dialog-body"><label class="field"><span>公司全称</span><input name="companyName" required /></label><label class="field"><span>统一社会信用代码</span><input name="creditCode" /></label><label class="check-field"><input type="checkbox" name="createUser" checked />同时创建公司账号</label><div class="form-grid"><label class="field"><span>登录账号</span><input name="loginName" required /></label><label class="field"><span>出纳人员</span><input name="cashier" required /></label></div><p class="form-callout"><svg><use href="icons.svg#key-round"/></svg>创建后生成随机初始密码,仅显示一次,首次登录必须修改。</p></div>
|
||||
<footer><button class="button secondary" value="cancel">取消</button><button class="button primary" value="default">创建公司与账号</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+517
-139
@@ -7,10 +7,16 @@ const viewNames = portal === "admin"
|
||||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||||
|
||||
const storageKeys = {
|
||||
accounts: "ledger-demo-account-submissions",
|
||||
manual: "ledger-demo-manual-records",
|
||||
};
|
||||
|
||||
const accountStatusLabels = {
|
||||
pending: "待复核",
|
||||
active: "已启用",
|
||||
returned: "已退回",
|
||||
disabled: "已停用",
|
||||
};
|
||||
|
||||
const state = {
|
||||
currentView: portal === "admin" ? "dashboard" : "workspace",
|
||||
selectedFile: null,
|
||||
@@ -51,18 +57,20 @@ function animateView(view, { initial = false } = {}) {
|
||||
|
||||
elements.forEach((element, index) => {
|
||||
element.getAnimations().forEach((animation) => animation.cancel());
|
||||
element.animate(
|
||||
[
|
||||
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: 440,
|
||||
delay: Math.min(index * 38, 260),
|
||||
easing: "cubic-bezier(.22,1,.36,1)",
|
||||
fill: "both",
|
||||
},
|
||||
);
|
||||
// metric-card 的 3D 倾斜与悬停倾斜由 CSS 控制,入场动画只做淡入,
|
||||
// 否则 fill:both 的 translateY(0) 会覆盖 CSS 的 transform,导致倾斜失效。
|
||||
const keyframes = element.classList.contains("metric-card")
|
||||
? [{ opacity: 0 }, { opacity: 1 }]
|
||||
: [
|
||||
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
];
|
||||
element.animate(keyframes, {
|
||||
duration: 440,
|
||||
delay: Math.min(index * 38, 260),
|
||||
easing: "cubic-bezier(.22,1,.36,1)",
|
||||
fill: "both",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,10 +118,18 @@ function writeStoredRecords(key, records) {
|
||||
function recordStatus(status) {
|
||||
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
|
||||
if (status === "已退回") return { className: "danger", label: status };
|
||||
if (status === "异常待处理") return { className: "neutral", label: status };
|
||||
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
|
||||
return { className: "warning", label: status || "待复核" };
|
||||
}
|
||||
|
||||
function accountStatusLabel(status) {
|
||||
return accountStatusLabels[status] || "待复核";
|
||||
}
|
||||
|
||||
function accountTail(masked) {
|
||||
return String(masked || "").replace(/^\*+/, "");
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -137,13 +153,14 @@ function showToast(title, detail = "") {
|
||||
|
||||
function closeNavigation({ restoreFocus = false } = {}) {
|
||||
const sidebar = $("#sidebar");
|
||||
const button = $("#menuButton");
|
||||
if (!sidebar || !button) return;
|
||||
if (!sidebar) return;
|
||||
const wasOpen = sidebar.classList.contains("is-open");
|
||||
sidebar.classList.remove("is-open");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-label", "打开导航");
|
||||
if (restoreFocus && wasOpen) button.focus();
|
||||
$$(".menu-button").forEach((button) => {
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-label", "打开导航");
|
||||
});
|
||||
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
|
||||
}
|
||||
|
||||
function showView(view) {
|
||||
@@ -165,6 +182,93 @@ function showView(view) {
|
||||
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
|
||||
}
|
||||
|
||||
const detailContent = {
|
||||
"gap-a": { tag: ["danger", "高风险"], title: "A公司 · 工行账户断档", desc: "工商银行 9481 缺少 07.01—07.21 流水,已影响 7 月结账。", fields: [["公司", "A公司"], ["账户", "工商银行 · 9481"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账 · 账户覆盖 · 双边匹配"], ["当前状态", "已逾期 2 天"]], tip: "建议先向 A公司出纳发送补传提醒,补齐后在审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
|
||||
"match-bd": { tag: ["warning", "中风险"], title: "B公司 ↔ D公司 · 单边待匹配", desc: "D公司侧流水已到,B公司侧尚未确认,合计 312.00 万元。", fields: [["本方", "B公司"], ["对方", "D公司"], ["笔数 / 金额", "6 笔 · 312.00 万元"], ["候选情况", "金额与日期存在 2 个候选"], ["当前状态", "今日新增"]], tip: "建议按账号优先核对候选流水,金额与日期相同者先确认。", action: ["去审核中心匹配", "audit"] },
|
||||
"calib-f": { tag: ["warning", "中风险"], title: "F公司 · 起算区间待校准", desc: "01.01—01.16 无银行流水覆盖,公司已提交无业务说明。", fields: [["公司", "F公司"], ["账户", "农业银行 · 3650"], ["无覆盖期间", "2026.01.01—01.16"], ["现有依据", "公司已提交无业务说明"], ["当前状态", "待公司确认"]], tip: "无业务说明属于审计证据,复核通过后该区间标记为已校准,不生成银行流水。", action: ["去审核中心复核", "audit"] },
|
||||
"task-upload": { tag: ["danger", "最紧急"], title: "补传工商银行流水", desc: "账户尾号 9481 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户", "工商银行 · 9481"], ["缺少期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月账户覆盖与双边匹配"], ["截止", "08.05 集团结账日前"]], tip: "从工商银行网银导出 7 月流水后直接上传,系统会自动识别表头并重新计算匹配。", action: ["去上传流水", "upload"] },
|
||||
"task-match": { tag: ["warning", "待确认"], title: "确认 1 笔单边流水", desc: "07.18 转出 280.00 万元,系统找到 2 个对方候选。", fields: [["对方", "B公司"], ["日期 / 金额", "07.18 · 280.00 万元"], ["候选", "工商银行 9481(推荐)· 建设银行 2046"], ["核对点", "摘要与账号是否一致"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
|
||||
"task-subject": { tag: ["warning", "待确认"], title: "确认往来科目", desc: "06.27 转出 600.00 万元,规则无法区分应收与其他应收。", fields: [["对方", "C公司"], ["日期 / 金额", "06.27 · 600.00 万元"], ["待确认", "应收 或 其他应收"], ["摘要", "资金调拨"]], tip: "科目只按确定性规则建议,拿不准时选“其他应收”并在说明里注明依据。", action: ["去确认科目", "reconcile"] },
|
||||
"task-notice": { tag: ["neutral", "提醒"], title: "阅读总账提醒", desc: "管理员要求 08.08 前完成 7 月银行流水确认。", fields: [["来自", "系统管理员 · 今天 09:30"], ["处理期限", "2026.08.08"], ["关联事项", "断档补传 · 2 项待确认往来"]], tip: "完成补传和两项确认后,再提交公司确认即可。", action: ["查看通知", "notifications"] },
|
||||
"acct-citic": { tag: ["success", "连续"], title: "中信银行 · 5316", desc: "基本户 · 起算日以来流水全部连续。", fields: [["账户类型", "基本户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "今天 09:42"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||||
"acct-abc": { tag: ["success", "连续"], title: "农业银行 · 3650", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 08:01"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||||
"acct-icbc": { tag: ["danger", "断档"], title: "工商银行 · 9481", desc: "一般户 · 缺少 07.01—07.21 流水,已逾期 2 天。", fields: [["账户类型", "一般户"], ["缺口期间", "2026.07.01—07.21 · 21 天"], ["影响", "7 月结账与双边匹配"], ["最近导入", "07.31 16:18(6 月批次)"]], tip: "这是最紧急的一项:补齐后系统会自动重算覆盖与匹配。", action: ["去补传流水", "upload"] },
|
||||
"acct-ccb": { tag: ["success", "连续"], title: "建设银行 · 0845", desc: "一般户 · 起算日以来流水全部连续。", fields: [["账户类型", "一般户"], ["启用日期", "2026.01.01"], ["流水覆盖", "01.01—07.31 · 7 个月连续"], ["最近导入", "08.01 10:03"]], tip: "该账户无需处理,8 月流水到期后正常上传即可。", action: ["查看银行账户", "accounts"] },
|
||||
};
|
||||
|
||||
function initDetailDrawer() {
|
||||
const triggers = $$("[data-detail]");
|
||||
if (!triggers.length) return;
|
||||
const drawer = document.createElement("aside");
|
||||
drawer.className = "detail-drawer";
|
||||
drawer.id = "detailDrawer";
|
||||
drawer.setAttribute("aria-label", "事项详情");
|
||||
drawer.innerHTML = `<header><div><span class="status" id="detailTag"></span><h2 id="detailTitle"></h2><p class="detail-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg><use href="icons.svg#x"/></svg></button></header><dl class="detail-fields" id="detailFields"></dl><div class="detail-tip" id="detailTip"></div><footer><button type="button" class="button secondary" data-close-detail>关闭</button><button type="button" class="button primary" id="detailAction"></button></footer>`;
|
||||
document.body.append(drawer);
|
||||
let lastTrigger = null;
|
||||
|
||||
function closeDrawer({ restoreFocus = true } = {}) {
|
||||
drawer.classList.remove("is-open");
|
||||
if (restoreFocus && lastTrigger) lastTrigger.focus();
|
||||
}
|
||||
|
||||
function openDetail(id, trigger) {
|
||||
const item = detailContent[id];
|
||||
if (!item) return;
|
||||
lastTrigger = trigger;
|
||||
const tag = $("#detailTag", drawer);
|
||||
tag.className = `status ${item.tag[0]}`;
|
||||
tag.textContent = item.tag[1];
|
||||
$("#detailTitle", drawer).textContent = item.title;
|
||||
$("#detailDesc", drawer).textContent = item.desc;
|
||||
const fields = $("#detailFields", drawer);
|
||||
fields.replaceChildren(...item.fields.map(([label, value]) => {
|
||||
const row = document.createElement("div");
|
||||
const dt = document.createElement("dt"); dt.textContent = label;
|
||||
const dd = document.createElement("dd"); dd.textContent = value;
|
||||
row.append(dt, dd);
|
||||
return row;
|
||||
}));
|
||||
const tip = $("#detailTip", drawer);
|
||||
tip.replaceChildren();
|
||||
const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议";
|
||||
tip.append(tipHeading, document.createTextNode(item.tip));
|
||||
const action = $("#detailAction", drawer);
|
||||
action.textContent = item.action[0];
|
||||
action.onclick = () => {
|
||||
closeDrawer({ restoreFocus: false });
|
||||
if (item.action[1] === "upload") $("[data-open-upload]")?.click();
|
||||
else showView(item.action[1]);
|
||||
};
|
||||
drawer.classList.add("is-open");
|
||||
$("[data-close-detail]", drawer).focus();
|
||||
}
|
||||
|
||||
$$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer()));
|
||||
drawer.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
triggers.forEach((element) => {
|
||||
element.addEventListener("click", (event) => {
|
||||
const innerButton = event.target.closest("button");
|
||||
if (innerButton && innerButton !== element) return;
|
||||
openDetail(element.dataset.detail, element);
|
||||
});
|
||||
if (element.tagName !== "BUTTON") {
|
||||
element.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openDetail(element.dataset.detail, element);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initEntry() {
|
||||
const form = $("#loginForm");
|
||||
if (!form) return;
|
||||
@@ -178,38 +282,131 @@ function initEntry() {
|
||||
}
|
||||
const roleInputs = $$('input[name="role"]', form);
|
||||
const username = $('input[name="username"]', form);
|
||||
const password = $('input[name="password"]', form);
|
||||
const action = $("#loginAction");
|
||||
const errorBox = $("#loginError");
|
||||
const changeSection = $("#changePassword");
|
||||
let pendingRole = null;
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = false;
|
||||
}
|
||||
|
||||
function updateRole() {
|
||||
const role = $('input[name="role"]:checked', form).value;
|
||||
action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端";
|
||||
username.value = role === "admin" ? "group-admin" : "a-cashier";
|
||||
}
|
||||
|
||||
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
|
||||
$("#togglePassword").addEventListener("click", (event) => {
|
||||
const password = $('input[name="password"]', form);
|
||||
const visible = password.type === "text";
|
||||
password.type = visible ? "password" : "text";
|
||||
event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码");
|
||||
event.currentTarget.title = visible ? "显示密码" : "隐藏密码";
|
||||
});
|
||||
form.addEventListener("submit", (event) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const role = $('input[name="role"]:checked', form).value;
|
||||
errorBox.hidden = true;
|
||||
const role = pendingRole || $('input[name="role"]:checked', form).value;
|
||||
|
||||
if (pendingRole) {
|
||||
const newPassword = $('input[name="new_password"]', form).value;
|
||||
const confirmPassword = $('input[name="confirm_password"]', form).value;
|
||||
if (newPassword !== confirmPassword) {
|
||||
showError("两次输入的新密码不一致。");
|
||||
return;
|
||||
}
|
||||
const changeResponse = await fetch("/api/password/change", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old_password: password.value, new_password: newPassword }),
|
||||
}).catch(() => null);
|
||||
const changeResult = await changeResponse?.json().catch(() => ({}));
|
||||
if (!changeResponse || !changeResponse.ok) {
|
||||
showError(changeResult?.message || "修改密码失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
window.location.href = role === "admin" ? "admin.html" : "company.html";
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }),
|
||||
}).catch(() => null);
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showError(result?.message || "登录服务暂时不可用,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
if (result.must_change_password) {
|
||||
pendingRole = role;
|
||||
changeSection.hidden = false;
|
||||
action.textContent = "设置新密码并进入";
|
||||
$('input[name="new_password"]', form).focus();
|
||||
return;
|
||||
}
|
||||
window.location.href = role === "admin" ? "admin.html" : "company.html";
|
||||
});
|
||||
}
|
||||
|
||||
async function initAuthGuard() {
|
||||
if (portal === "entry") return true;
|
||||
try {
|
||||
const response = await fetch("/api/me");
|
||||
if (response.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return false;
|
||||
}
|
||||
const me = await response.json();
|
||||
if (!response.ok || me.role !== portal) {
|
||||
window.location.href = "index.html";
|
||||
return false;
|
||||
}
|
||||
state.me = me;
|
||||
applyCompanyIdentity(me);
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCompanyIdentity(me) {
|
||||
// The company portal always shows the session-bound company, never a
|
||||
// hard-coded one.
|
||||
if (portal !== "company" || !me?.company_name) return;
|
||||
const context = $(".company-context");
|
||||
if (context) {
|
||||
const mark = $("span", context);
|
||||
if (mark) mark.textContent = me.company_name.slice(0, 1);
|
||||
const name = $("strong", context);
|
||||
if (name) name.textContent = me.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
function initShell() {
|
||||
const menuButton = $("#menuButton");
|
||||
if (menuButton) {
|
||||
$$(".page-heading").forEach((heading) => {
|
||||
if (heading.querySelector(".menu-button")) return;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "icon-button menu-button";
|
||||
button.setAttribute("aria-label", "打开导航");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-controls", "sidebar");
|
||||
button.title = "打开导航";
|
||||
button.innerHTML = '<svg><use href="icons.svg#menu"></use></svg>';
|
||||
heading.prepend(button);
|
||||
});
|
||||
|
||||
$$(".menu-button").forEach((menuButton) => {
|
||||
menuButton.addEventListener("click", () => {
|
||||
const open = $("#sidebar").classList.toggle("is-open");
|
||||
menuButton.setAttribute("aria-expanded", String(open));
|
||||
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$$(".nav-item").forEach((item) => {
|
||||
const label = $("span", item)?.textContent.trim();
|
||||
@@ -220,6 +417,13 @@ function initShell() {
|
||||
});
|
||||
$$("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
|
||||
$$("[data-view-link]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.viewLink)));
|
||||
$$('a.nav-item[href="index.html"]').forEach((link) => link.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
} catch { /* 网络异常时仍然回到登录页 */ }
|
||||
window.location.href = "index.html";
|
||||
}));
|
||||
$$("[data-metric-link]").forEach((card) => {
|
||||
const activate = () => showView(card.dataset.metricLink);
|
||||
card.addEventListener("click", activate);
|
||||
@@ -240,7 +444,11 @@ function initShell() {
|
||||
}
|
||||
});
|
||||
});
|
||||
$$("[data-toast]").forEach((button) => button.addEventListener("click", () => showToast(button.dataset.toast)));
|
||||
// Delegated: company table rows are rendered from the API after init.
|
||||
document.addEventListener("click", (event) => {
|
||||
const toastButton = event.target.closest("[data-toast]");
|
||||
if (toastButton) showToast(toastButton.dataset.toast);
|
||||
});
|
||||
$(".nav-item[data-view].is-active")?.setAttribute("aria-current", "page");
|
||||
|
||||
$("#globalSearch")?.addEventListener("input", (event) => {
|
||||
@@ -252,12 +460,13 @@ function initShell() {
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest("#menuButton")) closeNavigation({ restoreFocus: true });
|
||||
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
|
||||
});
|
||||
|
||||
initDetailDrawer();
|
||||
initMotion();
|
||||
}
|
||||
|
||||
@@ -407,59 +616,85 @@ function renderCompanyManualRecords() {
|
||||
if ($("#manualPendingStatus")) $("#manualPendingStatus").textContent = `${pending} 笔待总账复核`;
|
||||
}
|
||||
|
||||
function addApprovedAccountOption(record) {
|
||||
if (record.status !== "已启用") return;
|
||||
const value = `${record.bank} · ${record.accountNumber.slice(-4)}`;
|
||||
function fillAccountSelects(accounts) {
|
||||
const usable = accounts.filter((account) => account.usable);
|
||||
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
|
||||
if (!select || [...select.options].some((option) => option.value === value)) return;
|
||||
const option = new Option(value, value);
|
||||
option.dataset.submittedAccount = record.id;
|
||||
select.add(option);
|
||||
if (!select) return;
|
||||
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
|
||||
select.replaceChildren(...kept);
|
||||
usable.forEach((account) => {
|
||||
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
|
||||
const option = new Option(value, value);
|
||||
option.dataset.accountId = account.id;
|
||||
select.add(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderCompanyAccountSubmissions() {
|
||||
function renderCompanyAccounts(accounts) {
|
||||
const directory = $("#accountDirectory");
|
||||
if (!directory) return;
|
||||
$$('[data-submitted-account]', directory).forEach((item) => item.remove());
|
||||
const records = readStoredRecords(storageKeys.accounts).filter((record) => record.company === "A公司");
|
||||
records.forEach((record) => {
|
||||
const article = document.createElement("article");
|
||||
article.dataset.submittedAccount = record.id;
|
||||
const header = document.createElement("header");
|
||||
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = record.bank.slice(0, 1);
|
||||
const identity = document.createElement("div");
|
||||
const name = document.createElement("strong"); name.textContent = record.bank;
|
||||
const meta = document.createElement("small"); meta.textContent = `${record.type} · 尾号 ${record.accountNumber.slice(-4)}`;
|
||||
identity.append(name, meta);
|
||||
const status = recordStatus(record.status);
|
||||
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
|
||||
header.append(mark, identity, badge);
|
||||
const details = document.createElement("dl");
|
||||
[["申请启用", record.startDate], ["流水覆盖", record.status === "已启用" ? "尚未上传" : "不参与计算"], ["提交时间", record.createdAt]].forEach(([term, value]) => {
|
||||
const wrapper = document.createElement("div");
|
||||
const dt = document.createElement("dt"); dt.textContent = term;
|
||||
const dd = document.createElement("dd"); dd.textContent = value;
|
||||
wrapper.append(dt, dd); details.append(wrapper);
|
||||
});
|
||||
article.append(header, details); directory.append(article);
|
||||
addApprovedAccountOption(record);
|
||||
});
|
||||
if (directory) {
|
||||
directory.replaceChildren(...accounts.map((account) => {
|
||||
const article = document.createElement("article");
|
||||
const header = document.createElement("header");
|
||||
const mark = document.createElement("span"); mark.className = "bank-mark"; mark.textContent = account.bank_name.slice(0, 1);
|
||||
const identity = document.createElement("div");
|
||||
const name = document.createElement("strong"); name.textContent = account.bank_name;
|
||||
const meta = document.createElement("small"); meta.textContent = `${account.account_type} · 尾号 ${accountTail(account.account_number_masked)}`;
|
||||
identity.append(name, meta);
|
||||
const status = recordStatus(accountStatusLabel(account.status));
|
||||
const badge = document.createElement("em"); badge.className = `status ${status.className}`; badge.textContent = status.label;
|
||||
header.append(mark, identity, badge);
|
||||
const details = document.createElement("dl");
|
||||
const rows = [
|
||||
["申请启用", account.effective_from || "待审核确定"],
|
||||
["流水覆盖", account.usable ? "尚未上传" : "不参与计算"],
|
||||
["提交时间", String(account.created_at || "").slice(0, 10) || "—"],
|
||||
];
|
||||
if (account.status === "returned" && account.review_reason) rows.push(["退回原因", account.review_reason]);
|
||||
if (account.status === "disabled" && account.effective_to) rows.push(["停用日期", account.effective_to]);
|
||||
rows.forEach(([term, value]) => {
|
||||
const wrapper = document.createElement("div");
|
||||
const dt = document.createElement("dt"); dt.textContent = term;
|
||||
const dd = document.createElement("dd"); dd.textContent = value;
|
||||
wrapper.append(dt, dd); details.append(wrapper);
|
||||
});
|
||||
article.append(header, details);
|
||||
return article;
|
||||
}));
|
||||
}
|
||||
fillAccountSelects(accounts);
|
||||
}
|
||||
|
||||
async function loadCompanyAccounts() {
|
||||
const response = await fetch("/api/company/accounts").catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
if (!response?.ok) return;
|
||||
const result = await response.json().catch(() => null);
|
||||
if (result?.accounts) renderCompanyAccounts(result.accounts);
|
||||
}
|
||||
|
||||
function appendAdminReviewRow(record, kind) {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
const isAccount = kind === "account";
|
||||
// Account rows come from the server (full number visible only in this
|
||||
// authorized admin view); manual records are still browser-local demo data.
|
||||
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.storedReview = record.id;
|
||||
row.dataset.recordId = record.id;
|
||||
row.dataset.recordKind = kind;
|
||||
row.dataset.auditType = kind === "account" ? "账户" : "手工";
|
||||
row.dataset.company = record.company;
|
||||
row.dataset.evidence = kind === "account"
|
||||
if (isAccount) row.dataset.accountId = record.id;
|
||||
row.dataset.auditType = isAccount ? "账户" : "手工";
|
||||
row.dataset.company = isAccount ? record.company_name : record.company;
|
||||
row.dataset.evidence = isAccount
|
||||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||||
if (record.status !== "待复核" && record.status !== "待总账复核") row.dataset.resolved = "true";
|
||||
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
|
||||
|
||||
const riskCell = document.createElement("td");
|
||||
const risk = document.createElement("span"); risk.className = "task-level warning"; risk.textContent = "中"; riskCell.append(risk);
|
||||
@@ -469,11 +704,11 @@ function appendAdminReviewRow(record, kind) {
|
||||
const typeCell = document.createElement("td");
|
||||
const periodCell = document.createElement("td");
|
||||
const impactCell = document.createElement("td");
|
||||
if (kind === "account") {
|
||||
identity.textContent = `${record.company} · ${record.bank} ${record.accountNumber.slice(-4)}`;
|
||||
detail.textContent = `${record.type} · 完整账号 ${record.accountNumber}`;
|
||||
if (isAccount) {
|
||||
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
||||
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
||||
typeCell.textContent = "账户登记";
|
||||
periodCell.textContent = record.startDate;
|
||||
periodCell.textContent = record.effective_from || "待审核确定";
|
||||
impactCell.textContent = "账户识别与流水上传";
|
||||
} else {
|
||||
identity.textContent = `${record.company} · ${record.id}`;
|
||||
@@ -484,37 +719,53 @@ function appendAdminReviewRow(record, kind) {
|
||||
}
|
||||
identityCell.append(identity, detail);
|
||||
const statusCell = document.createElement("td");
|
||||
const status = recordStatus(record.status);
|
||||
const status = recordStatus(statusLabel);
|
||||
const badge = document.createElement("span"); badge.className = `status ${status.className}`; badge.textContent = status.label; statusCell.append(badge);
|
||||
const actionCell = document.createElement("td");
|
||||
const action = document.createElement("button"); action.className = "text-button"; action.dataset.auditAction = "";
|
||||
action.textContent = row.dataset.resolved ? "查看记录" : "复核";
|
||||
if (isAccount) row.dataset.accountStatus = record.status;
|
||||
action.textContent = row.dataset.resolved
|
||||
? (isAccount && record.status === "active" ? "管理" : "查看记录")
|
||||
: "复核";
|
||||
if (row.dataset.resolved) {
|
||||
action.dataset.record = `${record.decision || record.status} · ${record.reviewReason || "已留痕"}`;
|
||||
action.dataset.decision = record.decision || record.status;
|
||||
action.dataset.reason = record.reviewReason || "已留痕";
|
||||
action.dataset.processedAt = record.reviewedAt || "时间未记录";
|
||||
const decisionLabels = { active: "复核通过并启用账户", returned: "退回公司修改", disabled: "停用并驳回" };
|
||||
const decisionLabel = isAccount ? decisionLabels[record.status] || statusLabel : record.decision || record.status;
|
||||
const reasonText = (isAccount ? record.review_reason : record.reviewReason) || "已留痕";
|
||||
action.dataset.record = `${decisionLabel} · ${reasonText}`;
|
||||
action.dataset.decision = decisionLabel;
|
||||
action.dataset.reason = reasonText;
|
||||
action.dataset.processedAt = (isAccount ? record.reviewed_at : record.reviewedAt) || "时间未记录";
|
||||
}
|
||||
actionCell.append(action);
|
||||
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||||
tbody.append(row);
|
||||
}
|
||||
|
||||
async function renderAdminAccountReviews() {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
|
||||
const response = await fetch("/api/admin/accounts").catch(() => null);
|
||||
if (!response?.ok) return;
|
||||
const result = await response.json().catch(() => null);
|
||||
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||||
updateAuditCounts();
|
||||
}
|
||||
|
||||
function renderStoredAdminReviews() {
|
||||
if (!$("#auditRows")) return;
|
||||
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
|
||||
readStoredRecords(storageKeys.accounts).forEach((record) => appendAdminReviewRow(record, "account"));
|
||||
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||||
renderAdminAccountReviews();
|
||||
}
|
||||
|
||||
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
|
||||
if (!kind || !id) return;
|
||||
const key = kind === "account" ? storageKeys.accounts : storageKeys.manual;
|
||||
const records = readStoredRecords(key);
|
||||
if (kind !== "manual" || !id) return;
|
||||
const records = readStoredRecords(storageKeys.manual);
|
||||
const record = records.find((item) => item.id === id);
|
||||
if (!record) return;
|
||||
Object.assign(record, { status, decision, reviewReason, reviewedAt });
|
||||
writeStoredRecords(key, records);
|
||||
writeStoredRecords(storageKeys.manual, records);
|
||||
}
|
||||
|
||||
function updateAuditCounts() {
|
||||
@@ -529,9 +780,71 @@ function updateAuditCounts() {
|
||||
if (badge) badge.textContent = unresolved.length;
|
||||
}
|
||||
|
||||
function companyStatusBadge(status) {
|
||||
if (status === "preparing") return { className: "neutral", label: "筹备中" };
|
||||
if (status === "disabled") return { className: "danger", label: "已停用" };
|
||||
return { className: "success", label: "正常" };
|
||||
}
|
||||
|
||||
function renderAdminCompanyTable(companies) {
|
||||
const tbody = $("#companyTable tbody");
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(...companies.map((company) => {
|
||||
const row = document.createElement("tr");
|
||||
const nameCell = document.createElement("td");
|
||||
const name = document.createElement("strong"); name.textContent = company.name;
|
||||
const code = document.createElement("small"); code.textContent = `COMP-${String(company.id).padStart(3, "0")}`;
|
||||
nameCell.append(name, code);
|
||||
const credit = document.createElement("td"); credit.textContent = company.credit_code || "待补充";
|
||||
const accounts = document.createElement("td"); accounts.textContent = `${company.account_count ?? 0} 个`;
|
||||
const usernames = document.createElement("td"); usernames.textContent = company.usernames || "未创建";
|
||||
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
|
||||
const statusCell = document.createElement("td");
|
||||
const badge = companyStatusBadge(company.status);
|
||||
statusCell.innerHTML = `<span class="status ${badge.className}">${badge.label}</span>`;
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.innerHTML = `<button class="text-button" data-toast="已打开 ${company.name} 主档">管理</button>`;
|
||||
row.append(nameCell, credit, accounts, usernames, cashier, statusCell, actionCell);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function setSelectOptions(select, names, { keepFirst = false } = {}) {
|
||||
if (!select || !names.length) return;
|
||||
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
|
||||
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
|
||||
}
|
||||
|
||||
function fillCompanySelects(names) {
|
||||
// Every company picker is driven by master data: a newly created company
|
||||
// appears in pair queries, audit filters, flow filters and reminders
|
||||
// without any code change.
|
||||
$$("[data-pair-form]").forEach((form) => {
|
||||
setSelectOptions($('[name="from"]', form), names);
|
||||
setSelectOptions($('[name="to"]', form), names);
|
||||
const toSelect = $('[name="to"]', form);
|
||||
if (toSelect && names.length > 1) toSelect.value = names[1];
|
||||
});
|
||||
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
|
||||
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
|
||||
setSelectOptions($('#reminderForm [name="company"]'), names, { keepFirst: true });
|
||||
setSelectOptions($('#openingDialog [name="from"]'), names);
|
||||
setSelectOptions($('#openingDialog [name="to"]'), names);
|
||||
}
|
||||
|
||||
async function loadAdminCompanies() {
|
||||
const response = await fetch("/api/admin/companies").catch(() => null);
|
||||
if (!response?.ok) return;
|
||||
const result = await response.json().catch(() => null);
|
||||
const companies = result?.companies || [];
|
||||
renderAdminCompanyTable(companies);
|
||||
fillCompanySelects(companies.map((company) => company.name));
|
||||
}
|
||||
|
||||
function initAdmin() {
|
||||
renderStoredAdminReviews();
|
||||
updateAuditCounts();
|
||||
loadAdminCompanies();
|
||||
const companySearch = $('[data-filter-target="companyLedgers"]');
|
||||
companySearch?.addEventListener("input", () => {
|
||||
const query = companySearch.value.trim().toLowerCase();
|
||||
@@ -560,7 +873,10 @@ function initAdmin() {
|
||||
|
||||
const auditDialog = $("#auditDialog");
|
||||
$$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close()));
|
||||
$$("[data-audit-action]").forEach((button) => button.addEventListener("click", () => {
|
||||
// Delegated: account review rows arrive asynchronously from the API.
|
||||
$("#auditRows")?.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-audit-action]");
|
||||
if (!button) return;
|
||||
const row = button.closest("tr");
|
||||
state.auditRow = row;
|
||||
const cells = $$('td', row);
|
||||
@@ -569,8 +885,9 @@ function initAdmin() {
|
||||
const reason = $('[name="reason"]', form);
|
||||
const submit = $('button[type="submit"]', form);
|
||||
form.reset();
|
||||
const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active";
|
||||
const decisions = row.dataset.recordKind === "account"
|
||||
? ["复核通过并启用账户", "退回公司修改", "停用并驳回"]
|
||||
? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"])
|
||||
: ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"];
|
||||
decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item)));
|
||||
decision.disabled = false;
|
||||
@@ -584,7 +901,7 @@ function initAdmin() {
|
||||
const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small").textContent;
|
||||
const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
|
||||
evidence.append(heading, detail, source);
|
||||
if (button.dataset.record) {
|
||||
if (button.dataset.record && !isActiveAccount) {
|
||||
decision.value = button.dataset.decision;
|
||||
reason.value = button.dataset.reason;
|
||||
decision.disabled = true;
|
||||
@@ -596,28 +913,57 @@ function initAdmin() {
|
||||
evidence.append(record);
|
||||
}
|
||||
auditDialog.showModal();
|
||||
}));
|
||||
$("#auditForm")?.addEventListener("submit", (event) => {
|
||||
});
|
||||
$("#auditForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const row = state.auditRow;
|
||||
const decision = String(data.get("decision"));
|
||||
const reason = String(data.get("reason"));
|
||||
const approved = decision.includes("通过") || decision.includes("确认并纳入");
|
||||
const returned = decision.includes("退回");
|
||||
const storedStatus = approved
|
||||
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
|
||||
: (returned ? "已退回" : "异常待处理");
|
||||
let storedStatus;
|
||||
let reviewedAccount = null;
|
||||
if (row.dataset.recordKind === "account" && row.dataset.accountId) {
|
||||
// Server-side review: the account only becomes usable after this succeeds.
|
||||
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
|
||||
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision: apiDecision, reason }),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("审核结果提交失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
reviewedAccount = result.account;
|
||||
storedStatus = accountStatusLabel(result.account?.status);
|
||||
} else {
|
||||
storedStatus = approved
|
||||
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
|
||||
: (returned ? "已退回" : "异常待处理");
|
||||
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false }));
|
||||
}
|
||||
const status = recordStatus(storedStatus);
|
||||
const statusCell = row.children[5];
|
||||
statusCell.innerHTML = `<span class="status ${status.className}">${status.label}</span>`;
|
||||
row.dataset.resolved = "true";
|
||||
const action = $("[data-audit-action]", row);
|
||||
action.textContent = "查看记录";
|
||||
if (reviewedAccount) {
|
||||
row.dataset.accountStatus = reviewedAccount.status;
|
||||
action.textContent = reviewedAccount.status === "active" ? "管理" : "查看记录";
|
||||
} else {
|
||||
action.textContent = "查看记录";
|
||||
}
|
||||
action.dataset.record = `${decision} · ${data.get("reason")}`;
|
||||
action.dataset.decision = decision;
|
||||
action.dataset.reason = data.get("reason");
|
||||
action.dataset.processedAt = new Date().toLocaleString("zh-CN", { hour12: false });
|
||||
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, String(data.get("reason")), action.dataset.processedAt);
|
||||
updateAuditCounts();
|
||||
auditDialog.close();
|
||||
event.currentTarget.reset();
|
||||
@@ -626,29 +972,42 @@ function initAdmin() {
|
||||
|
||||
const dialog = $("#companyDialog");
|
||||
$("#openCompanyDialog")?.addEventListener("click", () => dialog.showModal());
|
||||
$("#companyForm")?.addEventListener("submit", (event) => {
|
||||
$("#companyForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const row = document.createElement("tr");
|
||||
const values = [data.get("companyName"), data.get("creditCode") || "待补充", "0 个", data.get("loginName"), data.get("cashier")];
|
||||
values.forEach((value, index) => {
|
||||
const cell = document.createElement("td");
|
||||
if (index === 0) {
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = value;
|
||||
cell.append(strong);
|
||||
} else cell.textContent = value;
|
||||
row.append(cell);
|
||||
});
|
||||
const statusCell = document.createElement("td");
|
||||
statusCell.innerHTML = '<span class="status success">正常</span>';
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.innerHTML = '<button class="text-button">管理</button>';
|
||||
row.append(statusCell, actionCell);
|
||||
$("#companyTable tbody").append(row);
|
||||
const companyName = String(data.get("companyName") || "").trim();
|
||||
const loginName = String(data.get("loginName") || "").trim();
|
||||
const createUser = data.get("createUser") !== null;
|
||||
|
||||
const payload = {
|
||||
name: companyName,
|
||||
credit_code: String(data.get("creditCode") || "").trim(),
|
||||
cashier_name: String(data.get("cashier") || "").trim(),
|
||||
};
|
||||
if (createUser && loginName) payload.username = loginName;
|
||||
|
||||
const response = await fetch("/api/admin/companies", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("公司创建失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
const accountCreated = Boolean(result.username);
|
||||
dialog.close();
|
||||
event.currentTarget.reset();
|
||||
showToast("公司与账号已创建", "初始密码:ChangeMe2026(演示)");
|
||||
await loadAdminCompanies();
|
||||
showToast(
|
||||
accountCreated ? "公司与账号已创建" : "公司已创建",
|
||||
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
|
||||
);
|
||||
});
|
||||
|
||||
$("#systemSettings")?.addEventListener("submit", (event) => {
|
||||
@@ -826,22 +1185,34 @@ function acceptFile(file) {
|
||||
async function parseFile() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", state.selectedFile);
|
||||
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
|
||||
if (selectedAccount?.dataset.accountId) {
|
||||
formData.append("bank_account_id", selectedAccount.dataset.accountId);
|
||||
}
|
||||
let result;
|
||||
let parsed = false;
|
||||
try {
|
||||
const response = await fetch("/api/parse", { method: "POST", body: formData });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
result = await response.json();
|
||||
parsed = response.ok && result.status === "parsed";
|
||||
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
|
||||
} catch {
|
||||
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
|
||||
}
|
||||
state.parseResult = result;
|
||||
const duplicated = result.status === "duplicate";
|
||||
const opaqueDuplicate = duplicated && !result.bank;
|
||||
const panel = $("#parseResult");
|
||||
panel.classList.toggle("is-exception", !parsed);
|
||||
$("use", panel).setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
|
||||
$("strong", panel).textContent = parsed ? "文件解析完成" : "未识别到银行模板";
|
||||
$("strong", panel).textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
|
||||
$("#parseSummary").textContent = parsed
|
||||
? `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}`
|
||||
? opaqueDuplicate
|
||||
? "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。"
|
||||
: `${result.bank} · 表头第 ${result.header_row} 行 · ${result.transactions} 条明细 · ${result.warnings.length ? `${result.warnings.length} 项提示` : "校验通过"}${duplicated ? " · 重复上传,复用已有批次" : ""}`
|
||||
: `${result.message} 系统不会猜测模板或自动入账。`;
|
||||
panel.hidden = false;
|
||||
$("#parseButton span").textContent = parsed ? "确认导入" : "提交异常";
|
||||
@@ -866,7 +1237,7 @@ function submitImportException() {
|
||||
|
||||
function initCompany() {
|
||||
renderCompanyManualRecords();
|
||||
renderCompanyAccountSubmissions();
|
||||
loadCompanyAccounts();
|
||||
const uploadDialog = $("#uploadDialog");
|
||||
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.showModal()));
|
||||
$$('[data-close-upload]').forEach((button) => button.addEventListener("click", () => uploadDialog.close()));
|
||||
@@ -890,7 +1261,7 @@ function initCompany() {
|
||||
$("#uploadForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if ($("#parseButton").dataset.stage === "confirm") {
|
||||
if (state.parseResult?.status === "parsed") confirmImport();
|
||||
if (["parsed", "duplicate"].includes(state.parseResult?.status)) confirmImport();
|
||||
else submitImportException();
|
||||
return;
|
||||
}
|
||||
@@ -973,29 +1344,33 @@ function initCompany() {
|
||||
const accountDialog = $("#accountDialog");
|
||||
$("#openAccountDialog")?.addEventListener("click", () => accountDialog.showModal());
|
||||
$$('[data-close-account]').forEach((button) => button.addEventListener("click", () => accountDialog.close()));
|
||||
$("#accountForm")?.addEventListener("submit", (event) => {
|
||||
$("#accountForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const accountNumber = String(data.get("accountNumber")).replace(/[\s-]/g, "");
|
||||
const records = readStoredRecords(storageKeys.accounts);
|
||||
if (records.some((record) => record.accountNumber === accountNumber && record.status !== "已退回")) {
|
||||
showToast("该银行账号已登记", "请等待现有申请处理,或联系总账管理员核对");
|
||||
// The server binds the account to the session company and normalizes the
|
||||
// number; duplicates come back as 409.
|
||||
const response = await fetch("/api/company/accounts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bank_name: String(data.get("bank") || "").trim(),
|
||||
account_type: String(data.get("type") || ""),
|
||||
account_number: String(data.get("accountNumber") || ""),
|
||||
start_date: String(data.get("startDate") || ""),
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("账户登记失败", result?.message || "请稍后重试");
|
||||
return;
|
||||
}
|
||||
records.push({
|
||||
id: `ACC-${Date.now().toString().slice(-10)}`,
|
||||
company: "A公司",
|
||||
bank: String(data.get("bank")).trim(),
|
||||
type: String(data.get("type")),
|
||||
accountNumber,
|
||||
startDate: String(data.get("startDate")),
|
||||
status: "待复核",
|
||||
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
});
|
||||
if (!writeStoredRecords(storageKeys.accounts, records)) return;
|
||||
renderCompanyAccountSubmissions();
|
||||
accountDialog.close();
|
||||
event.currentTarget.reset();
|
||||
await loadCompanyAccounts();
|
||||
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算");
|
||||
});
|
||||
}
|
||||
@@ -1003,12 +1378,15 @@ function initCompany() {
|
||||
if (portal === "entry") {
|
||||
initEntry();
|
||||
} else {
|
||||
initShell();
|
||||
initFlowTools();
|
||||
if (portal === "admin") {
|
||||
initPairQueries();
|
||||
initAdmin();
|
||||
} else {
|
||||
initCompany();
|
||||
}
|
||||
initAuthGuard().then((allowed) => {
|
||||
if (!allowed) return;
|
||||
initShell();
|
||||
initFlowTools();
|
||||
if (portal === "admin") {
|
||||
initPairQueries();
|
||||
initAdmin();
|
||||
} else {
|
||||
initCompany();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+54
-31
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="往来台账公司业务端" />
|
||||
<title>A公司业务端 · 往来台账</title>
|
||||
<meta name="description" content="金牛集团公司业务端" />
|
||||
<title>公司业务端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body data-portal="company">
|
||||
@@ -19,7 +19,7 @@
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="公司业务导航">
|
||||
<div class="brand"><span class="brand-mark">往</span><span class="brand-copy"><strong>往来台账</strong><small>公司业务端</small></span></div>
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>公司业务端</small></span></div>
|
||||
<div class="company-context"><span>A</span><div><strong>A公司</strong><small>当前登录公司</small></div></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" data-view="workspace"><svg><use href="icons.svg#layout-dashboard"/></svg><span>工作台</span><b>4</b></button>
|
||||
@@ -37,20 +37,18 @@
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<button class="icon-button menu-button" id="menuButton" aria-label="打开导航" aria-expanded="false" aria-controls="sidebar" title="打开导航"><svg><use href="icons.svg#menu"/></svg></button>
|
||||
<div class="workspace-name"><span>A公司</span><strong id="currentViewName">工作台</strong></div>
|
||||
<span class="demo-badge">演示数据</span>
|
||||
<div class="topbar-actions">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
|
||||
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active" data-page="workspace">
|
||||
<header class="page-heading"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p></div><span class="status warning" id="workspacePendingStatus">4 项待处理</span></header>
|
||||
<header class="page-heading dashboard-head"><div><h1>本月待办</h1><p>A公司 · 距离集团结账日还有 3 天</p><span class="status warning" id="workspacePendingStatus">4 项待处理</span></div>
|
||||
<div class="content-toolbar">
|
||||
<label class="search-box"><svg><use href="icons.svg#search"/></svg><span class="sr-only">搜索本公司数据</span><input id="globalSearch" type="search" placeholder="搜索本公司流水或对方" /></label>
|
||||
<div class="toolbar-group">
|
||||
<button class="icon-button" data-view-link="notifications" aria-label="通知,2 项未读" title="通知"><svg><use href="icons.svg#bell"/></svg><i class="notification-dot">2</i></button>
|
||||
<button class="period-button" type="button"><svg><use href="icons.svg#calendar"/></svg><span>2026 年 7 月</span></button>
|
||||
<button class="button primary" data-open-upload><svg><use href="icons.svg#upload"/></svg>上传流水</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="metric-grid" aria-label="本公司关键指标">
|
||||
<article class="metric-card danger" data-metric-action="upload" tabindex="0" role="button" aria-label="上传 1 个待补流水账户">
|
||||
<div class="metric-copy"><span class="metric-label">待上传账户</span><strong class="metric-value">1<small>/ 4</small></strong></div>
|
||||
@@ -74,25 +72,23 @@
|
||||
</article>
|
||||
</section>
|
||||
<section class="company-alert"><svg><use href="icons.svg#circle-alert"/></svg><div><strong>7 月数据尚不能结账</strong><p>工商银行账户缺少 07.01—07.21 流水,补齐后系统将重新计算匹配结果。</p></div><button class="button primary" data-open-upload>补传流水</button></section>
|
||||
<section class="panel deck-panel" aria-label="账户覆盖卡栈">
|
||||
<div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户 · 荧光绿为当前连续账户</p></div><span class="status warning">3 / 4 连续</span></div>
|
||||
<div class="stack-deck" role="list">
|
||||
<div class="deck-stage">
|
||||
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="中信银行 5316 全部连续" style="--deck-offset:-200px;--deck-rotate:-14deg;--deck-scale:0.94;opacity:0.92;z-index:2"><span class="deck-pos">04</span><span class="deck-mark">中</span><div class="deck-name"><strong>中信银行 · 5316</strong><small>基本户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 09:42</span><em class="status success">连续</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="农业银行 3650 全部连续" style="--deck-offset:-90px;--deck-rotate:-6deg;--deck-scale:0.98;opacity:0.96;z-index:3"><span class="deck-pos">03</span><span class="deck-mark">农</span><div class="deck-name"><strong>农业银行 · 3650</strong><small>一般户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 08.01</span><em class="status success">连续</em></div></article>
|
||||
<article class="deck-card is-active is-danger" role="listitem" data-open-upload tabindex="0" aria-label="工商银行 9481 缺少 21 天" style="transform:translate(0,0) rotate(0deg) scale(1);z-index:5"><span class="deck-pos">01</span><span class="deck-mark">工</span><div class="deck-name"><strong>工商银行 · 9481</strong><small>一般户</small></div><div class="deck-meta"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><div class="deck-foot"><span>已逾期 2 天</span><em class="status danger">断档</em></div></article>
|
||||
<article class="deck-card" role="listitem" data-view-link="accounts" tabindex="0" aria-label="建设银行 0845 全部连续" style="--deck-offset:90px;--deck-rotate:6deg;--deck-scale:0.98;opacity:0.96;z-index:3"><span class="deck-pos">02</span><span class="deck-mark">建</span><div class="deck-name"><strong>建设银行 · 0845</strong><small>一般户</small></div><div class="deck-meta"><strong>01.01—07.31</strong><small>7 个月连续</small></div><div class="deck-foot"><span>最近导入 08.01</span><em class="status success">连续</em></div></article>
|
||||
</div>
|
||||
<section class="panel company-stack-panel" aria-label="账户覆盖">
|
||||
<div class="panel-heading"><div><h2>账户覆盖</h2><p>A公司 · 4 个银行账户</p></div><span class="status warning">3 / 4 连续</span></div>
|
||||
<div class="company-list" role="list">
|
||||
<article class="company-row" role="listitem" data-detail="acct-citic" tabindex="0" aria-label="中信银行 5316 全部连续,查看详情"><span class="company-row-mark">中</span><div class="company-row-body"><strong>中信银行 · 5316</strong><small>基本户 · 最近导入 09:42</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
|
||||
<article class="company-row" role="listitem" data-detail="acct-abc" tabindex="0" aria-label="农业银行 3650 全部连续,查看详情"><span class="company-row-mark">农</span><div class="company-row-body"><strong>农业银行 · 3650</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
|
||||
<article class="company-row is-danger" role="listitem" data-detail="acct-icbc" tabindex="0" aria-label="工商银行 9481 缺少 21 天,查看详情"><span class="company-row-mark">工</span><div class="company-row-body"><strong>工商银行 · 9481</strong><small>一般户 · 已逾期 2 天</small></div><div class="company-row-figure"><strong>缺口 21 天</strong><small>07.01—07.21</small></div><em class="status danger">断档</em></article>
|
||||
<article class="company-row" role="listitem" data-detail="acct-ccb" tabindex="0" aria-label="建设银行 0845 全部连续,查看详情"><span class="company-row-mark">建</span><div class="company-row-body"><strong>建设银行 · 0845</strong><small>一般户 · 最近导入 08:01</small></div><div class="company-row-figure"><strong>01.01—07.31</strong><small>7 个月连续</small></div><em class="status success">连续</em></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="company-dashboard-grid">
|
||||
<section class="panel task-panel">
|
||||
<div class="panel-heading"><div><h2>需要处理</h2><p>按结账影响和期限排序</p></div></div>
|
||||
<div class="cashier-tasks">
|
||||
<article data-task-type="upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
|
||||
<article data-task-type="match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
|
||||
<article data-task-type="subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
|
||||
<article><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
|
||||
<article data-task-type="upload" data-detail="task-upload"><span class="task-level danger">1</span><div><h3>补传工商银行流水</h3><p>账户尾号 9481 · 缺少 07.01—07.21</p><small>影响:7 月账户覆盖与双边匹配</small></div><button class="button secondary small" data-open-upload>上传</button></article>
|
||||
<article data-task-type="match" data-detail="task-match"><span class="task-level warning">2</span><div><h3>确认 1 笔单边流水</h3><p>对方:B公司 · 07.18 · 280.00 万元</p><small>系统找到 2 个候选,请核对摘要与账号</small></div><button class="button secondary small" data-view-link="reconcile">去匹配</button></article>
|
||||
<article data-task-type="subject" data-detail="task-subject"><span class="task-level warning">3</span><div><h3>确认往来科目</h3><p>对方:C公司 · 06.27 · 600.00 万元</p><small>待确认:应收或其他应收</small></div><button class="button secondary small" data-view-link="reconcile">去确认</button></article>
|
||||
<article data-detail="task-notice"><span class="task-level neutral">4</span><div><h3>阅读总账提醒</h3><p>请于 08.08 前完成 7 月银行流水确认</p><small>管理员 · 今天 09:30</small></div><button class="button secondary small" data-view-link="notifications">查看</button></article>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="panel coverage-panel">
|
||||
@@ -102,6 +98,33 @@
|
||||
</aside>
|
||||
</div>
|
||||
<section class="work-progress" aria-label="本月处理进度"><div><span>1</span><p><strong>流水上传</strong><small>3 / 4 个账户完成</small></p></div><i></i><div><span>2</span><p><strong>异常处理</strong><small>2 项待确认</small></p></div><i></i><div class="pending"><span>3</span><p><strong>公司确认</strong><small>尚未提交</small></p></div><i></i><div class="pending"><span>4</span><p><strong>集团结账</strong><small>次月 5 日</small></p></div></section>
|
||||
<section class="panel timeline-panel" aria-label="账期时间轴">
|
||||
<div class="panel-heading"><div><h2>账期时间轴</h2><p>起算日 2026.01.01 · 每月 5 日结上月账</p></div><span class="status warning">距 7 月结账日 08.05 还有 3 天</span></div>
|
||||
<div class="timeline" role="img" aria-label="2026 年账期时间轴:1 至 6 月已结账,7 月进行中,工商银行 9481 在 07.01 至 07.21 断档,今天 08.08">
|
||||
<div class="timeline-bar">
|
||||
<span class="timeline-seg is-closed" style="left:0;width:81.9%"></span>
|
||||
<span class="timeline-seg is-current" style="left:81.9%;width:15.8%"></span>
|
||||
<span class="timeline-gap" style="left:81.9%;width:9.1%"></span>
|
||||
<i class="timeline-today" style="left:99.1%"></i>
|
||||
</div>
|
||||
<div class="timeline-ticks" aria-hidden="true">
|
||||
<span style="left:0">01.01<small>起算日</small></span>
|
||||
<span style="left:14%">2月</span>
|
||||
<span style="left:26.7%">3月</span>
|
||||
<span style="left:40.7%">4月</span>
|
||||
<span style="left:54.3%">5月</span>
|
||||
<span style="left:68.3%">6月</span>
|
||||
<span style="left:81.9%">7月</span>
|
||||
<span style="left:95.9%">8月</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="timeline-legend">
|
||||
<li><i class="dot success"></i>1—6 月已结账</li>
|
||||
<li><i class="dot warning"></i>7 月进行中 · 08.05 结账</li>
|
||||
<li><i class="dot danger"></i>工行 9481 断档 07.01—07.21</li>
|
||||
<li><i class="dot today"></i>今天 08.08</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="upload">
|
||||
@@ -116,7 +139,7 @@
|
||||
<div class="panel-heading"><div><h2>新增记录</h2><p>A公司 · 提交人:牛女士</p></div></div>
|
||||
<div class="form-body">
|
||||
<div class="form-grid"><label class="field"><span>交易日期</span><input name="transactionDate" type="date" value="2026-08-06" required /></label><label class="field"><span>收付方向</span><select name="direction" required><option>付款</option><option>收款</option></select></label></div>
|
||||
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option><option>个人过账</option></select></label></div>
|
||||
<div class="form-grid"><label class="field"><span>金额(元)</span><input name="amount" type="number" min="0.01" step="0.01" required /></label><label class="field"><span>资金来源</span><select name="sourceAccount" required><option value="">请选择</option><option>个人过账</option></select></label></div>
|
||||
<div class="form-grid"><label class="field"><span>对方类型</span><select name="counterpartyType" required><option>集团内部公司</option><option>个人过账方</option><option>外部单位</option></select></label><label class="field"><span>对方名称</span><input name="counterparty" maxlength="100" placeholder="公司全称或个人姓名" required /></label></div>
|
||||
<div class="form-grid"><label class="field"><span>对方账号</span><input name="counterpartyAccount" maxlength="64" placeholder="可选" /></label><label class="field"><span>往来科目</span><select name="subject" required><option>应收</option><option>应付</option><option>其他应收</option><option>其他应付</option></select></label></div>
|
||||
<label class="field"><span>业务摘要</span><input name="summary" maxlength="120" placeholder="例如:个人代付后转回" required /></label>
|
||||
@@ -147,7 +170,7 @@
|
||||
|
||||
<section class="app-view" data-page="accounts">
|
||||
<header class="page-heading"><div><h1>银行账户</h1><p>A公司登记账户与流水覆盖区间</p></div><button class="button secondary" id="openAccountDialog"><svg><use href="icons.svg#plus"/></svg>登记账户</button></header>
|
||||
<section class="account-directory" id="accountDirectory"><article><header><span class="bank-mark">中</span><div><strong>中信银行</strong><small>基本户 · 尾号 5316</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>今天 09:42</dd></div></dl></article><article><header><span class="bank-mark">工</span><div><strong>工商银行</strong><small>一般户 · 尾号 9481</small></div><em class="status danger">断档</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>缺 07.01—07.21</dd></div><div><dt>最近导入</dt><dd>07.31 16:18</dd></div></dl></article><article><header><span class="bank-mark">建</span><div><strong>建设银行</strong><small>一般户 · 尾号 0845</small></div><em class="status success">连续</em></header><dl><div><dt>启用日期</dt><dd>2026.01.01</dd></div><div><dt>流水覆盖</dt><dd>01.01—07.31</dd></div><div><dt>最近导入</dt><dd>08.01 10:03</dd></div></dl></article></section>
|
||||
<section class="account-directory" id="accountDirectory"></section>
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="notifications">
|
||||
@@ -161,7 +184,7 @@
|
||||
<dialog id="uploadDialog" class="dialog upload-dialog">
|
||||
<form id="uploadForm">
|
||||
<header><div><h2>上传银行流水</h2><p>A公司 · 系统将识别表头与银行模板</p></div><button type="button" class="icon-button" data-close-upload aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option><option>中信银行 · 5316</option><option>工商银行 · 9481</option><option>建设银行 · 0845</option><option>农业银行 · 3650</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div>
|
||||
<div class="dialog-body"><label class="field"><span>银行账户</span><select id="accountSelect" required><option value="">请选择账户</option></select></label><label class="dropzone" id="dropzone"><input id="fileInput" type="file" accept=".xls,.xlsx" /><svg><use href="icons.svg#upload"/></svg><strong>选择或拖入银行流水文件</strong><span>支持 .xls 与 .xlsx,最大 20 MB</span></label><div class="file-preview" id="filePreview" hidden><span class="file-type"><svg><use href="icons.svg#file-spreadsheet"/></svg></span><span><strong id="fileName"></strong><small id="fileMeta"></small></span><button type="button" class="icon-button" id="removeFile" aria-label="移除文件" title="移除文件"><svg><use href="icons.svg#x"/></svg></button></div><div class="parse-result" id="parseResult" hidden><span class="notification-icon"><svg><use href="icons.svg#circle-check"/></svg></span><span><strong>文件解析完成</strong><p id="parseSummary"></p></span></div></div>
|
||||
<footer><button type="button" class="button secondary" data-close-upload>取消</button><button class="button primary" id="parseButton" type="submit" disabled><span>开始解析</span></button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+20
-21
@@ -3,45 +3,44 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="集团内部银行流水与往来账务管理" />
|
||||
<title>登录 · 往来台账</title>
|
||||
<meta name="description" content="河南金牛实业集团内部资金往来与银行流水管理" />
|
||||
<title>登录 · 河南金牛实业集团</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body class="entry-page">
|
||||
<!--
|
||||
THESIS: 登录页只完成身份确认,并明确区分总账管理端与公司业务端。
|
||||
OWN-WORLD: 深黑身份场景、石墨玻璃表单和荧光绿当前状态,延续双端工作台的材料语言。
|
||||
STORY: 用户确认工作身份后进入拥有独立导航、功能侧重和数据边界的对应端口。
|
||||
FIRST VIEWPORT: 左侧用大标题和三个系统事实建立产品身份,右侧玻璃表单首先选择角色再登录。
|
||||
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-context" aria-labelledby="product-name">
|
||||
<div class="entry-brand"><span class="brand-mark">往</span><span><strong id="product-name">往来台账</strong><small>集团资金往来管理系统</small></span></div>
|
||||
<div class="entry-statement">
|
||||
<h1>一笔往来,追溯到双方银行凭证。</h1>
|
||||
<p>当前演示环境 · 数据均为模拟</p>
|
||||
</div>
|
||||
<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>
|
||||
<section class="entry-form-wrap" aria-labelledby="login-title">
|
||||
<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>
|
||||
<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" value="group-admin" required /></label>
|
||||
<label class="field"><span>密码</span><span class="password-field"><input name="password" type="password" autocomplete="current-password" value="demo123456" required /><button type="button" class="inside-icon" id="togglePassword" aria-label="显示密码" title="显示密码"><svg><use href="icons.svg#eye"/></svg></button></span></label>
|
||||
<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>
|
||||
<p class="entry-note">演示账号不会验证密码,正式系统将由服务端校验账号与公司绑定关系。</p>
|
||||
</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>
|
||||
|
||||
+187
-87
@@ -2,12 +2,12 @@
|
||||
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: #050706;
|
||||
--color-bg-soft: #090d0b;
|
||||
--color-nav: #090c0b;
|
||||
--color-surface: rgba(20, 26, 23, 0.82);
|
||||
--color-surface-solid: #131916;
|
||||
--color-surface-raised: #18201c;
|
||||
--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);
|
||||
@@ -31,7 +31,7 @@
|
||||
--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.025);
|
||||
--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;
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
* { 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: 13px/1.55 var(--font-ui); -webkit-font-smoothing: antialiased; }
|
||||
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; }
|
||||
@@ -60,9 +60,9 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* Application frame */
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 252px minmax(0, 1fr); }
|
||||
.sidebar { position: fixed; inset: 18px auto 18px 18px; z-index: 120; width: 234px; display: flex; flex-direction: column; padding: 16px 12px; border: 1px solid var(--color-line); border-radius: var(--radius-xl); background: rgba(12, 16, 14, 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 15px; border-bottom: 1px solid var(--color-line); }
|
||||
.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; }
|
||||
@@ -70,8 +70,8 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
.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: 5px; padding: 14px 2px; overflow-y: auto; }
|
||||
.nav-item { position: relative; width: 100%; min-height: 43px; 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-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)); }
|
||||
@@ -83,13 +83,14 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
.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: 78px; display: flex; align-items: center; gap: 13px; padding: 0 30px; border-bottom: 1px solid rgba(255, 255, 255, 0.055); background: rgba(5, 7, 6, 0.82); backdrop-filter: blur(22px); }
|
||||
.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(230px, 26vw, 390px); 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 { 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; }
|
||||
@@ -100,12 +101,16 @@ strong, .amount, .number, dd { font-variant-numeric: tabular-nums; }
|
||||
.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: 32px 32px 58px; }
|
||||
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: 70px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||
.page-heading h1 { font-size: clamp(26px, 3vw, 35px); line-height: 1.15; letter-spacing: 0; }
|
||||
.page-heading p { margin-top: 7px; color: var(--color-ink-muted); }
|
||||
.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); }
|
||||
@@ -135,8 +140,9 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.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: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(120%); }
|
||||
.panel::before, .period-ribbon::before, .reconcile-summary::before, .work-progress::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 32%); pointer-events: none; }
|
||||
.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); }
|
||||
@@ -153,14 +159,15 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.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: 14px; margin: 0 0 18px; }
|
||||
.metric-card { position: relative; min-height: 142px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-content: space-between; gap: 14px; padding: 20px; overflow: hidden; border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-low); backdrop-filter: blur(24px) saturate(125%); 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(168deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 38%); 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; }
|
||||
.metric-card:hover { transform: translateY(-4px); border-color: rgba(255, 255, 255, 0.17); box-shadow: 0 24px 54px rgba(0, 0, 0, 0.3); }
|
||||
.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: 12px; }
|
||||
.metric-value { display: block; margin-top: 7px; font: 700 31px/1 var(--font-data); letter-spacing: 0; }
|
||||
.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; }
|
||||
@@ -202,49 +209,24 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.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); }
|
||||
|
||||
/* Stack-deck: signature 3D card-stack visual */
|
||||
.stack-deck { position: relative; height: 188px; perspective: 1400px; perspective-origin: 50% 60%; }
|
||||
.deck-stage { position: absolute; inset: 0; display: grid; place-items: center; }
|
||||
.deck-card { position: absolute; width: 184px; min-height: 158px; display: flex; flex-direction: column; justify-content: space-between; gap: 14px; padding: 16px; border: 1px solid var(--color-line); border-radius: var(--radius-md); background: linear-gradient(180deg, rgba(28, 36, 32, 0.92), rgba(14, 19, 16, 0.92)); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35), inset 0 1px rgba(255, 255, 255, 0.05); transform-origin: 50% 100%; transition: transform var(--duration-standard) var(--ease-out), box-shadow var(--duration-standard), border-color var(--duration-standard), opacity var(--duration-standard); cursor: pointer; }
|
||||
.deck-card::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(168deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0) 36%); pointer-events: none; }
|
||||
.deck-card > * { position: relative; }
|
||||
.deck-card .deck-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 11px; background: var(--color-primary-wash); color: var(--color-primary); font-weight: 800; font-size: 13px; }
|
||||
.deck-card .deck-name { display: flex; flex-direction: column; }
|
||||
.deck-card .deck-name strong { font-size: 14px; }
|
||||
.deck-card .deck-name small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.deck-card .deck-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.deck-card .deck-meta strong { font: 600 16px var(--font-data); color: var(--color-ink); }
|
||||
.deck-card .deck-meta small { color: var(--color-ink-muted); font-size: 11px; }
|
||||
.deck-card .deck-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 10px; color: var(--color-ink-muted); }
|
||||
.deck-card.is-active { border-color: rgba(55, 235, 137, 0.55); background: linear-gradient(180deg, rgba(20, 56, 36, 0.95), rgba(8, 22, 14, 0.95)); box-shadow: 0 28px 60px rgba(0, 0, 0, 0.45), 0 0 36px rgba(55, 235, 137, 0.22), inset 0 1px rgba(112, 255, 174, 0.18); }
|
||||
.deck-card.is-active .deck-mark { background: rgba(255, 255, 255, 0.08); color: var(--color-primary-strong); box-shadow: inset 0 0 0 1px rgba(112, 255, 174, 0.4); }
|
||||
.deck-card.is-active .deck-name strong { color: var(--color-primary-strong); }
|
||||
.deck-card.is-warning { border-color: rgba(255, 188, 82, 0.35); }
|
||||
.deck-card.is-danger { border-color: rgba(255, 98, 109, 0.35); }
|
||||
.deck-card .deck-pos { position: absolute; top: 12px; right: 12px; font: 700 10px var(--font-data); color: var(--color-ink-muted); letter-spacing: 0.06em; }
|
||||
.deck-card.is-active .deck-pos { color: var(--color-primary-strong); }
|
||||
|
||||
/* Stack-deck fan layout */
|
||||
.deck-stage .deck-card:hover { transform: translateY(-12px) scale(1.02); box-shadow: 0 28px 60px rgba(0, 0, 0, 0.5); opacity: 1; z-index: 6; }
|
||||
.deck-stage .deck-card.is-active:hover { transform: translateY(-10px) scale(1.04); box-shadow: 0 32px 70px rgba(0, 0, 0, 0.55), 0 0 40px rgba(55, 235, 137, 0.3), inset 0 1px rgba(112, 255, 174, 0.18); }
|
||||
.deck-panel { padding-bottom: 6px; overflow: visible; }
|
||||
.deck-panel > .panel-heading { background: var(--color-surface); position: relative; z-index: 7; }
|
||||
.deck-panel .stack-deck { overflow: visible; padding: 12px 16px 18px; }
|
||||
|
||||
/* Deck card fan layout driven by --deck-offset / --deck-rotate */
|
||||
.deck-card { --deck-offset: 0px; --deck-rotate: 0deg; --deck-scale: 1; transform: translate(var(--deck-offset), 6px) rotate(var(--deck-rotate)) scale(var(--deck-scale)); }
|
||||
.deck-stage .deck-card:nth-child(1) { --deck-offset: -280px; --deck-rotate: -22deg; --deck-scale: 0.92; z-index: 1; opacity: 0.85; }
|
||||
.deck-stage .deck-card:nth-child(2) { --deck-offset: -165px; --deck-rotate: -12deg; --deck-scale: 0.96; z-index: 2; opacity: 0.92; }
|
||||
.deck-stage .deck-card:nth-child(3) { --deck-offset: -55px; --deck-rotate: -4deg; z-index: 3; }
|
||||
.deck-stage .deck-card:nth-child(4) { --deck-offset: 55px; --deck-rotate: 4deg; z-index: 4; }
|
||||
.deck-stage .deck-card:nth-child(5) { --deck-offset: 165px; --deck-rotate: 12deg; --deck-scale: 0.96; z-index: 2; opacity: 0.92; }
|
||||
.deck-stage .deck-card:nth-child(6) { --deck-offset: 280px; --deck-rotate: 22deg; --deck-scale: 0.92; z-index: 1; opacity: 0.85; }
|
||||
.deck-stage .deck-card:nth-child(7) { --deck-offset: 395px; --deck-rotate: 30deg; --deck-scale: 0.88; z-index: 0; opacity: 0.7; }
|
||||
.deck-stage .deck-card:nth-child(8) { --deck-offset: -395px; --deck-rotate: -30deg; --deck-scale: 0.88; z-index: 0; opacity: 0.7; }
|
||||
.deck-card.is-active { z-index: 5; }
|
||||
/* 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(20, 26, 23, 0.86), rgba(10, 14, 12, 0.86)); box-shadow: var(--shadow-low); backdrop-filter: blur(22px) saturate(125%); }
|
||||
.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; }
|
||||
@@ -409,7 +391,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.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, 23, 20, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(28px); }
|
||||
.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); }
|
||||
@@ -431,14 +413,14 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.parse-result.is-exception { border-color: rgba(255, 188, 82, 0.25); background: var(--color-warning-wash); }
|
||||
.parse-result.is-exception p { color: #e7bd78; }
|
||||
.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, 26, 23, 0.96); color: var(--color-ink); box-shadow: var(--shadow-panel); backdrop-filter: blur(20px); }
|
||||
.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: #070a08; }
|
||||
.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; }
|
||||
@@ -519,7 +501,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
.demo-badge { padding: 2px 6px; }
|
||||
.period-button { display: none; }
|
||||
.topbar-actions .button { width: 40px; padding: 0; font-size: 0; }
|
||||
main { padding: 22px 13px 42px; }
|
||||
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; }
|
||||
@@ -592,34 +574,152 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 32px 32px 58px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.stack-deck { height: 168px; }
|
||||
.deck-card { width: 168px; min-height: 144px; padding: 14px; }
|
||||
.deck-stage .deck-card:nth-child(1), .deck-stage .deck-card:nth-child(6) { opacity: 0; pointer-events: none; }
|
||||
.deck-stage .deck-card:nth-child(1) { transform: translate(-260px, 14px) rotate(-22deg) scale(0.85); }
|
||||
.deck-stage .deck-card:nth-child(6) { transform: translate(260px, 14px) rotate(22deg) scale(0.85); }
|
||||
.deck-stage .deck-card:nth-child(2) { transform: translate(-160px, 6px) rotate(-12deg) scale(0.94); }
|
||||
.deck-stage .deck-card:nth-child(5) { transform: translate(160px, 6px) rotate(12deg) scale(0.94); }
|
||||
.timeline-strip { grid-template-columns: 1fr; }
|
||||
.timeline-legend { padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stack-deck { height: auto; padding: 0 4px 8px; }
|
||||
.deck-stage { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.deck-stage .deck-card { position: relative; transform: none !important; opacity: 1 !important; width: auto; }
|
||||
.deck-card .deck-pos { right: 10px; }
|
||||
.company-row { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row-figure { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stack-deck { height: auto; padding-bottom: 4px; }
|
||||
.deck-stage { grid-template-columns: 1fr; gap: 8px; }
|
||||
.deck-card { width: auto; min-height: 0; padding: 12px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user