Compare commits

...
Author SHA1 Message Date
acc3c29c49 HEL-351: 修复夜间下拉/日期弹层、一次性初始密码领取与缩放滚动条
夜间控件改走现有 token 与 color-scheme,日期弹层锚定触发器并可翻转;
创建公司账号改为一次性可复制口令窗口,列表与后续接口不再回明文。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 21:58:56 +08:00
总工 9d06445d81 HEL-343: 合并 HEL-282 事务/审计修复(9193a3f) 与 HEL-342 双主题施工(b2bcd47) 作为外网测试环境发布基线 2026-09-01 19:49:17 +08:00
b2bcd47e62 HEL-342: 管理总览 KPI 绑定真实 dashboard 接口
去掉写死的演示金额与账期进度,改由 /api/admin/dashboard 的 totals 与 period_progress 填充;无数据走空态。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 19:32:59 +08:00
b905cba52c HEL-342: 全站切换霜曜日间与黑金夜间主题
按定稿把运行时 token 换成霜曜/黑金双主题,登录与壳层接入官方 Logo 与主题记忆,不改业务接口与金额计算。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 17:44:35 +08:00
9193a3fce0 HEL-282: 写库路径事务提交与审计留痕自查修复
同类未提交即 close 回滚、业务与审计拆成两笔事务的路径一并收进可嵌套事务边界。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 23:02:14 +08:00
总工andmultica-agent 9e0e4a103a HEL-271: 反代 tmpfs 属主与运行 uid 对齐(修复非 root nginx 启动)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 22:52:42 +08:00
总工andmultica-agent 2d0ae4d7d1 HEL-271: 反代证书挂载收紧为单文件并按属主 uid 运行(server.key 保持 0600)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 22:51:20 +08:00
总工andmultica-agent 9cfd515b8f HEL-271: compose 多环境隔离参数化 + 正式 HTTPS 反代(nginx/tls profile)与证书工具
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 22:49:35 +08:00
42 changed files with 2825 additions and 556 deletions
+6
View File
@@ -28,3 +28,9 @@ package-lock.json
# local vendor for agent test env (not shipped)
.vendor/
vendor_wheels/
# 正式环境部署产物(证书私钥、备份)
deploy/tls/certs/
backups/
ALERT.log
backup.log
+3 -2
View File
@@ -1,6 +1,8 @@
# Design Tokens
This file maps the shipped dark visual system to its implementation source in `web/styles.css`. `DESIGN.md` is the portable design contract; the `:root` custom properties in `web/styles.css` are the runtime source of truth.
**HEL-342 定稿:** 运行时视觉来源是 `web/design-system.css` 的霜曜日间 + 黑金夜间变量(`.v-fusion` / `[data-theme="night"]`),依据 `jinniu-fusion-design`。禁止退回旧青绿 token 或叠第二套覆盖层。主题偏好只存 `localStorage.jinniu-theme``day` | `night`),不跟随系统、不恢复业务数据。
下文是历史暗色合同存档,不再作为施工依据。
## Architecture
@@ -8,7 +10,6 @@ This file maps the shipped dark visual system to its implementation source in `w
- Keep shared primitives in `:root`; keep component behavior in its existing semantic rule group.
- Business-component rules must not use `!important`.
- Only accessibility utilities (`.sr-only`, `[hidden]`) and `prefers-reduced-motion` enforcement may force priority.
- Do not restore warm-paper, white-card, cobalt-action, cream, or light-theme aliases. New code must consume the dark semantic tokens directly.
- Keep administrator and cashier portals as independent route-level surfaces even when they share tokens and component primitives.
## Typography
+13
View File
@@ -16,3 +16,16 @@ APP_BOOTSTRAP_ADMIN_PASSWORD=change-me-in-local-env
# 内网测试可暂时关闭登录失败锁定;正式环境不得开启
# APP_LOGIN_RATE_LIMIT_DISABLED=1
# ---- 同宿主机第二套环境(正式)必须设置的隔离参数 ----
# compose 项目名(默认 deploy;正式环境改为 caiwuzongzhang-prod
# COMPOSE_PROJECT_NAME=caiwuzongzhang-prod
# 应用容器名与宿主机端口映射(正式默认只绑本机回环,由反代对外)
# APP_CONTAINER_NAME=caiwuzongzhang-prod-app
# APP_PORT_MAP=127.0.0.1:4174:4173
# ---- 正式 HTTPS 反代(COMPOSE_PROFILES=tls 启用 proxy 服务)----
# COMPOSE_PROFILES=tls
# PROXY_CONTAINER_NAME=caiwuzongzhang-proxy
# PROXY_PORT_MAP=8443:8443
# 首次启用前先执行 ./tls/gen-cert.sh 生成证书
+49
View File
@@ -46,6 +46,45 @@ docker exec caiwuzongzhang-app python -c "import sqlite3; s=sqlite3.connect('/ap
docker cp caiwuzongzhang-app:/tmp/b.db ../backups/app.db.$STAMP
```
## 3.1 正式环境部署(同宿主机隔离,2026-08-30 首次上线采用)
测试与正式同宿主机并行,靠 compose 项目名/容器名/端口三隔离:
| 项 | 测试环境 | 正式环境 |
|---|---|---|
| 目录 | `/home/leefer/caiwuzongzhang-test` | `/home/leefer/caiwuzongzhang-prod` |
| compose 项目 | `deploy`(默认) | `COMPOSE_PROJECT_NAME=caiwuzongzhang-prod` |
| 应用容器 | `caiwuzongzhang-app` | `caiwuzongzhang-prod-app` |
| 端口 | `4173`(对外) | `127.0.0.1:4174`(仅本机,反代上游) |
| HTTPS | 无 | nginx 反代 `8443``COMPOSE_PROFILES=tls` |
| 访问 | http://192.168.200.36:4173/ | https://192.168.200.36:8443/ |
首次部署顺序:
```bash
git clone <repo> /home/leefer/caiwuzongzhang-prod && cd /home/leefer/caiwuzongzhang-prod
git checkout release/prod
mkdir -p data/files backups && chown -R 10001:10001 data # 容器内 app uid
cd deploy
cp .env.example .env # 按下述差异填写
./tls/gen-cert.sh # 生成内部 CA + 服务器证书(SAN 含对外 IP)
TAG=<发布镜像tag> docker compose up -d # 不带 --build,直接用已验收镜像
docker logs <应用容器> 2>&1 | grep "shown once" # 首启一次性初始口令,只出现一次
```
正式 `.env` 与测试的差异(铁律):
- `COMPOSE_PROJECT_NAME=caiwuzongzhang-prod``APP_CONTAINER_NAME=caiwuzongzhang-prod-app``APP_PORT_MAP=127.0.0.1:4174:4173`
- `COMPOSE_PROFILES=tls``PROXY_CONTAINER_NAME=caiwuzongzhang-proxy``PROXY_PORT_MAP=8443:8443`
- `APP_UID=10001` / `APP_GID=10001`data/ 已 chown 10001
- 管理员用户名自定(如 `jinniu-admin`);**不设** `APP_BOOTSTRAP_ADMIN_PASSWORD`(首启控制台取随机一次性口令,首登立即改密)
- **不设** `APP_LOGIN_RATE_LIMIT_DISABLED`(限流默认开启)
证书与信任分发:
- `tls/certs/ca.crt` 发给各公司电脑安装到「受信任的根证书颁发机构」(安装指引见交接文档);`ca.key`/`server.key` 永不离开宿主机。
- 服务器证书有效期 5 年,到期前用 `./tls/gen-cert.sh` 重签并 `docker compose restart proxy`
## 4. 回滚(应用层,5 分钟内)
```bash
@@ -68,6 +107,16 @@ cd deploy && ./rollback.sh <上一个镜像tag>
cron `*/5 * * * *` 运行 `deploy/monitor.sh`;连续失败或磁盘 ≥80% 时查看 `deploy/ALERT.log` 并按需扩容/清理。容器重启策略 `unless-stopped`
宿主机 crontableefer2026-08-30 起,正式环境):
```cron
15 2 * * * cd /home/leefer/caiwuzongzhang-prod/deploy && CONTAINER=caiwuzongzhang-prod-app ./backup.sh >> backup.log 2>&1
*/5 * * * * cd /home/leefer/caiwuzongzhang-prod/deploy && CONTAINER=caiwuzongzhang-prod-app HEALTH_URL=http://127.0.0.1:4174/ ./monitor.sh >/dev/null 2>&1
01 3 1 * * rsync -a /home/leefer/caiwuzongzhang-prod/backups/monthly/ /vol1/caiwuzongzhang-backups/monthly/
```
(第三行为月度异盘副本:`/vol1` 是与系统盘不同的物理卷;如后续提供真正的异机目标,改为该目标。)
## 7. 升级数据库
应用启动时自动执行迁移(`MIGRATIONS`,当前版本 10)。迁移只前不改写历史;回退 schema 用对应 down 迁移,先备份后操作。
+55 -2
View File
@@ -1,10 +1,16 @@
# 测试/正式环境部署编排(正式口令不得写入本文件)。
# 使用:复制 .env.example 为 .env 填写;TAG 指定镜像标签(默认取当前提交 sha)。
# 例:TAG=v1.0.0-rc1 docker compose -f compose.yaml up -d --build
#
# 同宿主机多套环境(测试 + 正式)必须各自设置:
# COMPOSE_PROJECT_NAME / APP_CONTAINER_NAME / APP_PORT_MAP 互不相同。
# 正式环境启用 HTTPS 反代:.env 中设 COMPOSE_PROFILES=tls(先跑 tls/gen-cert.sh)。
name: ${COMPOSE_PROJECT_NAME:-deploy}
services:
app:
container_name: caiwuzongzhang-app
container_name: ${APP_CONTAINER_NAME:-caiwuzongzhang-app}
image: caiwuzongzhang:${TAG:-latest}
build:
context: ..
@@ -15,7 +21,7 @@ services:
# APP_UID/APP_GID,正式与测试环境各自填写,默认镜像内 app=10001)
user: "${APP_UID:-10001}:${APP_GID:-10001}"
ports:
- "4173:4173"
- "${APP_PORT_MAP:-4173:4173}"
volumes:
- ../data:/app/data
init: true
@@ -30,9 +36,56 @@ services:
pids_limit: 128
mem_limit: 512m
cpus: 1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=2)"]
interval: 15s
timeout: 3s
retries: 5
start_period: 10s
# 可选 HTTPS 反向代理(正式环境用):COMPOSE_PROFILES=tls 启用。
# 证书由 tls/gen-cert.sh 生成的内部 CA 签发(内网自受管信任),公司电脑
# 安装 tls/certs/ca.crt 一次即可无告警访问。
proxy:
container_name: ${PROXY_CONTAINER_NAME:-caiwuzongzhang-proxy}
image: nginx:1.27-alpine
profiles: ["tls"]
ports:
- "${PROXY_PORT_MAP:-8443:8443}"
volumes:
- ./tls/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./tls/certs/server.crt:/etc/nginx/tls/server.crt:ro
- ./tls/certs/server.key:/etc/nginx/tls/server.key:ro
# 与证书文件属主一致的 uid/gid(默认 101=镜像 nginx 用户;宿主机生成证书时
# 设为属主 uid/gid,保持 server.key 0600 不放宽)
user: "${PROXY_UID:-101}:${PROXY_GID:-101}"
depends_on:
- app
restart: unless-stopped
read_only: true
tmpfs:
- /var/cache/nginx:size=16m,uid=${PROXY_UID:-101},gid=${PROXY_GID:-101},mode=700
- /var/run:size=1m,uid=${PROXY_UID:-101},gid=${PROXY_GID:-101},mode=700
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 64
mem_limit: 64m
cpus: 0.5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "wget", "-q", "--no-check-certificate", "-O", "/dev/null", "https://127.0.0.1:8443/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 生成内网专用 CA 与服务器证书(自受管内部信任,不涉及公网域名)。
# 用法:在正式环境 deploy/ 目录执行 ./tls/gen-cert.sh
# 可用环境变量覆盖:
# CERT_IP 对外访问 IP(默认 192.168.200.36
# CERT_DNS 额外 DNS 名称(空格分隔,默认 caiwuzongzhang.jinniu.internal
# 输出:tls/certs/{ca.crt,ca.key,server.crt,server.key}key 永不入库、不分发)
# 分发:仅把 ca.crt 发给公司电脑安装(受信任的根证书颁发机构)。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
CERTS="$ROOT/certs"
mkdir -p "$CERTS"
cd "$CERTS"
CERT_IP="${CERT_IP:-192.168.200.36}"
CERT_DNS="${CERT_DNS:-caiwuzongzhang.jinniu.internal}"
SAN="IP:${CERT_IP}"
for d in $CERT_DNS; do SAN="$SAN,DNS:$d"; done
if [[ ! -f ca.key ]]; then
# 内部根 CA:10 年,仅本系统使用
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout ca.key -out ca.crt -days 3650 -nodes -subj "/CN=Jinniu Ledger Internal CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
chmod 600 ca.key
echo "ca 已生成"
fi
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout server.key -out server.csr -nodes \
-subj "/CN=${CERT_DNS%% *}" >/dev/null 2>&1
cat > server.ext <<EXT
subjectAltName=${SAN}
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
EXT
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 1825 -sha256 -extfile server.ext >/dev/null 2>&1
chmod 600 server.key
rm -f server.csr server.ext ca.srl
echo "server 证书已生成(SAN: ${SAN}),有效期 5 年"
echo "分发文件:${CERTS}/ca.crt(其余文件不得离开本机)"
+31
View File
@@ -0,0 +1,31 @@
# 正式环境 HTTPS 反代(nginx)。证书挂载自 deploy/tls/certs/gen-cert.sh 生成)。
# 上游为同 compose 网络内的 app:4173;对外仅暴露本代理的 8443。
server {
listen 8443 ssl;
listen [::]:8443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:2m;
ssl_session_timeout 1h;
# 银行流水 Excel 上传上限
client_max_body_size 25m;
proxy_http_version 1.1;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
location / {
proxy_pass http://app:4173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
+126
View File
@@ -0,0 +1,126 @@
"""HEL-342: static visual shots + computed-style probe (no business data writes)."""
from __future__ import annotations
import json
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = ROOT.parent / "hel342-shots"
PROBES = [
(".sidebar", ["width", "backgroundColor", "backdropFilter"]),
(".brand-logo-day", ["width", "height"]),
(".btn-primary", ["backgroundColor", "height", "borderRadius", "color"]),
(".stat-card.gold .stat-value, .stat-card .stat-value", ["color", "fontSize", "fontVariantNumeric"]),
(".page-head h1", ["fontSize", "fontWeight"]),
(".ds-table th", ["fontSize", "color"]),
]
def main() -> None:
from playwright.sync_api import sync_playwright
OUT.mkdir(parents=True, exist_ok=True)
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{httpd.server_address[1]}"
report = []
pages = [
("index", "index.html", None, None),
("login-admin", "login-admin.html", None, None),
("login-company", "login-company.html", None, None),
("admin-dashboard", "admin.html", "dashboard", None),
("admin-flows", "admin.html", "flows", None),
("admin-settings", "admin.html", "settings", None),
("admin-audit", "admin.html", "audit", None),
("admin-reminders", "admin.html", "reminders", None),
("admin-period-audit", "admin.html", "period-audit", None),
("admin-companies", "admin.html", "companies", None),
("admin-pair", "admin.html", "pair", None),
("company-workspace", "company.html", "workspace", None),
("company-transfers", "company.html", "transfers", None),
("company-reconcile", "company.html", "reconcile", None),
("company-upload", "company.html", "upload", None),
]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
for theme in ("day", "night"):
for width, height, tag in ((1440, 900, "1440"), (820, 900, "820"), (390, 844, "390")):
if tag != "1440" and theme == "night" and width != 390:
continue
page = browser.new_page(viewport={"width": width, "height": height})
page.add_init_script(
f"() => {{ try {{ localStorage.setItem('jinniu-theme', '{theme}'); }} catch (e) {{}} }}"
)
for name, html, view, _ in pages:
if tag != "1440" and name not in ("login-admin", "admin-flows", "company-transfers", "admin-dashboard"):
continue
page.route("**/app.js**", lambda route: route.abort())
page.goto(f"{base}/{html}", wait_until="domcontentloaded")
page.evaluate(
"""(args) => {
document.documentElement.classList.add('v-fusion');
document.documentElement.setAttribute('data-theme', args.theme);
if (args.view) {
document.querySelectorAll('.app-view').forEach((el) => {
el.classList.toggle('is-active', el.dataset.page === args.view);
});
}
}""",
{"theme": theme, "view": view},
)
page.wait_for_timeout(80)
shot = OUT / f"{name}-{theme}-{tag}.png"
page.screenshot(path=str(shot), full_page=False)
if tag == "1440" and name in ("login-admin", "admin-dashboard", "admin-flows"):
probe = page.evaluate(
"""(sels) => sels.map(([sel, props]) => {
const el = document.querySelector(sel);
if (!el) return { sel, missing: true };
const cs = getComputedStyle(el);
const out = { sel };
props.forEach((p) => { out[p] = cs[p]; });
const box = el.getBoundingClientRect();
out.box = { w: Math.round(box.width), h: Math.round(box.height) };
return out;
})""",
PROBES,
)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1"
)
report.append({"page": name, "theme": theme, "overflow": overflow, "probe": probe})
page.close()
# 390 drawer
page = browser.new_page(viewport={"width": 390, "height": 844})
page.add_init_script("() => { try { localStorage.setItem('jinniu-theme', 'day'); } catch (e) {} }")
page.route("**/app.js**", lambda route: route.abort())
page.goto(f"{base}/company.html", wait_until="domcontentloaded")
page.evaluate(
"""() => {
document.documentElement.classList.add('v-fusion');
document.querySelectorAll('.app-view').forEach((el) => {
el.classList.toggle('is-active', el.dataset.page === 'transfers');
});
const d = document.getElementById('transferEvidenceDrawer');
if (d) d.classList.add('is-open');
}"""
)
page.screenshot(path=str(OUT / "company-transfers-drawer-day-390.png"), full_page=False)
page.close()
browser.close()
httpd.shutdown()
(OUT / "probe.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"wrote {OUT} ({len(list(OUT.glob('*.png')))} png)")
if __name__ == "__main__":
main()
+89 -88
View File
@@ -18,7 +18,7 @@ from bank_importer import (
manual_records, master_data, matching, multipart, period_close, personal_transit,
positions, reminders, settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
from bank_importer.db import connect, migrate, transaction, utc_now
ROOT = Path(__file__).resolve().parent
@@ -2615,22 +2615,22 @@ class AppHandler(SimpleHTTPRequestHandler):
)
return
try:
result = matching.reconcile_rows(connection, writable, actor=user)
with transaction(connection):
result = matching.reconcile_rows(connection, writable, actor=user)
ledger_events.reconcile_bank_events(connection, actor=user)
result["late_arrivals"] = late
auth.audit(
connection, "transfer_reconcile", actor=user,
target=f"rows:{len(writable)}",
detail=(
f"created:{result['created_events']};"
f"updated:{result['updated_events']};late:{late}"
),
ip=self._client_ip,
)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
result["late_arrivals"] = late
auth.audit(
connection, "transfer_reconcile", actor=user,
target=f"rows:{len(writable)}",
detail=f"created:{result['created_events']};updated:{result['updated_events']};late:{late}",
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "matching": result, "late_arrivals": late})
finally:
connection.close()
@@ -2660,19 +2660,21 @@ class AppHandler(SimpleHTTPRequestHandler):
return
try:
period_close.assert_event_writable(connection, event_id)
payload = matching.apply_manual_decision(
connection,
event_id,
action,
reason=str(data.get("reason") or ""),
expected_revision=expected_revision,
request_key=str(data.get("request_key") or "") or None,
actor=user,
source_row_ids=[int(item) for item in source_row_ids]
if source_row_ids
else None,
participant=data.get("participant"),
)
with transaction(connection):
payload = matching.apply_manual_decision(
connection,
event_id,
action,
reason=str(data.get("reason") or ""),
expected_revision=expected_revision,
request_key=str(data.get("request_key") or "") or None,
actor=user,
source_row_ids=[int(item) for item in source_row_ids]
if source_row_ids
else None,
participant=data.get("participant"),
)
ledger_events.reconcile_bank_events(connection, actor=user)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
@@ -2682,8 +2684,6 @@ class AppHandler(SimpleHTTPRequestHandler):
except matching.MatchInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
@@ -2976,19 +2976,21 @@ class AppHandler(SimpleHTTPRequestHandler):
reason = str(data.get("reason") or "").strip() or "公司端确认单边流水"
try:
period_close.assert_event_writable(connection, event_id)
payload = matching.apply_manual_decision(
connection,
event_id,
"assign_participant",
reason=reason,
expected_revision=expected_revision,
request_key=request_key,
actor=user,
participant={
"role": role,
"company_id": counterparty_company_id,
},
)
with transaction(connection):
payload = matching.apply_manual_decision(
connection,
event_id,
"assign_participant",
reason=reason,
expected_revision=expected_revision,
request_key=request_key,
actor=user,
participant={
"role": role,
"company_id": counterparty_company_id,
},
)
ledger_events.reconcile_bank_events(connection, actor=user)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
@@ -2998,8 +3000,6 @@ class AppHandler(SimpleHTTPRequestHandler):
except matching.MatchInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
@@ -3417,51 +3417,56 @@ class AppHandler(SimpleHTTPRequestHandler):
period_close.assert_ledger_writable(connection, event_id)
if action in ("adjust", "reverse") and data.get("effective_at"):
period_close.assert_date_writable(connection, str(data.get("effective_at")))
if action == "reverse":
event_id, _revision_id = ledger_events.create_reversal(
connection, event_id,
source_kind="adjustment",
source_revision_token=None,
effective_at=data.get("effective_at") or None,
reason=reason, actor=user, idempotency_key=request_key,
)
outcome: dict[str, object] = {
"action": "reverse", "ledger_event_id": event_id,
}
elif action == "adjust":
try:
effective_at = str(data.get("effective_at") or "")
amount = str(data.get("amount") or "")
currency = str(data.get("currency") or "")
payer = int(data["payer_company_id"])
payee = int(data["payee_company_id"])
perspective = int(data["perspective_company_id"])
subject_code = str(data.get("subject_code") or "")
except (KeyError, TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "adjust 参数不完整或无效。"}
)
return
event_id, _revision_id = ledger_events.create_adjustment(
connection, event_id,
effective_at=effective_at, amount=amount, currency=currency,
payer_company_id=payer, payee_company_id=payee,
perspective_company_id=perspective, subject_code=subject_code,
reason=reason, actor=user, idempotency_key=request_key,
)
outcome = {"action": "adjust", "ledger_event_id": event_id}
elif action == "reopen":
event_id, _revision_id = ledger_events.reopen_subject(
connection, event_id, reason=reason, actor=user,
idempotency_key=request_key,
)
outcome = {"action": "reopen", "ledger_event_id": event_id}
else:
if action not in ("reverse", "adjust", "reopen"):
self._send_json(
400,
{"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"},
)
return
with transaction(connection):
if action == "reverse":
event_id, _revision_id = ledger_events.create_reversal(
connection, event_id,
source_kind="adjustment",
source_revision_token=None,
effective_at=data.get("effective_at") or None,
reason=reason, actor=user, idempotency_key=request_key,
)
outcome = {
"action": "reverse", "ledger_event_id": event_id,
}
elif action == "adjust":
try:
effective_at = str(data.get("effective_at") or "")
amount = str(data.get("amount") or "")
currency = str(data.get("currency") or "")
payer = int(data["payer_company_id"])
payee = int(data["payee_company_id"])
perspective = int(data["perspective_company_id"])
subject_code = str(data.get("subject_code") or "")
except (KeyError, TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "adjust 参数不完整或无效。"}
)
return
event_id, _revision_id = ledger_events.create_adjustment(
connection, event_id,
effective_at=effective_at, amount=amount, currency=currency,
payer_company_id=payer, payee_company_id=payee,
perspective_company_id=perspective, subject_code=subject_code,
reason=reason, actor=user, idempotency_key=request_key,
)
outcome = {"action": "adjust", "ledger_event_id": event_id}
else:
event_id, _revision_id = ledger_events.reopen_subject(
connection, event_id, reason=reason, actor=user,
idempotency_key=request_key,
)
outcome = {"action": "reopen", "ledger_event_id": event_id}
auth.audit(
connection, f"ledger_{action}", actor=user,
target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip,
)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
@@ -3471,10 +3476,6 @@ class AppHandler(SimpleHTTPRequestHandler):
except ledger_events.LedgerInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection, f"ledger_{action}", actor=user,
target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip,
)
self._send_json(200, {"status": "ok", **outcome})
finally:
connection.close()
+2 -2
View File
@@ -18,7 +18,7 @@ import secrets
import sqlite3
import string
from .db import utc_now
from .db import transaction, utc_now
MIN_PASSWORD_LENGTH = 8
@@ -308,7 +308,7 @@ def audit(
ip: str | None = None,
) -> None:
"""Append an audit log entry. Never pass passwords in ``detail``."""
with connection:
with transaction(connection):
connection.execute(
"""
INSERT INTO audit_log (
+24 -10
View File
@@ -7,8 +7,8 @@ from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from . import master_data, matching
from .db import transaction, utc_now
from . import auth, master_data, matching
SETTING_START_DATE = "calculation_start_date"
@@ -81,7 +81,7 @@ def set_calculation_start_date(
raise LockedError("已有结账月份,起算日已锁定。")
before = get_calculation_start_date(connection)
now = utc_now()
with connection:
with transaction(connection):
connection.execute(
"""
INSERT INTO system_settings (key, value, updated_at, updated_by)
@@ -185,7 +185,7 @@ def create_opening_balance(
raise ConflictError("该对公司已有确认期初,请使用修订。")
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
@@ -235,7 +235,7 @@ def confirm_opening_balance(
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("确认期初必须填写原因。")
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
@@ -278,7 +278,7 @@ def revise_opening_balance(
high_id = int(row["company_id_high"])
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
with transaction(connection):
connection.execute(
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
(revision_id,),
@@ -333,7 +333,7 @@ def void_opening_balance(
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("作废期初必须填写原因。")
with connection:
with transaction(connection):
connection.execute(
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
(reason, revision_id),
@@ -551,7 +551,7 @@ def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
"SELECT * FROM bank_accounts WHERE status = 'active'"
).fetchall()
rebuilt = 0
with connection:
with transaction(connection):
for account in accounts:
connection.execute(
"""
@@ -668,7 +668,7 @@ def submit_no_business_attestation(
if account["company_id"] != company_id:
raise ValueError("只能为本公司账户提交说明。")
now = utc_now()
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO no_business_attestations (
@@ -688,6 +688,13 @@ def submit_no_business_attestation(
),
)
attestation_id = int(cursor.lastrowid)
auth.audit(
connection,
"attestation_submit",
actor=actor,
target=f"attestation:{attestation_id}",
detail=f"account:{bank_account_id};gap:{gap_start}..{gap_end}",
)
return attestation_payload(connection, attestation_id)
@@ -712,7 +719,7 @@ def review_no_business_attestation(
raise ValueError("审核必须填写理由。")
status = "approved" if decision == "approve" else "rejected"
now = utc_now()
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE no_business_attestations
@@ -748,6 +755,13 @@ def review_no_business_attestation(
""",
(row["bank_account_id"], row["gap_end"], row["gap_start"]),
)
auth.audit(
connection,
f"attestation_{decision}",
actor=actor,
target=f"attestation:{attestation_id}",
detail=review_reason,
)
return attestation_payload(connection, attestation_id)
+112 -1
View File
@@ -8,6 +8,7 @@ as null so the UI shows an em dash rather than a guessed label.
from __future__ import annotations
from calendar import monthrange
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
import sqlite3
@@ -22,6 +23,12 @@ def today_shanghai() -> str:
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
def _month_bounds(year_month: str) -> tuple[str, str]:
year, month = (int(part) for part in year_month.split("-"))
last = monthrange(year, month)[1]
return f"{year_month}-01", f"{year_month}-{last:02d}"
def _q2(value: Decimal) -> str:
return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP))
@@ -209,6 +216,108 @@ def company_summaries(
return items, totals
def period_progress(
connection: sqlite3.Connection, *, year_month: str
) -> dict[str, object]:
"""Per-company coverage of the cutoff month: done / in progress / unsubmitted.
Only companies with at least one active bank account are counted — the same
rule as monthly-close submission checks. Covered = confirmed source rows in
the month, or an approved no-business attestation that spans the month.
"""
start, end = _month_bounds(year_month)
enabled_rows = connection.execute(
"SELECT DISTINCT company_id FROM bank_accounts WHERE status = 'active'"
).fetchall()
enabled_ids = {int(row["company_id"]) for row in enabled_rows}
empty = {
"year_month": year_month,
"enabled_count": 0,
"done": 0,
"in_progress": 0,
"unsubmitted": 0,
"percent": 0,
}
if not enabled_ids:
return empty
submitted = {
int(row["company_id"])
for row in connection.execute(
"""
SELECT DISTINCT b.company_id AS company_id
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN import_batches b ON b.id = s.import_batch_id
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
WHERE date(r.transaction_at) >= date(?) AND date(r.transaction_at) <= date(?)
""",
(start, end),
).fetchall()
}
attested = {
int(row["company_id"])
for row in connection.execute(
"""
SELECT DISTINCT a.company_id AS company_id
FROM no_business_attestations a
WHERE a.status = 'approved'
AND date(a.gap_start) <= date(?)
AND date(a.gap_end) >= date(?)
""",
(end, start),
).fetchall()
}
covered = (submitted | attested) & enabled_ids
pending: set[int] = set()
for row in connection.execute(
"""
SELECT DISTINCT p.company_id AS company_id
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_decision_participants p ON p.decision_id = d.id
WHERE e.lifecycle = 'active'
AND d.classification IN ('unresolved', 'needs_review')
AND date(d.effective_at) >= date(?) AND date(d.effective_at) <= date(?)
""",
(start, end),
):
pending.add(int(row["company_id"]))
for row in connection.execute(
"""
SELECT DISTINCT m.company_id AS company_id
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE d.state = 'pending'
AND date(m.occurred_at) >= date(?) AND date(m.occurred_at) <= date(?)
""",
(start, end),
):
pending.add(int(row["company_id"]))
for row in connection.execute(
"SELECT DISTINCT company_id FROM bank_accounts WHERE status = 'pending'"
):
pending.add(int(row["company_id"]))
pending_enabled = pending & enabled_ids
done = covered - pending_enabled
in_progress = pending_enabled
unsubmitted = enabled_ids - covered - pending_enabled
enabled_count = len(enabled_ids)
percent = round(100 * len(done) / enabled_count) if enabled_count else 0
return {
"year_month": year_month,
"enabled_count": enabled_count,
"done": len(done),
"in_progress": len(in_progress),
"unsubmitted": len(unsubmitted),
"percent": percent,
}
def company_peer_groups(
connection: sqlite3.Connection,
company_id: int,
@@ -378,13 +487,15 @@ def build_dashboard(
companies, totals = company_summaries(
connection, from_date=from_date, cutoff=cutoff_date
)
period_label = f"{period.year}-{period.month:02d}"
return {
"from_date": from_date,
"cutoff": cutoff_date,
"period_month": period_month,
"period_label": f"{period.year}-{period.month:02d}",
"period_label": period_label,
"audit": audit_counts(connection),
"totals": totals,
"period_progress": period_progress(connection, year_month=period_label),
"companies": companies,
"weekly_flow": weekly_flow(connection, cutoff=cutoff_date),
"opening_status": "unavailable",
+27
View File
@@ -10,10 +10,12 @@ version order; each records itself in ``schema_migrations`` so re-running
from __future__ import annotations
import argparse
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import sqlite3
from typing import Iterator
DEFAULT_DB_PATH = Path("data/app.db")
@@ -23,6 +25,31 @@ def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@contextmanager
def transaction(connection: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Own a write transaction only when the caller has not already started one.
Nested helpers join the outer boundary so business rows and their audit
trail commit or roll back together. Standalone callers still commit before
return, so ``connection.close()`` cannot silently drop the work (HEL-270).
``sqlite3.Connection`` as a context manager always commits on exit even
when it did not begin the transaction; do not use it for nestable writes.
"""
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
yield connection
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
@dataclass(frozen=True)
class Migration:
version: int
+8 -8
View File
@@ -650,6 +650,14 @@ def review_sheets(
actor=actor,
)
ledger_events.reconcile_bank_events(connection, actor=actor)
if updated:
auth.audit(
connection,
f"sheet_{decision}",
actor=actor,
target=f"batch:{batch_id}",
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
)
if began:
connection.commit()
except Exception:
@@ -657,14 +665,6 @@ def review_sheets(
connection.rollback()
raise
if updated:
auth.audit(
connection,
f"sheet_{decision}",
actor=actor,
target=f"batch:{batch_id}",
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
)
payload: dict[str, object] = {"updated": updated, "already": already}
if matching_result is not None:
payload["matching"] = matching_result
+63 -58
View File
@@ -18,7 +18,7 @@ from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .db import transaction, utc_now
from .subjects import MIRROR, SUBJECTS, mirror_subject
@@ -428,26 +428,30 @@ def reopen_subject(
raise LedgerInputError(
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
)
if not _has_reversal(connection, ledger_event_id):
create_reversal(
connection, ledger_event_id,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
reason="科目复核:原确认事件冲销",
actor=actor,
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
rule_version=current["rule_version"],
)
ev = connection.execute(
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
(bank_claim["bank_event_id"],),
).fetchone()
if ev is None:
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
event_id, revision_id = _create_bank_event(
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
replacing_claim=bank_claim,
)
# One nestable transaction: reversal, replacement event, source re-claim
# and suggestions commit together. create_event used to commit on its own,
# leaving the bank-source UPDATE uncommitted for connection.close().
with transaction(connection):
if not _has_reversal(connection, ledger_event_id):
create_reversal(
connection, ledger_event_id,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
reason="科目复核:原确认事件冲销",
actor=actor,
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
rule_version=current["rule_version"],
)
event_id, revision_id = _create_bank_event(
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
replacing_claim=bank_claim,
)
return event_id, revision_id
@@ -553,51 +557,52 @@ def _create_bank_event(
reason: str,
replacing_claim: sqlite3.Row | None = None,
) -> tuple[int, int]:
event_id, revision_id = create_event(
connection,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason=reason,
)
if replacing_claim is not None:
connection.execute(
"""
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
WHERE bank_event_id = ?
""",
(event_id, event["event_id"]),
with transaction(connection):
event_id, revision_id = create_event(
connection,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason=reason,
)
else:
connection.execute(
"""
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
VALUES (?, ?)
""",
(event["event_id"], event_id),
)
from .subjects import store_suggestions
if replacing_claim is not None:
connection.execute(
"""
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
WHERE bank_event_id = ?
""",
(event_id, event["event_id"]),
)
else:
connection.execute(
"""
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
VALUES (?, ?)
""",
(event["event_id"], event_id),
)
from .subjects import store_suggestions
store_suggestions(connection, event_id)
store_suggestions(connection, event_id)
return event_id, revision_id
+31 -34
View File
@@ -17,7 +17,7 @@ import json
import re
import sqlite3
from .db import utc_now
from .db import transaction, utc_now
ACCOUNT_TYPES = ("基本户", "一般户", "专用户")
@@ -143,7 +143,7 @@ def create_company(
raise ValueError("公司名称不能为空。")
now = utc_now()
try:
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO companies (
@@ -153,16 +153,15 @@ def create_company(
(name, (credit_code or "").strip() or None,
(cashier_name or "").strip() or None, now, now),
)
company_id = int(cursor.lastrowid)
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,
)
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
@@ -210,7 +209,7 @@ def submit_bank_account(
if existing is None:
try:
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO bank_accounts (
@@ -222,23 +221,22 @@ def submit_bank_account(
(company_id, number, holder, bank, kind,
requested_from, actor["id"] if actor else None, now, now),
)
account_id = int(cursor.lastrowid)
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,
)
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:
with transaction(connection):
connection.execute(
"""
UPDATE bank_accounts
@@ -287,7 +285,7 @@ def review_bank_account(
if account["status"] != "pending":
raise ConflictError("只有待复核的账户可以审核通过。")
start = validate_date(effective_from, "启用日期") or account["effective_from"] or today
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE bank_accounts
@@ -306,7 +304,7 @@ def review_bank_account(
raise ConflictError("只有待复核的账户可以退回。")
if reason is None:
raise ValueError("退回必须填写原因。")
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE bank_accounts
@@ -326,7 +324,7 @@ def review_bank_account(
if reason is None:
raise ValueError("停用必须填写原因。")
end = validate_date(effective_to, "停用日期") or today
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE bank_accounts
@@ -448,7 +446,7 @@ def add_alias(
if start and end and end < start:
raise ValueError("别名失效日期不能早于生效日期。")
try:
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO account_aliases (
@@ -459,17 +457,16 @@ def add_alias(
(account_id, alias_kind, value, rank, start, end,
actor["id"] if actor else None, utc_now()),
)
alias_id = int(cursor.lastrowid)
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,
)
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
+28 -28
View File
@@ -33,7 +33,7 @@ import re
import sqlite3
from .auth import audit
from .db import utc_now
from .db import transaction, utc_now
from .master_data import (
is_identifiable,
normalize_account_number,
@@ -1457,36 +1457,36 @@ def rebuild_current_projection(connection: sqlite3.Connection) -> int:
no current pointer and no claims. Returns the number of current decisions
rebuilt. Intended as a recovery/consistency entry point.
"""
connection.execute("DELETE FROM transfer_observation_claims")
connection.execute("DELETE FROM current_transfer_decisions")
events = connection.execute(
"""
SELECT e.id AS event_id,
(SELECT d2.id FROM transfer_match_decisions d2
WHERE d2.event_id = e.id
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
FROM canonical_transfer_events e
WHERE e.lifecycle = 'active'
"""
).fetchall()
rebuilt = 0
for event in events:
if event["latest_id"] is None:
continue
latest = connection.execute(
"SELECT mode FROM transfer_match_decisions WHERE id = ?",
(event["latest_id"],),
).fetchone()
if latest is None or latest["mode"] == MODE_REVERSAL:
continue
observations = connection.execute(
with transaction(connection):
connection.execute("DELETE FROM transfer_observation_claims")
connection.execute("DELETE FROM current_transfer_decisions")
events = connection.execute(
"""
SELECT source_row_id FROM transfer_decision_observations
WHERE decision_id = ? ORDER BY id
SELECT e.id AS event_id,
(SELECT d2.id FROM transfer_match_decisions d2
WHERE d2.event_id = e.id
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
FROM canonical_transfer_events e
WHERE e.lifecycle = 'active'
""",
(event["latest_id"],),
).fetchall()
with connection:
for event in events:
if event["latest_id"] is None:
continue
latest = connection.execute(
"SELECT mode FROM transfer_match_decisions WHERE id = ?",
(event["latest_id"],),
).fetchone()
if latest is None or latest["mode"] == MODE_REVERSAL:
continue
observations = connection.execute(
"""
SELECT source_row_id FROM transfer_decision_observations
WHERE decision_id = ? ORDER BY id
""",
(event["latest_id"],),
).fetchall()
connection.execute(
"""
INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id)
@@ -1502,7 +1502,7 @@ def rebuild_current_projection(connection: sqlite3.Connection) -> int:
""",
(observation["source_row_id"], event["event_id"], event["latest_id"]),
)
rebuilt += 1
rebuilt += 1
return rebuilt
+10 -11
View File
@@ -16,7 +16,7 @@ import json
import re
import sqlite3
from .db import utc_now
from .db import transaction, utc_now
from . import calculation, dashboard, settings as settings_mod
@@ -237,10 +237,9 @@ def record_late_arrivals(
return 0
now = utc_now()
inserted = 0
# One transaction so the rows and their audit trail commit or roll back
# together; without it the caller's connection.close() silently rolled
# the late-arrival records back while the API still reported them.
with connection:
# Nestable transaction: rows and their audit trail commit or roll back
# together. ``with connection:`` would commit an outer caller early.
with transaction(connection):
for row_id, year_month in items:
existing = connection.execute(
"SELECT id FROM period_late_arrivals WHERE source_row_id = ?",
@@ -530,7 +529,7 @@ def ensure_pending_tasks(
first = earliest_month(connection) or last
created: list[str] = []
month = first
with connection:
with transaction(connection):
while month <= last:
existing = _current_run(connection, month)
if existing is None:
@@ -598,7 +597,7 @@ def execute_close(
snapshot = build_snapshot(connection, year_month)
digest = _hash_payload(snapshot)
now = utc_now()
with connection:
with transaction(connection):
if current is not None and current["status"] == "pending":
version = int(current["version"])
report_no = _report_no(year_month, version)
@@ -665,7 +664,7 @@ def mark_close_failed(
reason = str(reason or "").strip() or "结账失败"
current = _current_run(connection, year_month)
now = utc_now()
with connection:
with transaction(connection):
if current is None:
version = 1
connection.execute(
@@ -727,7 +726,7 @@ def request_reopen(
if pending is not None:
raise PeriodConflictError("该账期已有待审批的重开申请。")
now = utc_now()
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO period_reopen_requests (
@@ -786,7 +785,7 @@ def decide_reopen(
close_run = connection.execute(
"SELECT * FROM period_close_runs WHERE id = ?", (row["period_close_id"],)
).fetchone()
with connection:
with transaction(connection):
if approve:
window_end = (
datetime.now(timezone.utc) + timedelta(days=int(row["window_days"]))
@@ -873,7 +872,7 @@ def expire_reopen_windows(
for row in rows:
year_month = row["year_month"]
now = utc_now()
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE period_close_runs
+15 -16
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import sqlite3
from .db import utc_now
from .db import transaction, utc_now
from .master_data import (
ConflictError,
mask_account_number,
@@ -67,7 +67,7 @@ def submit_mapping(
).fetchone()
if existing is None:
try:
with connection:
with transaction(connection):
cursor = connection.execute(
"""
INSERT INTO personal_transit_mappings (
@@ -81,22 +81,21 @@ def submit_mapping(
start, actor["id"] if actor else None, now, now,
),
)
mapping_id = int(cursor.lastrowid)
record_change(
connection, "personal_transit_mapping", mapping_id, "submit", None,
{"account_number": number, "account_name": holder,
"represented_company_id": represented_company_id,
"allowed_direction": direction, "status": "pending",
"effective_from": start},
None, actor,
)
except sqlite3.IntegrityError as exc:
raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") from exc
mapping_id = int(cursor.lastrowid)
with connection:
record_change(
connection, "personal_transit_mapping", mapping_id, "submit", None,
{"account_number": number, "account_name": holder,
"represented_company_id": represented_company_id,
"allowed_direction": direction, "status": "pending",
"effective_from": start},
None, actor,
)
return get_mapping(connection, mapping_id)
if existing["status"] == "returned":
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE personal_transit_mappings
@@ -143,7 +142,7 @@ def review_mapping(
if mapping["status"] != "pending":
raise ConflictError("只有待复核的映射可以审核通过。")
start = validate_date(effective_from, "生效日期") or mapping["effective_from"] or today
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE personal_transit_mappings
@@ -162,7 +161,7 @@ def review_mapping(
raise ConflictError("只有待复核的映射可以退回。")
if reason is None:
raise ValueError("退回必须填写原因。")
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE personal_transit_mappings
@@ -182,7 +181,7 @@ def review_mapping(
if reason is None:
raise ValueError("停用必须填写原因。")
end = validate_date(effective_to, "停用日期") or today
with connection:
with transaction(connection):
connection.execute(
"""
UPDATE personal_transit_mappings
+2 -2
View File
@@ -12,7 +12,7 @@ import re
import sqlite3
from datetime import datetime
from .db import utc_now
from .db import utc_now, transaction
# Defaults are applied when a key is absent; the value type is always string.
@@ -93,7 +93,7 @@ def update_settings(
if not cleaned:
raise ValueError("没有需要保存的设置项。")
current = get_settings(connection)
with connection:
with transaction(connection):
for key, new_value in cleaned.items():
old_value = current.get(key)
if old_value == new_value:
+60
View File
@@ -349,6 +349,66 @@ class CoverageGapTests(CalculationBase):
).fetchone()
self.assertEqual("closed_attested", closed["status"])
def test_attestation_and_audit_survive_connection_close(self) -> None:
"""HEL-282: attestation writes used to skip the change log; review also
nested-committed coverage recalculation before the overlap close."""
from bank_importer.db import connect as db_connect
self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-06-21T10:00:00",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE status = 'open'"
).fetchone()
cashier_id = auth.create_user(
self.connection, "cashier-close", "CashierA123", "company", self.company_a
)
cashier = self.connection.execute(
"SELECT * FROM users WHERE id = ?", (cashier_id,)
).fetchone()
att = calculation.submit_no_business_attestation(
self.connection,
company_id=self.company_a,
bank_account_id=self.account_a["id"],
gap_start=gap["gap_start"],
gap_end=gap["gap_end"],
reason="当日账户无资金往来",
evidence=None,
actor=cashier,
)
calculation.review_no_business_attestation(
self.connection, att["id"], "approve", "审核通过", self.admin
)
att_id = att["id"]
self.connection.close()
fresh = db_connect(self.db_path)
try:
row = fresh.execute(
"SELECT status FROM no_business_attestations WHERE id = ?", (att_id,)
).fetchone()
actions = [
item["action"]
for item in fresh.execute(
"""
SELECT action FROM audit_log
WHERE action LIKE 'attestation_%'
ORDER BY id
"""
).fetchall()
]
closed = fresh.execute(
"SELECT status FROM coverage_gaps WHERE id = ?", (gap["id"],)
).fetchone()
finally:
fresh.close()
self.assertEqual("approved", row["status"])
self.assertEqual(["attestation_submit", "attestation_approve"], actions)
self.assertEqual("closed_attested", closed["status"])
class BalanceBasisTests(CalculationBase):
def setUp(self) -> None:
+1 -1
View File
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=15", html)
self.assertIn("app.js?v=18", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
+2 -2
View File
@@ -31,8 +31,8 @@ class TransfersPageSourceContractTests(unittest.TestCase):
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=7", html)
self.assertIn("app.js?v=15", html)
self.assertIn("design-system.css?v=13", html)
self.assertIn("app.js?v=18", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
+73
View File
@@ -33,6 +33,42 @@ class DashboardUnitTests(unittest.TestCase):
self.assertEqual(0, payload["audit"]["total"])
self.assertEqual([], payload["companies"])
self.assertEqual(7, len(payload["weekly_flow"]["labels"]))
self.assertEqual(0, payload["totals"]["company_count"])
self.assertEqual(0, payload["totals"]["detail_count"])
self.assertEqual("0.00", payload["totals"]["debit_wan"])
self.assertEqual("0.00", payload["totals"]["credit_wan"])
progress = payload["period_progress"]
self.assertEqual("2026-08", progress["year_month"])
self.assertEqual(0, progress["enabled_count"])
self.assertEqual(0, progress["done"])
self.assertEqual(0, progress["in_progress"])
self.assertEqual(0, progress["unsubmitted"])
self.assertEqual(0, progress["percent"])
def test_period_progress_unsubmitted_when_account_active_no_flows(self) -> None:
now = master_data.utc_now()
cursor = self.connection.execute(
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
(now, now),
)
company_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO bank_accounts (
company_id, account_number, bank_name, account_type, status,
created_at, updated_at
) VALUES (?, '6222020000000099', '工行', '一般户', 'active', ?, ?)
""",
(company_id, now, now),
)
self.connection.commit()
progress = dashboard.period_progress(self.connection, year_month="2026-08")
self.assertEqual(1, progress["enabled_count"])
self.assertEqual(0, progress["done"])
self.assertEqual(0, progress["in_progress"])
self.assertEqual(1, progress["unsubmitted"])
self.assertEqual(0, progress["percent"])
def test_pending_account_counts_as_medium(self) -> None:
now = master_data.utc_now()
@@ -267,6 +303,9 @@ class DashboardApiTests(unittest.TestCase):
self.assertEqual(200, status)
self.assertEqual("ok", data["status"])
self.assertIn("audit", data)
self.assertIn("totals", data)
self.assertIn("period_progress", data)
self.assertIn("debit_wan", data["totals"])
self.assertEqual(1, len(data["companies"]))
self.assertEqual("甲公司", data["companies"][0]["name"])
@@ -494,5 +533,39 @@ class DashboardApiTests(unittest.TestCase):
self.assertEqual([], detail["groups"])
class DashboardPageContractTests(unittest.TestCase):
"""HEL-343 阻断项:管理总览 KPI 不得写死演示金额。"""
def test_admin_html_kpi_placeholders_not_demo_amounts(self) -> None:
html = (Path(__file__).resolve().parents[1] / "web" / "admin.html").read_text(
encoding="utf-8"
)
self.assertIn('id="dashPageSub"', html)
self.assertIn('id="dashDebitValue"', html)
self.assertIn('id="dashCreditValue"', html)
self.assertIn('id="dashPeriodPercent"', html)
self.assertIn('id="dashPeriodBar"', html)
self.assertIn('id="dashPeriodFoot"', html)
self.assertNotIn("42,040.30", html)
self.assertNotIn("39,040.30", html)
self.assertNotIn("8.11 亿", html)
self.assertNotIn("392 笔", html)
self.assertNotIn("3 家已完成 · 2 家在途 · 1 家未提交", html)
self.assertNotRegex(html, r'id="flowStart"[^>]*value="2026-07-01"')
self.assertIn('id="pending-check-all"', html)
self.assertIn("pending-check-all", html)
def test_app_js_binds_dashboard_totals(self) -> None:
js = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(
encoding="utf-8"
)
self.assertIn("function applyDashKpis(", js)
self.assertIn("applyDashKpis(data)", js)
self.assertIn("dashDebitValue", js)
self.assertIn("dashCreditValue", js)
self.assertIn("period_progress", js)
self.assertIn("master.disabled = boxes.length === 0", js)
if __name__ == "__main__":
unittest.main()
+271
View File
@@ -0,0 +1,271 @@
"""HEL-351: 夜间控件、一次性初始密码交付、缩放滚动条契约。"""
from __future__ import annotations
import os
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel351SourceContractTests(unittest.TestCase):
def test_night_select_and_scrollbar_use_tokens(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("color-scheme: light", css)
self.assertIn("color-scheme: dark", css)
self.assertIn("scrollbar-color: var(--gold) var(--surface-2)", css)
self.assertIn("html[data-theme=\"night\"] ::-webkit-scrollbar-thumb", css)
self.assertIn("background-color: var(--gold)", css)
self.assertIn(".select option", css)
self.assertIn("background-color: var(--surface)", css)
self.assertIn("html[data-theme=\"night\"] .ds-dp-day.is-selected", css)
self.assertIn("color: var(--gold)", css)
self.assertNotIn("overflow: hidden; /* HEL-351", css)
def test_tabs_and_table_split_overflow_axes(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
tabs = css[css.index(".tabs {") : css.index(".tabs button")]
self.assertIn("overflow-x: auto", tabs)
self.assertIn("overflow-y: hidden", tabs)
wrap = css[css.index(".table-wrap {") : css.index(".table-wrap.dash-master-scroll")]
self.assertIn("overflow-x: auto", wrap)
self.assertIn("overflow-y: hidden", wrap)
self.assertIn(".table-wrap.dash-master-scroll", css)
self.assertIn("overflow-y: auto", css[css.index(".table-wrap.dash-master-scroll") :][:180])
def test_admin_delivers_once_password_window(self) -> None:
html = (WEB / "admin.html").read_text(encoding="utf-8")
self.assertIn('id="credentialDialog"', html)
self.assertIn('id="cred-pass"', html)
self.assertIn('id="cred-copy-pass"', html)
self.assertIn("datepicker.js?v=1", html)
self.assertNotIn("初始密码由管理员统一发放", html)
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertIn("function showOnceCredentials(", js)
self.assertIn("function wipeCredentials(", js)
self.assertNotIn("初始密码已生成(仅此一次显示):${result.initial_password}", js)
self.assertNotIn("临时密码已生成(仅此一次):${result.initial_password}", js)
self.assertIn('openModal("credentialDialog")', js)
def test_datepicker_follows_trigger_and_flips(self) -> None:
js = (WEB / "datepicker.js").read_text(encoding="utf-8")
css = (WEB / "design-system.css").read_text(encoding="utf-8")
self.assertIn("visualViewport", js)
self.assertIn("getBoundingClientRect", js)
self.assertIn("rect.top - gap - height", js)
self.assertIn('addEventListener("scroll", position, true)', js)
block = css[css.index(".ds-datepicker {") : css.index(".ds-datepicker[hidden]")]
self.assertIn("position: fixed", block)
self.assertIn("z-index: var(--z-tooltip)", block)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针")
class Hel351BrowserProbeTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open(self, page, *, night: bool, width: int = 1440, zoom: float = 1) -> None:
page.set_viewport_size({"width": width, "height": 900})
theme_attr = 'data-theme="night"' if night else 'data-theme="day"'
page.set_content(
f"""<!doctype html>
<html lang="zh-CN" class="v-fusion" {theme_attr}>
<head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" {theme_attr}>
<div class="tabs" id="probe-tabs">
<button class="active">全部</button>
<button>流水断档</button>
<button>单边匹配</button>
<button>科目确认</button>
<button>起算区间校准</button>
<button>银行账户登记</button>
<button>公司手工记录</button>
<button>重开审批</button>
</div>
<select class="select" id="probe-select">
<option>河南金牛农业科技发展有限公司</option>
<option>河南金牛煤业有限公司</option>
</select>
<input class="input" id="probe-date" type="date" value="2026-09-01" />
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_function("() => window.JinniuDatePicker")
if zoom != 1:
page.evaluate(f"() => {{ document.body.style.zoom = '{zoom}'; }}")
def test_night_select_is_not_white_on_white(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open(page, night=True)
colors = page.evaluate(
"""() => {
const sel = document.getElementById('probe-select');
const cs = getComputedStyle(sel);
const opt = getComputedStyle(sel.options[0]);
return {
scheme: getComputedStyle(document.documentElement).colorScheme,
bg: cs.backgroundColor,
color: cs.color,
optionBg: opt.backgroundColor,
optionColor: opt.color,
};
}"""
)
browser.close()
self.assertEqual("dark", colors["scheme"])
self.assertNotEqual("rgb(255, 255, 255)", colors["bg"])
self.assertNotEqual("rgb(255, 255, 255)", colors["color"])
self.assertNotEqual("rgb(255, 255, 255)", colors["optionBg"])
def test_tabs_have_no_vertical_scrollbar_at_zoom(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 390, "height": 800})
self._open(page, night=False, width=390, zoom=1.25)
metrics = page.evaluate(
"""() => {
const tabs = document.getElementById('probe-tabs');
const cs = getComputedStyle(tabs);
return {
overflowX: cs.overflowX,
overflowY: cs.overflowY,
clientHeight: tabs.clientHeight,
scrollHeight: tabs.scrollHeight,
clientWidth: tabs.clientWidth,
scrollWidth: tabs.scrollWidth,
};
}"""
)
browser.close()
self.assertEqual("auto", metrics["overflowX"])
self.assertEqual("hidden", metrics["overflowY"])
self.assertLessEqual(metrics["scrollHeight"] - metrics["clientHeight"], 1)
def test_datepicker_anchors_and_night_gold(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 900})
self._open(page, night=True)
page.click("#probe-date")
page.wait_for_selector("#ds-datepicker:not([hidden])")
box = page.evaluate(
"""() => {
const input = document.getElementById('probe-date');
const panel = document.getElementById('ds-datepicker');
const ir = input.getBoundingClientRect();
const pr = panel.getBoundingClientRect();
const selected = panel.querySelector('.ds-dp-day.is-selected');
const cs = selected ? getComputedStyle(selected) : null;
return {
inputBottom: ir.bottom,
inputLeft: ir.left,
panelTop: pr.top,
panelLeft: pr.left,
panelRight: pr.right,
viewportWidth: window.innerWidth,
selectedColor: cs && cs.color,
selectedBg: cs && cs.backgroundColor,
hidden: panel.hidden,
};
}"""
)
browser.close()
self.assertFalse(box["hidden"])
self.assertLess(abs(box["panelTop"] - box["inputBottom"]), 24)
self.assertLess(abs(box["panelLeft"] - box["inputLeft"]), 24)
self.assertLess(box["panelRight"], box["viewportWidth"])
self.assertIsNotNone(box["selectedColor"])
self.assertNotEqual("rgb(255, 255, 255)", box["selectedBg"])
self.assertIn("217", box["selectedColor"])
def test_datepicker_flips_when_near_bottom(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1440, "height": 500})
page.set_content(
f"""<!doctype html>
<html lang="zh-CN" class="v-fusion" data-theme="night">
<head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" data-theme="night" style="min-height:100vh">
<input class="input" id="probe-date" type="date" value="2026-09-01" style="position:fixed;left:24px;bottom:12px;width:180px" />
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_function("() => window.JinniuDatePicker")
page.click("#probe-date")
page.wait_for_selector("#ds-datepicker:not([hidden])")
box = page.evaluate(
"""() => {
const input = document.getElementById('probe-date');
const panel = document.getElementById('ds-datepicker');
const ir = input.getBoundingClientRect();
const pr = panel.getBoundingClientRect();
return { inputTop: ir.top, panelBottom: pr.bottom, panelTop: pr.top };
}"""
)
browser.close()
self.assertLess(box["panelBottom"], box["inputTop"] + 2)
self.assertGreater(box["inputTop"] - box["panelBottom"], 0)
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -257,6 +257,56 @@ class ProjectionTests(LedgerBase):
self.assertEqual("confirmed", revision["state"])
self.assertEqual("receivable", revision["subject_code"])
def test_reopen_subject_survives_connection_close(self) -> None:
"""HEL-282: create_event used to commit the replacement event while
the bank-source re-claim stayed uncommitted; close() dropped the claim."""
from bank_importer.db import connect as db_connect
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
original_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, original_id,
perspective_company_id=self.company_a, subject_code="receivable",
reason="确认应收", expected_revision=1, request_key="k1",
actor=self.admin,
)
new_id, _ = ledger_events.reopen_subject(
self.connection, original_id,
reason="科目复核更正为其他应收", actor=self.admin,
)
self.connection.close()
fresh = db_connect(self.db_path)
try:
claim = fresh.execute(
"SELECT ledger_event_id FROM ledger_event_bank_sources"
).fetchone()
new_state = fresh.execute(
"""
SELECT r.state FROM current_ledger_event_revisions c
JOIN ledger_event_revisions r ON r.id = c.revision_id
WHERE c.ledger_event_id = ?
""",
(new_id,),
).fetchone()
suggestions = fresh.execute(
"SELECT COUNT(*) AS n FROM ledger_subject_suggestions WHERE ledger_event_id = ?",
(new_id,),
).fetchone()["n"]
reversal = fresh.execute(
"""
SELECT COUNT(*) AS n FROM ledger_event_revisions
WHERE posting_kind = 'reversal' AND reverses_ledger_event_id = ?
""",
(original_id,),
).fetchone()["n"]
finally:
fresh.close()
self.assertEqual(new_id, claim["ledger_event_id"])
self.assertEqual("pending_subject", new_state["state"])
self.assertGreaterEqual(suggestions, 1)
self.assertEqual(1, reversal)
class SubjectSuggestionTests(LedgerBase):
def test_mirror_mapping_is_symmetric(self) -> None:
+34
View File
@@ -210,6 +210,40 @@ class MasterDataUnitTests(unittest.TestCase):
)
class MasterDataCommitTests(unittest.TestCase):
"""File-database checks that business rows and audit share one commit."""
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
def test_create_company_and_audit_survive_connection_close(self) -> None:
company_id = master_data.create_company(
self.connection, "丁公司", None, None, actor=None
)
self.connection.close()
fresh = connect(self.db_path)
try:
company = fresh.execute(
"SELECT name FROM companies WHERE id = ?", (company_id,)
).fetchone()
change = fresh.execute(
"""
SELECT action, entity_id FROM master_data_changes
WHERE entity_type = 'company'
"""
).fetchone()
finally:
fresh.close()
self.assertEqual("丁公司", company["name"])
self.assertEqual("create", change["action"])
self.assertEqual(company_id, change["entity_id"])
class MasterDataApiTests(unittest.TestCase):
"""Live-server workflow tests for account registration and review."""
+32
View File
@@ -943,6 +943,38 @@ class ProjectionRebuildTests(MatchingBase):
self.assertEqual(sorted(before), sorted(after))
self.assertEqual(sorted(claims_before), sorted(claims_after))
def test_rebuild_clears_stale_projection_and_survives_close(self) -> None:
"""HEL-282: DELETEs used to stay uncommitted when nothing was restored."""
from bank_importer.db import connect as db_connect
row_a = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
row_b = self.add_row(
self.company_b, own_account="6222000000000002",
cp_account="6222000000000001", income="100.00",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
with self.connection:
self.connection.execute(
"UPDATE canonical_transfer_events SET lifecycle = 'superseded'"
)
matching.rebuild_current_projection(self.connection)
self.connection.close()
fresh = db_connect(self.db_path)
try:
remaining = fresh.execute(
"SELECT COUNT(*) AS n FROM current_transfer_decisions"
).fetchone()["n"]
claims = fresh.execute(
"SELECT COUNT(*) AS n FROM transfer_observation_claims"
).fetchone()["n"]
finally:
fresh.close()
self.assertEqual(0, remaining)
self.assertEqual(0, claims)
class ConcurrentReconcileTests(MatchingBase):
def test_concurrent_reconcile_creates_one_event(self) -> None:
+36
View File
@@ -189,6 +189,42 @@ class PeriodCloseTests(LedgerBase):
self.assertEqual(1, versions[0]["version"])
self.assertEqual(2, versions[-1]["version"])
def test_close_and_reopen_request_survive_connection_close(self) -> None:
"""HEL-282: monthly close / reopen request must persist with audit."""
from bank_importer.db import connect as db_connect
self._cover_month()
closed = self._close()
req = period_close.request_reopen(
self.connection, self.MONTH, self.admin,
reason="补录金牛煤业七月运输费并核对金额",
)
report_no = closed["report_no"]
request_id = req["id"]
self.connection.close()
fresh = db_connect(self.db_path)
try:
run = fresh.execute(
"SELECT status, report_no FROM period_close_runs WHERE year_month = ?",
(self.MONTH,),
).fetchone()
reopen = fresh.execute(
"SELECT status FROM period_reopen_requests WHERE id = ?",
(request_id,),
).fetchone()
actions = {
row["action"]
for row in fresh.execute(
"SELECT action FROM period_audit_events"
).fetchall()
}
finally:
fresh.close()
self.assertEqual("closed", run["status"])
self.assertEqual(report_no, run["report_no"])
self.assertEqual("pending", reopen["status"])
self.assertTrue({"close_execute", "reopen_request"} <= actions)
def test_wal_on_file_database(self) -> None:
mode = self.connection.execute("PRAGMA journal_mode").fetchone()[0]
self.assertEqual("wal", str(mode).lower())
+3 -3
View File
@@ -37,8 +37,8 @@ class RemindersPageSourceContractTests(unittest.TestCase):
self.assertIn('id="reminder-tbody"', html)
self.assertIn('id="reminder-tabs"', html)
self.assertIn('id="reminder-detail-drawer"', html)
self.assertIn("design-system.css?v=10", html)
self.assertIn("app.js?v=15", html)
self.assertIn("design-system.css?v=13", html)
self.assertIn("app.js?v=18", html)
pending = html.index('id="pending-reminders-card"')
history = html.index('id="reminder-history-card"')
send = html.index('id="send-reminder-card"')
@@ -135,7 +135,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase):
def test_send_flow_columns_and_no_page_overflow(self) -> None:
html = (WEB / "admin.html").read_text(encoding="utf-8")
self.assertIn("app.js?v=15", html)
self.assertIn("app.js?v=18", html)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
+6 -1
View File
@@ -540,6 +540,9 @@ class ServerAuthMatrixTests(unittest.TestCase):
status, _, data = self.admin.get("/api/admin/users")
self.assertEqual(200, status)
bodies.append(data)
status, _, data = self.admin.get("/api/admin/companies")
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)
@@ -548,7 +551,9 @@ class ServerAuthMatrixTests(unittest.TestCase):
status, _, data = self.cashier_a.get("/api/batches")
bodies.append(data)
for body in bodies:
self.assertNotIn("password_hash", body.decode("utf-8"))
text = body.decode("utf-8")
self.assertNotIn("password_hash", text)
self.assertNotIn("initial_password", text)
def test_audit_log_contains_no_plaintext_passwords(self) -> None:
connection = connect(self.db_path)
+80 -32
View File
@@ -5,16 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团管理端" />
<title>管理端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=10" />
<script>
(function () {
try { if (localStorage.getItem("jinniu-theme") === "night") document.documentElement.setAttribute("data-theme", "night"); } catch (e) {}
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body data-portal="admin">
<body class="is-app" data-portal="admin">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="shell">
<aside class="sidebar" id="sidebar" aria-label="管理导航">
<div class="side-brand">
<div class="brand-name">金牛实业 · 资金往来</div>
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
<span class="side-role">管理端</span>
<img class="brand-logo brand-logo-day" src="assets/logo-day.png" width="38" height="38" alt="金牛实业" />
<img class="brand-logo brand-logo-night" src="assets/logo-night.png" width="38" height="38" alt="" />
<div class="brand-text">
<div class="brand-name">金牛实业 · 资金往来</div>
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
<span class="side-role">管理端</span>
</div>
</div>
<nav class="side-nav">
<div class="nav-group">日常</div>
@@ -28,14 +38,22 @@
<a data-view="period-audit" href="#period-audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 8v5l3 1.5"/><circle cx="12" cy="12" r="8.5"/></svg><span class="nav-label">审计记录</span></a>
<a data-view="reminders" href="#reminders"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">提醒管理</span></a>
</nav>
<div class="side-foot">
<div class="user-row">
<span class="avatar"></span>
<div>
<div class="user-name">系统管理员</div>
<div class="user-meta">管理员</div>
<div class="side-foot sidebar-account">
<div class="theme-seg" role="group" aria-label="主题">
<button type="button" data-theme-set="day" class="is-active" aria-pressed="true">日间</button>
<button type="button" data-theme-set="night" aria-pressed="false">夜间</button>
</div>
<div class="account-box">
<div class="user-row">
<span class="avatar"></span>
<div>
<div class="user-name">系统管理员</div>
</div>
<button class="logout icon-btn" type="button" aria-label="退出登录" title="退出登录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M10 6H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h4"/><path d="M15 16l4-4-4-4"/><path d="M10 12h9"/></svg>
</button>
</div>
<button class="logout" type="button">退出</button>
<div class="user-meta">管理员 · 集团全量数据</div>
</div>
</div>
</aside>
@@ -44,8 +62,8 @@
<div class="topbar">
<span class="crumb">管理端 / <b id="currentViewName">管理总览</b></span>
<div class="topbar-right">
<span class="tag">账期 2026-07</span>
<span class="tag">统计截止 2026-08-20</span>
<span class="tag" id="topbarPeriod">账期 —</span>
<span class="tag" id="topbarCutoff">统计截止 —</span>
<button class="btn btn-sm" data-view-link="settings">结账检查</button>
</div>
</div>
@@ -55,20 +73,20 @@
<div class="page-head">
<div>
<h1>管理总览</h1>
<p class="page-sub">2026-01-01 至 2026-08-20,集团 6 家成员公司相互往来累计发生 8.11 亿元、392 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。</p>
<p class="page-sub" id="dashPageSub">正在读取集团往来汇总…</p>
</div>
</div>
<div class="grid grid-4">
<div class="card stat-card">
<div class="card stat-card gold">
<div class="stat-label">往来借方总额 · 年初至今</div>
<div class="stat-value">42,040.30<span class="unit">万元</span></div>
<div class="stat-foot">6 家公司合计 · 明细 392 笔</div>
<div class="stat-value" id="dashDebitValue"><span class="unit">万元</span></div>
<div class="stat-foot" id="dashDebitFoot">加载中…</div>
</div>
<div class="card stat-card">
<div class="card stat-card gold">
<div class="stat-label">往来贷方总额 · 年初至今</div>
<div class="stat-value">39,040.30<span class="unit">万元</span></div>
<div class="stat-foot">6 家公司合计 · 与借方同源互证</div>
<div class="stat-value" id="dashCreditValue"><span class="unit">万元</span></div>
<div class="stat-foot" id="dashCreditFoot">加载中…</div>
</div>
<div class="card stat-card warn" data-view-link="audit" style="cursor: pointer;">
<div class="stat-label">待审核事项 · 审核中心</div>
@@ -76,10 +94,10 @@
<div class="stat-foot" id="dashAuditFoot">高 0 项 · 中 0 项 · 其余 0 项低风险</div>
</div>
<div class="card stat-card">
<div class="stat-label">7 月账期确认进度</div>
<div class="stat-value">50<span class="unit">%</span></div>
<div class="progress" style="margin-top: 10px;"><span style="width: 50%;"></span></div>
<div class="stat-foot" style="margin-top: 8px;">3 家已完成 · 2 家在途 · 1 家未提交</div>
<div class="stat-label" id="dashPeriodLabel">账期确认进度</div>
<div class="stat-value" id="dashPeriodPercent"><span class="unit">%</span></div>
<div class="progress" style="margin-top: 10px;"><span id="dashPeriodBar" style="width: 0%;"></span></div>
<div class="stat-foot" id="dashPeriodFoot" style="margin-top: 8px;">加载中…</div>
</div>
</div>
@@ -200,7 +218,7 @@
</div>
<dl class="kv">
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
<dt>结账日</dt><dd style="color: color-mix(in oklch, var(--warn) 78%, black); font-weight: 650;">2026-08-29(顺延)· 距今 9 天</dd>
<dt>结账日</dt><dd style="color: var(--warn); font-weight: 650;">2026-08-29(顺延)· 距今 9 天</dd>
<dt>上一账期</dt><dd>2026-06 · 已于 07-03 结账</dd>
</dl>
<button class="btn" data-view-link="settings" style="width: 100%; margin-top: 14px;">进入结账检查</button>
@@ -410,11 +428,11 @@
</div>
<div class="field">
<label for="flowStart">日期起</label>
<input class="input" id="flowStart" type="date" value="2026-07-01" />
<input class="input" id="flowStart" type="date" />
</div>
<div class="field">
<label for="flowEnd">日期止</label>
<input class="input" id="flowEnd" type="date" value="2026-07-31" />
<input class="input" id="flowEnd" type="date" />
</div>
<div class="field" style="min-width: 200px;">
<label for="flowKeyword">关键词</label>
@@ -494,7 +512,7 @@
</div>
<div class="table-foot" style="border: 1px solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg);">
<span id="companyFoot">共 0 家公司</span>
<span class="meta">登录账号初始密码由管理员统一发放</span>
<span class="meta">创建账号时生成一次性随机密码,仅当时显示一次</span>
</div>
</div>
</section>
@@ -748,6 +766,7 @@
<div class="card-head">
<span class="card-title">待提醒清单<span class="sub">系统按流水提交、断档、待确认自动发现,点发送即送达对应公司</span></span>
<div class="row" style="gap: 10px; align-items: center;">
<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="ds-check" id="pending-check-all" data-check-all aria-label="全选待提醒" disabled /></label>
<span class="meta" id="pending-summary"></span>
<button type="button" class="btn btn-primary btn-sm" id="pending-send-all" disabled>全部一键发送</button>
</div>
@@ -857,7 +876,7 @@
<span class="modal-title">新增成员公司</span>
<button type="button" class="modal-close" data-close="companyDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由管理员统一发放</p>
<p class="modal-sub">登记后可同时生成公司端登录账号。系统会发一次性随机初始密码,仅创建成功时显示一次,请当场复制后交给出纳;首次登录必须改密。没有全公司通用默认密码</p>
<form id="companyForm" novalidate>
<div class="field" style="margin-bottom: 12px;">
<label for="f-name">公司全称</label>
@@ -880,7 +899,7 @@
<input class="input" id="f-cashier" name="cashier" type="text" required />
</div>
</div>
<p class="hint" style="margin-top: 10px;">创建后生成随机初始密码,仅显示一次,首次登录必须修改</p>
<p class="hint" style="margin-top: 10px;">创建成功后弹出一次性口令窗口,可复制账号和密码。关闭后无法再次查看明文,列表与日志也不会保存密码</p>
<div class="modal-actions">
<button type="button" class="btn" data-close="companyDialog">取消</button>
<button type="submit" class="btn btn-primary">创建公司与账号</button>
@@ -889,6 +908,33 @@
</div>
</div>
<!-- 一次性初始密码领取(关闭后从页面清除明文) -->
<div class="modal-backdrop" id="credentialDialog">
<div class="modal" role="dialog" aria-labelledby="cred-title" aria-modal="true">
<div class="modal-head">
<span class="modal-title" id="cred-title">一次性初始密码</span>
<button type="button" class="modal-close" data-close="credentialDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub" id="cred-sub">请立即复制并交给出纳。关闭后无法再次查看明文。</p>
<div class="cred-box">
<div class="cred-row">
<span class="cred-label">登录账号</span>
<span class="cred-value" id="cred-user"></span>
<button type="button" class="btn btn-sm" id="cred-copy-user">复制</button>
</div>
<div class="cred-row">
<span class="cred-label">初始密码</span>
<span class="cred-value" id="cred-pass"></span>
<button type="button" class="btn btn-sm btn-primary" id="cred-copy-pass">复制密码</button>
</div>
</div>
<p class="cred-warn">对方首次登录必须修改密码。重置密码同样只展示这一次。</p>
<div class="modal-actions">
<button type="button" class="btn btn-primary" data-close="credentialDialog">我已抄录</button>
</div>
</div>
</div>
<!-- 公司详情弹窗 -->
<div class="modal-backdrop" id="modal-detail">
<div class="modal wide">
@@ -1208,6 +1254,8 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=15"></script>
<script src="theme.js?v=1"></script>
<script src="datepicker.js?v=1"></script>
<script src="app.js?v=18"></script>
</body>
</html>
+214 -58
View File
@@ -39,49 +39,20 @@ const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
function animateView(view, { initial = false } = {}) {
if (!view || motionQuery.matches || typeof view.animate !== "function") return;
if (!initial) {
view.getAnimations().forEach((animation) => animation.cancel());
view.animate(
[{ opacity: 0.84, transform: "translateY(5px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 180, easing: "cubic-bezier(.22,1,.36,1)" },
if (!initial) return;
const rows = [...view.querySelectorAll(".ds-table tbody tr")].slice(0, 8);
rows.forEach((row, index) => {
row.getAnimations().forEach((animation) => animation.cancel());
row.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 300, delay: index * 45, easing: "cubic-bezier(0.23, 1, 0.32, 1)", fill: "both" },
);
return;
}
const selectors = [
".page-head",
".stat-card",
".card",
".notice",
".list-row",
".filters",
];
const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))];
elements.forEach((element, index) => {
element.getAnimations().forEach((animation) => animation.cancel());
const keyframes = [
{ 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",
});
});
}
function initMotion() {
if (motionQuery.matches) return;
animateView($(".app-view.is-active"), { initial: true });
$$(".side-nav a", $("#sidebar") || document).forEach((item, index) => {
item.animate(
[{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }],
{ duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
);
});
}
function recordStatus(status) {
@@ -139,11 +110,67 @@ function showToast(title, detail = "", kind = "info") {
}
toast.append(dot, body);
region.append(toast);
window.setTimeout(() => {
let remaining = 2600;
let started = Date.now();
let timer = window.setTimeout(dismiss, remaining);
function dismiss() {
toast.style.opacity = "0";
toast.style.transition = "opacity 0.2s ease";
window.setTimeout(() => toast.remove(), 200);
}, 4200);
toast.style.transition = "opacity 400ms cubic-bezier(0.32, 0.72, 0, 1), transform 400ms cubic-bezier(0.32, 0.72, 0, 1)";
toast.style.transform = "translateX(12px)";
window.setTimeout(() => toast.remove(), 400);
}
toast.addEventListener("mouseenter", () => {
window.clearTimeout(timer);
remaining -= Date.now() - started;
});
toast.addEventListener("mouseleave", () => {
started = Date.now();
timer = window.setTimeout(dismiss, Math.max(remaining, 0));
});
}
function copyText(value, button) {
const text = String(value || "");
const done = () => {
if (!button) return;
const previous = button.textContent;
button.textContent = "已复制";
window.setTimeout(() => { button.textContent = previous; }, 1400);
};
const fallback = () => {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.left = "-9999px";
document.body.append(area);
area.select();
try { document.execCommand("copy"); } catch (err) { /* ignore */ }
area.remove();
done();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fallback);
} else {
fallback();
}
}
function wipeCredentials() {
const user = $("#cred-user");
const pass = $("#cred-pass");
if (user) user.textContent = "";
if (pass) pass.textContent = "";
}
function showOnceCredentials({ title, subtitle, username, password }) {
const titleEl = $("#cred-title");
const subEl = $("#cred-sub");
if (titleEl && title) titleEl.textContent = title;
if (subEl && subtitle) subEl.textContent = subtitle;
if ($("#cred-user")) $("#cred-user").textContent = username || "";
if ($("#cred-pass")) $("#cred-pass").textContent = password || "";
openModal("credentialDialog");
}
function toastIfLocked(result) {
@@ -188,7 +215,9 @@ function showView(view) {
closeNavigation({ restoreFocus: navigationWasOpen });
const activeView = $(`.app-view[data-page="${view}"]`);
requestAnimationFrame(() => animateView(activeView));
window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
const shell = $(".main");
if (shell) shell.scrollTop = 0;
else window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" });
if (portal === "company" && view === "transfers") {
if (state.transfersKeepDetail && state.transfersDetail?.company_id) {
showTransfersDetailLayer();
@@ -422,15 +451,19 @@ async function initAuthGuard() {
function applyCompanyIdentity(me) {
if (!me) return;
const row = $(".side-foot .user-row");
if (row) {
const foot = $(".side-foot") || $(".side-foot .user-row");
if (foot) {
const name = me.username || (portal === "company" ? (me.company_name || "公司用户") : "系统管理员");
const avatar = $(".avatar", row);
const avatar = $(".avatar", foot);
if (avatar) avatar.textContent = name.slice(0, 1);
const nameEl = $(".user-name", row);
const nameEl = $(".user-name", foot);
if (nameEl) nameEl.textContent = name;
const metaEl = $(".user-meta", row);
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "管理员";
const metaEl = $(".user-meta", foot);
if (metaEl) {
metaEl.textContent = portal === "company"
? `${me.company_name || "公司业务端"} · 仅本公司数据`
: "管理员 · 集团全量数据";
}
}
// The company portal always shows the session-bound company in page copy.
if (portal === "company" && me.company_name) {
@@ -1478,6 +1511,63 @@ function updateDashAuditCard(audit) {
applyAuditCounts(audit, { fromApi: true });
}
function formatDashWan(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "0.00";
return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function applyDashKpis(data) {
const totals = data?.totals || {};
const from = data?.from_date || "—";
const cutoff = data?.cutoff || "—";
const periodLabel = data?.period_label || "";
const companyCount = Number(totals.company_count) || 0;
const detailCount = Number(totals.detail_count) || 0;
const debit = formatDashWan(totals.debit_wan);
const credit = formatDashWan(totals.credit_wan);
const pageSub = $("#dashPageSub");
if (pageSub) {
if (!companyCount && !detailCount) {
pageSub.textContent = `${from}${cutoff},暂无成员公司往来数据。导入并归集银行流水后,金额将显示在此。页面上任意数字与公司均可逐级穿透,直至银行原始流水。`;
} else {
pageSub.textContent = `${from}${cutoff},集团 ${companyCount} 家成员公司相互往来累计发生 ${debit} 万元、${detailCount} 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。`;
}
}
const debitVal = $("#dashDebitValue");
if (debitVal) debitVal.innerHTML = `${debit}<span class="unit">万元</span>`;
const debitFoot = $("#dashDebitFoot");
if (debitFoot) debitFoot.textContent = `${companyCount} 家公司合计 · 明细 ${detailCount}`;
const creditVal = $("#dashCreditValue");
if (creditVal) creditVal.innerHTML = `${credit}<span class="unit">万元</span>`;
const creditFoot = $("#dashCreditFoot");
if (creditFoot) creditFoot.textContent = `${companyCount} 家公司合计 · 与借方同源互证`;
const progress = data?.period_progress || {};
const ym = progress.year_month || periodLabel;
const monthNum = ym ? Number(String(ym).slice(5, 7)) : 0;
const labelEl = $("#dashPeriodLabel");
if (labelEl) labelEl.textContent = monthNum ? `${monthNum} 月账期确认进度` : "账期确认进度";
const percent = Number(progress.percent) || 0;
const pctEl = $("#dashPeriodPercent");
if (pctEl) pctEl.innerHTML = `${percent}<span class="unit">%</span>`;
const bar = $("#dashPeriodBar");
if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
const periodFoot = $("#dashPeriodFoot");
if (periodFoot) {
const enabled = Number(progress.enabled_count) || 0;
periodFoot.textContent = enabled
? `${progress.done || 0} 家已完成 · ${progress.in_progress || 0} 家在途 · ${progress.unsubmitted || 0} 家未提交`
: "尚无已启用银行账户";
}
const tbPeriod = $("#topbarPeriod");
if (tbPeriod) tbPeriod.textContent = periodLabel ? `账期 ${periodLabel}` : "账期 —";
const tbCutoff = $("#topbarCutoff");
if (tbCutoff) tbCutoff.textContent = cutoff && cutoff !== "—" ? `统计截止 ${cutoff}` : "统计截止 —";
}
function renderDashCompanyRows(companies, selectedId) {
const list = $("#dashCompanyRows");
if (!list) return;
@@ -1692,11 +1782,15 @@ async function loadAdminDashboard() {
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
if (!response?.ok) {
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
const pageSub = $("#dashPageSub");
if (pageSub) pageSub.textContent = "总览加载失败,请刷新后重试。";
return;
}
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok") {
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
const pageSub = $("#dashPageSub");
if (pageSub) pageSub.textContent = "总览加载失败,请刷新后重试。";
return;
}
state.dashCompanies = data.companies || [];
@@ -1704,6 +1798,7 @@ async function loadAdminDashboard() {
state.dashCutoff = data.cutoff;
state.dashPeriodMonth = data.period_month;
updateDashAuditCard(data.audit);
applyDashKpis(data);
const listSub = $("#dashListSub");
if (listSub) {
listSub.textContent = `${data.from_date}${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`;
@@ -1814,6 +1909,7 @@ async function loadAdminRemindersPending() {
if (empty) empty.style.display = "";
if (summary) summary.textContent = "0 家公司 · 0 项";
if (sendAll) { sendAll.disabled = true; sendAll.textContent = "全部一键发送"; }
syncPendingCheckAll();
return;
}
if (empty) empty.style.display = "none";
@@ -1831,7 +1927,7 @@ async function loadAdminRemindersPending() {
row.dataset.companyId = String(item.company_id);
const sentHint = item.send_count > 0 ? ` · 已提醒 ${item.send_count}` : "";
row.innerHTML =
'<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="pending-check" style="width:auto;" /></label>' +
'<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="pending-check ds-check" /></label>' +
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.rule_label}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${item.reason}${sentHint}</span></div></div>` +
@@ -1839,6 +1935,21 @@ async function loadAdminRemindersPending() {
`<div class="lr-side"><button type="button" class="btn btn-sm btn-primary pending-send-one">发送提醒</button></div>`;
list.append(row);
});
syncPendingCheckAll();
}
function syncPendingCheckAll() {
const master = $("#pending-check-all");
if (!master) return;
const boxes = $$(".pending-check");
const checked = boxes.filter((el) => el.checked).length;
master.disabled = boxes.length === 0;
master.checked = boxes.length > 0 && checked === boxes.length;
master.indeterminate = checked > 0 && checked < boxes.length;
if (boxes.length === 0) {
master.checked = false;
master.indeterminate = false;
}
}
async function loadAdminRemindersHistory(sourceFilter) {
@@ -1932,6 +2043,15 @@ function initAdminReminders() {
loadAdminRemindersPending();
loadAdminRemindersHistory("all");
$("#pending-check-all")?.addEventListener("change", (event) => {
const on = event.target.checked;
event.target.indeterminate = false;
$$(".pending-check").forEach((box) => { box.checked = on; });
});
document.addEventListener("change", (event) => {
if (event.target.classList?.contains("pending-check")) syncPendingCheckAll();
});
$("#reminder-scan-btn")?.addEventListener("click", async () => {
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
@@ -2599,9 +2719,24 @@ function initAdmin() {
}));
$("#auditCompany")?.addEventListener("change", filterAuditRows);
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => {
closeModal(el.dataset.close);
if (el.dataset.close === "credentialDialog") wipeCredentials();
}));
$("#cred-copy-user")?.addEventListener("click", () => copyText($("#cred-user")?.textContent, $("#cred-copy-user")));
$("#cred-copy-pass")?.addEventListener("click", () => copyText($("#cred-pass")?.textContent, $("#cred-copy-pass")));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => {
if (e.target !== bd) return;
bd.classList.remove("open");
if (bd.id === "credentialDialog") wipeCredentials();
}));
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
document.querySelectorAll(".modal-backdrop.open").forEach((m) => {
m.classList.remove("open");
if (m.id === "credentialDialog") wipeCredentials();
});
});
async function submitAuditResult(row) {
const decision = state.auditDecision;
@@ -2813,11 +2948,17 @@ function initAdmin() {
closeModal("companyDialog");
form.reset();
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
"success",
);
if (accountCreated) {
showOnceCredentials({
title: "一次性初始密码",
subtitle: "请立即复制并交给出纳。关闭后无法再次查看明文。",
username: result.username,
password: result.initial_password,
});
showToast("公司与账号已创建", "请在一次性口令窗口复制初始密码,关闭后无法再看", "success");
} else {
showToast("公司已创建", "可稍后在账号管理中创建公司账号", "success");
}
});
function openCompanyDetail(companyId) {
@@ -2857,8 +2998,14 @@ function initAdmin() {
if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; }
$("#btn-reset-pwd").disabled = true;
$("#btn-reset-pwd").textContent = "已重置";
$("#d-login-meta").textContent = `临时密码已生成(仅此一次):${result.initial_password},首次登录必须修改`;
showToast("密码已重置", "临时密码仅本次显示,首次登录必须修改", "success");
$("#d-login-meta").textContent = "临时密码已在一次性窗口展示,关闭后无法再查看。";
showOnceCredentials({
title: "临时密码已生成",
subtitle: "仅此一次显示。旧会话已失效,首次登录必须修改。",
username: user.username,
password: result.initial_password,
});
showToast("密码已重置", "请在一次性口令窗口复制后交给出纳", "success");
});
const remind = $("#cs-remind");
@@ -4174,6 +4321,15 @@ function renderWorkspaceTransfersCard(summary) {
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
const confirmedSplit = $("#wsTfConfirmedSplit");
const pendingSplit = $("#wsTfPendingSplit");
if (confirmedSplit) confirmedSplit.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
if (pendingSplit) {
const pCount = Number(pending.count) || 0;
pendingSplit.innerHTML = pCount
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
}
async function loadTransfersSummary({ asOf } = {}) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+53 -17
View File
@@ -5,16 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团公司业务端" />
<title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=7" />
<script>
(function () {
try { if (localStorage.getItem("jinniu-theme") === "night") document.documentElement.setAttribute("data-theme", "night"); } catch (e) {}
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body data-portal="company">
<body class="is-app" data-portal="company">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="shell">
<aside class="sidebar" id="sidebar" aria-label="公司业务导航">
<div class="side-brand">
<div class="brand-name">金牛实业 · 资金往来</div>
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
<span class="side-role company">公司业务端</span>
<img class="brand-logo brand-logo-day" src="assets/logo-day.png" width="38" height="38" alt="金牛实业" />
<img class="brand-logo brand-logo-night" src="assets/logo-night.png" width="38" height="38" alt="" />
<div class="brand-text">
<div class="brand-name">金牛实业 · 资金往来</div>
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
<span class="side-role company">公司业务端</span>
</div>
</div>
<nav class="side-nav">
<div class="nav-group">业务</div>
@@ -28,14 +38,22 @@
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge" id="notice-nav-badge" style="display: none;">0</span></a>
</nav>
<div class="side-foot">
<div class="user-row">
<span class="avatar"></span>
<div>
<div class="user-name">牛女士</div>
<div class="user-meta">金牛煤业 · 煤业出纳</div>
<div class="side-foot sidebar-account">
<div class="theme-seg" role="group" aria-label="主题">
<button type="button" data-theme-set="day" class="is-active" aria-pressed="true">日间</button>
<button type="button" data-theme-set="night" aria-pressed="false">夜间</button>
</div>
<div class="account-box">
<div class="user-row">
<span class="avatar"></span>
<div>
<div class="user-name">牛女士</div>
</div>
<button class="logout icon-btn" type="button" aria-label="退出登录" title="退出登录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M10 6H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h4"/><path d="M15 16l4-4-4-4"/><path d="M10 12h9"/></svg>
</button>
</div>
<button class="logout" type="button">退出</button>
<div class="user-meta">金牛煤业 · 仅本公司数据</div>
</div>
</div>
</aside>
@@ -44,8 +62,8 @@
<div class="topbar">
<span class="crumb">公司业务端 / <b id="currentViewName">工作台</b></span>
<div class="topbar-right">
<span class="tag">账期 2026-07</span>
<span class="tag">统计截止 2026-08-20</span>
<span class="tag" id="topbarPeriod">账期 —</span>
<span class="tag" id="topbarCutoff">统计截止 —</span>
</div>
</div>
@@ -175,6 +193,22 @@
<div class="ms-value" id="wsTfPending" style="color: var(--warn);"></div>
</div>
</div>
<div class="xfer-split" id="workspaceConfirmSplit" style="margin-top: 12px;">
<div class="xfer-split-pane confirmed">
<div class="xfer-split-head">
<span class="pill pill-success">已确认</span>
</div>
<div class="xfer-split-value num" id="wsTfConfirmedSplit"></div>
<div class="xfer-split-note">计入上方合计与期间净变动</div>
</div>
<div class="xfer-split-pane pending">
<div class="xfer-split-head">
<span class="pill pill-warn">待确认</span>
</div>
<div class="xfer-split-value num" id="wsTfPendingSplit"></div>
<div class="xfer-split-note">单列展示,不计入已确认合计</div>
</div>
</div>
<button class="btn btn-ghost" data-view-link="transfers" style="width: 100%; margin-top: 12px;">查看转账往来明细 →</button>
</div>
@@ -381,11 +415,11 @@
</div>
<div class="field">
<label for="flowStart">日期起</label>
<input class="input num-input" id="flowStart" type="date" value="2026-07-01" />
<input class="input num-input" id="flowStart" type="date" />
</div>
<div class="field">
<label for="flowEnd">日期止</label>
<input class="input num-input" id="flowEnd" type="date" value="2026-08-20" />
<input class="input num-input" id="flowEnd" type="date" />
</div>
<div class="field" style="min-width: 200px;">
<label for="flowKeyword">关键词</label>
@@ -1009,6 +1043,8 @@
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=15"></script>
<script src="theme.js?v=1"></script>
<script src="datepicker.js?v=1"></script>
<script src="app.js?v=18"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
/* 锚定在日期输入旁的页内日历。不引入第三方库;滚动/缩放后跟随触发器。 */
(function (global) {
var WEEK = ["日", "一", "二", "三", "四", "五", "六"];
var panel = null;
var grid = null;
var titleEl = null;
var activeInput = null;
var viewYear = 0;
var viewMonth = 0;
var bound = false;
function pad(n) {
return n < 10 ? "0" + n : String(n);
}
function toISO(year, month, day) {
return year + "-" + pad(month + 1) + "-" + pad(day);
}
function parseISO(value) {
var match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || "").trim());
if (!match) return null;
var year = Number(match[1]);
var month = Number(match[2]) - 1;
var day = Number(match[3]);
var date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null;
}
return date;
}
function todayISO() {
var now = new Date();
return toISO(now.getFullYear(), now.getMonth(), now.getDate());
}
function ensurePanel() {
if (panel) return;
panel = document.createElement("div");
panel.className = "ds-datepicker";
panel.id = "ds-datepicker";
panel.hidden = true;
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "选择日期");
panel.innerHTML =
'<div class="ds-dp-head">' +
'<button type="button" class="ds-dp-nav" data-dp-nav="-1" aria-label="上一月"></button>' +
'<div class="ds-dp-title"></div>' +
'<button type="button" class="ds-dp-nav" data-dp-nav="1" aria-label="下一月"></button>' +
"</div>" +
'<div class="ds-dp-week">' + WEEK.map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
'<div class="ds-dp-grid" role="grid"></div>' +
'<div class="ds-dp-foot">' +
'<button type="button" data-dp-today>今天</button>' +
'<button type="button" data-dp-clear>清除</button>' +
"</div>";
document.body.appendChild(panel);
titleEl = panel.querySelector(".ds-dp-title");
grid = panel.querySelector(".ds-dp-grid");
panel.addEventListener("mousedown", function (event) {
event.preventDefault();
});
panel.addEventListener("click", function (event) {
var nav = event.target.closest("[data-dp-nav]");
if (nav) {
shiftMonth(Number(nav.getAttribute("data-dp-nav")));
return;
}
if (event.target.closest("[data-dp-today]")) {
commit(todayISO());
return;
}
if (event.target.closest("[data-dp-clear]")) {
commit("");
return;
}
var dayBtn = event.target.closest("[data-iso]");
if (dayBtn && !dayBtn.disabled) commit(dayBtn.getAttribute("data-iso"));
});
}
function enhance(input) {
if (!input || input.dataset.dsDate === "1") return;
input.dataset.dsDate = "1";
if (input.type === "date") {
var value = input.value;
try {
input.type = "text";
} catch (err) {
/* keep native type if the engine refuses */
}
if (value) input.value = value;
}
input.setAttribute("inputmode", "numeric");
input.setAttribute("autocomplete", "off");
input.setAttribute("spellcheck", "false");
input.setAttribute("placeholder", input.getAttribute("placeholder") || "YYYY-MM-DD");
if (!input.getAttribute("pattern")) input.setAttribute("pattern", "\\d{4}-\\d{2}-\\d{2}");
if (!input.classList.contains("num-input")) input.classList.add("num-input");
input.setAttribute("aria-haspopup", "dialog");
}
function scan() {
document.querySelectorAll('input[type="date"]').forEach(enhance);
}
function render() {
if (!grid || !titleEl) return;
titleEl.textContent = viewYear + "年" + pad(viewMonth + 1) + "月";
var first = new Date(viewYear, viewMonth, 1);
var start = first.getDay();
var selected = activeInput ? parseISO(activeInput.value) : null;
var selectedISO = selected ? toISO(selected.getFullYear(), selected.getMonth(), selected.getDate()) : "";
var today = todayISO();
var min = activeInput && activeInput.min ? activeInput.min : "";
var max = activeInput && activeInput.max ? activeInput.max : "";
var html = "";
var cursor = new Date(viewYear, viewMonth, 1 - start);
for (var i = 0; i < 42; i += 1) {
var iso = toISO(cursor.getFullYear(), cursor.getMonth(), cursor.getDate());
var other = cursor.getMonth() !== viewMonth;
var disabled = (min && iso < min) || (max && iso > max);
var cls = "ds-dp-day";
if (other) cls += " is-other";
if (iso === today) cls += " is-today";
if (iso === selectedISO) cls += " is-selected";
html +=
'<button type="button" class="' + cls + '" data-iso="' + iso + '"' +
(disabled ? " disabled" : "") +
' aria-label="' + iso + '"' +
(iso === selectedISO ? ' aria-current="date"' : "") +
">" + cursor.getDate() + "</button>";
cursor.setDate(cursor.getDate() + 1);
}
grid.innerHTML = html;
position();
}
function viewportBox() {
var vv = global.visualViewport;
if (vv) {
return {
left: vv.offsetLeft,
top: vv.offsetTop,
width: vv.width,
height: vv.height,
};
}
return { left: 0, top: 0, width: global.innerWidth, height: global.innerHeight };
}
function position() {
if (!panel || panel.hidden || !activeInput) return;
var rect = activeInput.getBoundingClientRect();
var view = viewportBox();
var gap = 6;
var width = panel.offsetWidth || 280;
var height = panel.offsetHeight || 320;
var left = rect.left;
var top = rect.bottom + gap;
if (top + height > view.top + view.height - 8 && rect.top - gap - height >= view.top + 8) {
top = rect.top - gap - height;
}
if (left + width > view.left + view.width - 8) {
left = Math.max(view.left + 8, rect.right - width);
}
if (left < view.left + 8) left = view.left + 8;
if (top < view.top + 8) top = view.top + 8;
panel.style.left = Math.round(left) + "px";
panel.style.top = Math.round(top) + "px";
}
function openPicker(input) {
if (!input || input.disabled) return;
enhance(input);
ensurePanel();
activeInput = input;
var parsed = parseISO(input.value) || new Date();
viewYear = parsed.getFullYear();
viewMonth = parsed.getMonth();
panel.hidden = false;
render();
input.setAttribute("aria-expanded", "true");
}
function closePicker() {
if (!panel || panel.hidden) return;
panel.hidden = true;
if (activeInput) activeInput.setAttribute("aria-expanded", "false");
activeInput = null;
}
function shiftMonth(delta) {
viewMonth += delta;
while (viewMonth < 0) {
viewMonth += 12;
viewYear -= 1;
}
while (viewMonth > 11) {
viewMonth -= 12;
viewYear += 1;
}
render();
}
function commit(iso) {
if (!activeInput) return;
var input = activeInput;
input.value = iso;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
closePicker();
input.focus({ preventScroll: true });
}
function onPointer(event) {
var input = event.target.closest && event.target.closest('input[type="date"], input[data-ds-date="1"]');
if (input) {
if (event.type === "mousedown" && event.button === 0) event.preventDefault();
openPicker(input);
return;
}
if (panel && !panel.hidden && !panel.contains(event.target)) closePicker();
}
function onKey(event) {
if (event.key === "Escape") {
closePicker();
return;
}
var input = event.target.closest && event.target.closest('input[data-ds-date="1"]');
if (!input) return;
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openPicker(input);
}
}
function bind() {
if (bound) return;
bound = true;
document.addEventListener("mousedown", onPointer, true);
document.addEventListener("focusin", function (event) {
var input = event.target.closest && event.target.closest('input[type="date"]');
if (input) openPicker(input);
});
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", position, true);
global.addEventListener("resize", position);
if (global.visualViewport) {
global.visualViewport.addEventListener("resize", position);
global.visualViewport.addEventListener("scroll", position);
}
if (typeof MutationObserver === "function") {
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
}
scan();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bind);
} else {
bind();
}
global.JinniuDatePicker = { open: openPicker, close: closePicker, scan: scan };
})(window);
+786 -165
View File
File diff suppressed because it is too large Load Diff
+22 -7
View File
@@ -4,30 +4,45 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>金牛实业资金往来管理系统 · 入口</title>
<link rel="stylesheet" href="design-system.css?v=5" />
<script>
(function () {
try { if (localStorage.getItem("jinniu-theme") === "night") document.documentElement.setAttribute("data-theme", "night"); } catch (e) {}
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body>
<body class="login-page">
<div class="portal-wrap">
<div class="portal-inner">
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
<div class="login-theme">
<div class="theme-seg" role="group" aria-label="主题">
<button type="button" data-theme-set="day" class="is-active" aria-pressed="true">日间</button>
<button type="button" data-theme-set="night" aria-pressed="false">夜间</button>
</div>
</div>
<img class="brand-logo brand-logo-day" src="assets/logo-day.png" width="44" height="44" alt="金牛实业" />
<img class="brand-logo brand-logo-night" src="assets/logo-night.png" width="44" height="44" alt="" />
<p class="meta" style="letter-spacing: 0.1em; margin-top: 14px;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
<div class="portal-grid">
<a class="portal-card" href="login-admin.html">
<span class="pc-role">管理端 · 7 个页面</span>
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
<span class="pc-role">管理端 · 8 个页面</span>
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 审计记录 / 提醒管理</h3>
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
<span class="pc-go">去管理端登录 →</span>
</a>
<a class="portal-card" href="login-company.html">
<span class="pc-role">公司业务端 · 7 个页面</span>
<h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 往来确认 / 银行账户 / 通知</h3>
<span class="pc-role">公司业务端 · 8 个页面</span>
<h3>工作台 / 流水导入 / 手工记录 / 流水管理 / 转账往来 / 往来确认 / 银行账户 / 通知</h3>
<p>面向成员公司出纳:上传银行流水、登记手工往来、确认单边匹配与科目,跟踪本月完成进度。</p>
<span class="pc-go">去公司端登录 →</span>
</a>
</div>
</div>
</div>
<script src="theme.js?v=1"></script>
</body>
</html>
+20 -4
View File
@@ -4,11 +4,19 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 金牛实业资金往来管理系统</title>
<link rel="stylesheet" href="design-system.css?v=5" />
<script>
(function () {
try { if (localStorage.getItem("jinniu-theme") === "night") document.documentElement.setAttribute("data-theme", "night"); } catch (e) {}
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body data-role="admin">
<body class="login-page" data-role="admin">
<div class="login-wrap">
<aside class="login-aside">
<img class="brand-logo brand-logo-day" src="assets/logo-day.png" width="52" height="52" alt="金牛实业" />
<img class="brand-logo brand-logo-night" src="assets/logo-night.png" width="52" height="52" alt="" />
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
@@ -21,6 +29,12 @@
</aside>
<main class="login-panel">
<div class="login-theme">
<div class="theme-seg" role="group" aria-label="主题">
<button type="button" data-theme-set="day" class="is-active" aria-pressed="true">日间</button>
<button type="button" data-theme-set="night" aria-pressed="false">夜间</button>
</div>
</div>
<div class="login-card">
<a class="login-back" href="index.html">← 返回入口页</a>
<div class="login-role-title admin">管理端</div>
@@ -42,9 +56,10 @@
<div class="field">
<span class="hint error" id="login-err" role="alert" hidden></span>
</div>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
<button class="btn btn-primary" type="submit">登 录</button>
</form>
<div class="login-notice">首次登录请修改初始密码。新密码须 8 位以上,且包含字母与数字。</div>
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
</div>
</main>
@@ -75,6 +90,7 @@
</div>
</div>
<script src="login.js?v=5"></script>
<script src="theme.js?v=1"></script>
<script src="login.js?v=6"></script>
</body>
</html>
+20 -4
View File
@@ -4,11 +4,19 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 金牛实业资金往来管理系统</title>
<link rel="stylesheet" href="design-system.css?v=5" />
<script>
(function () {
try { if (localStorage.getItem("jinniu-theme") === "night") document.documentElement.setAttribute("data-theme", "night"); } catch (e) {}
document.documentElement.classList.add("v-fusion");
})();
</script>
<link rel="stylesheet" href="design-system.css?v=13" />
</head>
<body data-role="company">
<body class="login-page" data-role="company">
<div class="login-wrap">
<aside class="login-aside company">
<img class="brand-logo brand-logo-day" src="assets/logo-day.png" width="52" height="52" alt="金牛实业" />
<img class="brand-logo brand-logo-night" src="assets/logo-night.png" width="52" height="52" alt="" />
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
@@ -21,6 +29,12 @@
</aside>
<main class="login-panel">
<div class="login-theme">
<div class="theme-seg" role="group" aria-label="主题">
<button type="button" data-theme-set="day" class="is-active" aria-pressed="true">日间</button>
<button type="button" data-theme-set="night" aria-pressed="false">夜间</button>
</div>
</div>
<div class="login-card">
<a class="login-back company" href="index.html">← 返回入口页</a>
<div class="login-role-title company">公司端</div>
@@ -42,9 +56,10 @@
<div class="field">
<span class="hint error" id="login-err" role="alert" hidden></span>
</div>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
<button class="btn btn-primary" type="submit">登 录</button>
</form>
<div class="login-notice">首次登录请修改初始密码。新密码须 8 位以上,且包含字母与数字。</div>
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
</div>
</main>
@@ -75,6 +90,7 @@
</div>
</div>
<script src="login.js?v=5"></script>
<script src="theme.js?v=1"></script>
<script src="login.js?v=6"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
/* 霜曜 / 黑金主题:仅记忆主题偏好,不写任何业务数据。 */
(function (global) {
var KEY = "jinniu-theme";
function read() {
try {
return localStorage.getItem(KEY) === "night" ? "night" : "day";
} catch (err) {
return "day";
}
}
function syncButtons(theme) {
document.querySelectorAll("[data-theme-set]").forEach(function (btn) {
var on = btn.getAttribute("data-theme-set") === theme;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function apply(theme, animate) {
var next = theme === "night" ? "night" : "day";
var root = document.documentElement;
root.classList.add("v-fusion");
if (animate) {
root.setAttribute("data-theme-anim", "");
window.setTimeout(function () {
root.removeAttribute("data-theme-anim");
}, 220);
}
root.setAttribute("data-theme", next);
if (document.body) document.body.setAttribute("data-theme", next);
syncButtons(next);
}
function set(theme) {
var next = theme === "night" ? "night" : "day";
try {
localStorage.setItem(KEY, next);
} catch (err) {
/* 无本地存储时仍切换当次会话 */
}
apply(next, true);
}
apply(read(), false);
document.addEventListener("click", function (event) {
var btn = event.target.closest("[data-theme-set]");
if (!btn) return;
event.preventDefault();
set(btn.getAttribute("data-theme-set"));
});
global.JinniuTheme = { apply: apply, set: set, read: read };
})(window);