From e5e326514df7f18690b5d18580b2092c61283c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sun, 30 Aug 2026 21:42:22 +0800 Subject: [PATCH] =?UTF-8?q?HEL-269:=20=E5=AE=8C=E6=88=90=E6=9C=88=E7=BB=93?= =?UTF-8?q?=E9=94=81=E8=B4=A6=E3=80=81=E6=92=A4=E6=BC=94=E7=A4=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E4=BB=A3=E7=A0=81=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 锁账后写保护与闭期补录留痕;重开须审批并按版本链再结;列表/导出改走真实 API。 Co-authored-by: Cursor Co-authored-by: multica-agent --- .gitignore | 1 + README.md | 2 +- deploy/.env.example | 14 + deploy/Dockerfile | 23 + deploy/compose.yaml | 36 + deploy/deploy.sh | 12 + deploy/rollback.sh | 15 + docs/任务清单.md | 24 +- docs/最新进度.md | 20 +- server.py | 369 +++++- src/bank_importer/auth.py | 12 + src/bank_importer/dashboard.py | 39 +- src/bank_importer/db.py | 104 ++ src/bank_importer/flows.py | 172 +++ src/bank_importer/period_close.py | 1180 +++++++++++++++++++ tests/test_company_confirm_status_color.py | 2 +- tests/test_company_transfers_page.py | 14 +- tests/test_period_close.py | 171 +++ tests/test_period_close_page.py | 101 ++ tests/test_persistence.py | 14 +- tests/test_reminders.py | 4 +- tests/test_reminders_page.py | 8 +- web/admin.html | 265 ++++- web/app.js | 1220 +++++++++++++------- web/company.html | 6 +- web/design-system.css | 114 ++ 26 files changed, 3455 insertions(+), 487 deletions(-) create mode 100644 deploy/.env.example create mode 100644 deploy/Dockerfile create mode 100644 deploy/compose.yaml create mode 100755 deploy/deploy.sh create mode 100755 deploy/rollback.sh create mode 100644 src/bank_importer/flows.py create mode 100644 src/bank_importer/period_close.py create mode 100644 tests/test_period_close.py create mode 100644 tests/test_period_close_page.py diff --git a/.gitignore b/.gitignore index a1fc478..1ec9146 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ data/ uploads/ exports/ *.local +deploy/.env server.pid server.out.log server.err.log diff --git a/README.md b/README.md index 1156327..0ebd5bd 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并 - 总账管理端:`http://127.0.0.1:4173/admin.html` - 公司业务端:`http://127.0.0.1:4173/company.html` -公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器;解析结果、原始文件、批次和源行会持久化到 SQLite,并按登录账号绑定的公司隔离。当前前端仍是交互原型,期初、提醒、往来匹配等业务状态仅保存在当前浏览器页面中,尚未接入数据库。 +公司端上传会把所选工作簿提交到本地 `/api/parse`,与 CLI 使用同一个确定性表头解析器;解析结果、原始文件、批次和源行会持久化到 SQLite,并按登录账号绑定的公司隔离。流水列表、往来查询、手工记录、月结与重开均走服务端接口;银行原始数据不可改,已确认与待确认金额分开计算。测试环境编排见 `deploy/`(默认 4173)。 ## 样本数据政策 diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..d24ae32 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,14 @@ +# 测试环境变量示例。复制为 deploy/.env 后填写。 +# 正式环境口令不得提交进仓库。 + +APP_HOST=0.0.0.0 +APP_PORT=4173 +APP_DB_PATH=/app/data/app.db +APP_STORAGE_DIR=/app/data/files + +# 引导管理员(库中尚无管理员时生效) +APP_ADMIN_USERNAME=group-admin +APP_BOOTSTRAP_ADMIN_PASSWORD=change-me-in-local-env + +# 内网测试可暂时关闭登录失败锁定;正式环境不得开启 +# APP_LOGIN_RATE_LIMIT_DISABLED=1 diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..6420b03 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py ./ +COPY src ./src +COPY web ./web + +ENV PYTHONPATH=/app/src +ENV APP_HOST=0.0.0.0 +ENV APP_PORT=4173 +ENV APP_DB_PATH=/app/data/app.db +ENV APP_STORAGE_DIR=/app/data/files + +EXPOSE 4173 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=4)" + +CMD ["python", "server.py"] diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..127f353 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,36 @@ +# 测试环境部署编排。正式口令不得写入本文件。 +# 使用:复制 .env.example 为 .env 后填写,再执行 ./deploy.sh + +services: + caiwuzongzhang: + build: + context: .. + dockerfile: deploy/Dockerfile + image: caiwuzongzhang:test + container_name: caiwuzongzhang-test + ports: + - "4173:4173" + env_file: + - .env + environment: + APP_HOST: "0.0.0.0" + APP_PORT: "4173" + APP_DB_PATH: /app/data/app.db + APP_STORAGE_DIR: /app/data/files + volumes: + - ../data:/app/data + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: 512m + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=4)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..4d41dcb --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# 测试环境部署。施工员不执行上线;由总工在测试机运行。 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +if [[ ! -f .env ]]; then + echo "缺少 deploy/.env。请复制 .env.example 后填写测试口令。" >&2 + exit 1 +fi +mkdir -p "$ROOT/../data/files" +docker compose -f compose.yaml up -d --build +echo "已启动测试环境:http://127.0.0.1:4173/" diff --git a/deploy/rollback.sh b/deploy/rollback.sh new file mode 100755 index 0000000..22c4714 --- /dev/null +++ b/deploy/rollback.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 测试环境回滚:停掉当前容器,按 TAG 或上一个镜像再拉起。 +# 用法:./rollback.sh [image-tag] +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +TAG="${1:-}" +docker compose -f compose.yaml down +if [[ -n "$TAG" ]]; then + export COMPOSE_IMAGE="caiwuzongzhang:${TAG}" + docker compose -f compose.yaml up -d + echo "已回滚到镜像 caiwuzongzhang:${TAG}" +else + echo "已停止测试容器。指定镜像标签可重新拉起:./rollback.sh " +fi diff --git a/docs/任务清单.md b/docs/任务清单.md index 3891b8a..777f5ce 100644 --- a/docs/任务清单.md +++ b/docs/任务清单.md @@ -1,29 +1,29 @@ # 任务清单 -最后核对:2026-08-23。状态以当前代码和已合并提交为准。 +最后核对:2026-08-30。状态以当前代码和已合并提交为准。 ## 正在做 -- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。 +- 无。HEL-269 施工交付后交总工审核测试环境部署。 ## 已做完 - 六类银行流水模板识别与解析。 -- 数据库迁移、不可变原始证据、导入批次和重复导入处理。 -- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。 +- 数据库迁移(至版本 10)、不可变原始证据、导入批次和重复导入处理;文件库 WAL。 +- 管理员/公司用户登录、密码策略、会话清理、限流、服务端公司隔离和审计。 - 动态公司、用户、银行账户、别名与账户审核流程。 - 导入接口加固、逐工作表确认、失败诊断和导出。 - 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。 -- 登录页视觉融合改版。 +- 起算日、期初余额、流水覆盖断档检测和无业务校准。 +- 月结、重开审批、闭期补录、锁账写保护、月报与审计记录。 +- 服务端流水查询、筛选、分页和可追溯导出;往来查询与手工记录接入真实接口。 +- 站内提醒与状态流转。 +- 前端移除演示流水/往来造数和 `localStorage` 业务状态。 +- 登录页视觉融合改版;月结/重开/审计按 HEL-268 视觉规范落地。 +- 测试环境部署编排收编到 `deploy/`(不在施工员职责内执行上线)。 ## 还没安排 -- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。 -- 起算日、期初余额、流水覆盖断档检测和无业务校准。 -- 月结、重开、调整/冲销审批与审计报告。 -- 服务端流水查询、筛选、分页和可追溯导出。 -- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。 -- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。 -- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。 +- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。覆盖正式环境必须老板明确同意。 每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。 diff --git a/docs/最新进度.md b/docs/最新进度.md index afcb72d..fccf992 100644 --- a/docs/最新进度.md +++ b/docs/最新进度.md @@ -1,24 +1,26 @@ # 最新进度 -最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。 +最后核对:2026-08-30。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。 ## 已经真实完成 - 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。 -- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。 -- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。 +- 已有 SQLite 数据库和 10 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。文件库启用 WAL。 +- 已实现管理员与公司用户登录、首次改密、会话失效(过期会话在发新会话时清理)、登录失败限流、服务端权限隔离和审计记录。 - 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。 - 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。 - 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。 -- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。 +- 已实现全局起算日、公司对期初余额、流水覆盖断档与无业务说明审核。 +- 已实现月结:结账日到达后生成待结账任务,管理员确认后锁账并生成带 SHA-256 的月报;重开须审批,窗口到期自动恢复锁定;闭期补录进入 `period_late_arrivals`,不改已结快照;写保护拒绝锁定月的普通修改。 +- 流水列表/导出、往来查询、公司端手工记录已改为服务器真实数据;空列表显示「暂无数据」。页面不再使用 `FLOW_DEMO` / `localStorage` 业务状态。 +- 已有登录页、总账端和公司端页面。管理端「结账与期初」承接月结,「审核中心」增加重开审批,「结账与期初」之后增加「审计记录」。 +- `deploy/` 收编测试环境 Dockerfile、compose、`.env.example` 与部署/回滚脚本(4173、只读根文件系统、drop ALL、512m、healthcheck)。正式口令不进仓库。 ## 仍未完成或不能当成已完成 -- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。 -- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。 -- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。 -- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。 +- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。覆盖正式环境必须老板明确同意。 +- 发布基线思路是从 `f5e0915` 拉长期分支 `release/prod`,由总工审核后处理,施工员不部署。 ## 最近验证 -2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。 +2026-08-30 已运行完整 Python 自动化测试(`PYTHONPATH=src python -m unittest discover -s tests -v`),覆盖解析、持久化、认证、导入、主数据、匹配、月结/重开/写保护、前端契约与 360/820/1440 布局冒烟(有 Chromium 时)。后续修改功能时必须再次运行完整测试并在本文件记录结果。 diff --git a/server.py b/server.py index 1fb1cdc..1c1dc43 100644 --- a/server.py +++ b/server.py @@ -14,9 +14,9 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from bank_importer import ( - auth, calculation, company_transfers, dashboard, importing, ledger_events, - manual_records, master_data, matching, multipart, personal_transit, positions, - reminders, settings, subjects, + auth, calculation, company_transfers, dashboard, flows, importing, ledger_events, + manual_records, master_data, matching, multipart, period_close, personal_transit, + positions, reminders, settings, subjects, ) from bank_importer.db import connect, migrate, utc_now @@ -62,6 +62,9 @@ class AppHandler(SimpleHTTPRequestHandler): if path == "/api/export.csv": self._handle_export_csv(query) return + if path == "/api/flows": + self._handle_flows(query) + return if path == "/api/admin/companies": self._handle_admin_companies() return @@ -108,6 +111,29 @@ class AppHandler(SimpleHTTPRequestHandler): if path == "/api/admin/audit-log": self._handle_admin_audit_log(query) return + if path == "/api/admin/period-closes": + self._handle_admin_period_closes() + return + period_close_one = re.fullmatch(r"/api/admin/period-closes/(\d{4}-\d{2})", path) + if period_close_one: + self._handle_admin_period_close_one(period_close_one.group(1)) + return + period_report = re.fullmatch( + r"/api/admin/period-closes/(\d{4}-\d{2})/report.json", path + ) + if period_report: + self._handle_admin_period_report(period_report.group(1)) + return + if path == "/api/admin/period-reopens": + self._handle_admin_period_reopens(query) + return + period_reopen_one = re.fullmatch(r"/api/admin/period-reopens/(\d+)", path) + if period_reopen_one: + self._handle_admin_period_reopen_one(int(period_reopen_one.group(1))) + return + if path == "/api/admin/period-audit": + self._handle_admin_period_audit(query) + return if path == "/api/admin/settings": self._handle_admin_settings() return @@ -384,6 +410,22 @@ class AppHandler(SimpleHTTPRequestHandler): if path == "/api/admin/reminder-settings": self._handle_admin_reminder_settings_put() return + period_execute = re.fullmatch( + r"/api/admin/period-closes/(\d{4}-\d{2})/execute", path + ) + if period_execute: + self._handle_admin_period_execute(period_execute.group(1)) + return + period_reopen = re.fullmatch( + r"/api/admin/period-closes/(\d{4}-\d{2})/reopen", path + ) + if period_reopen: + self._handle_admin_period_reopen_request(period_reopen.group(1)) + return + period_decide = re.fullmatch(r"/api/admin/period-reopens/(\d+)/decide", path) + if period_decide: + self._handle_admin_period_reopen_decide(int(period_decide.group(1))) + return company_status_match = re.fullmatch( r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path ) @@ -1022,6 +1064,46 @@ class AppHandler(SimpleHTTPRequestHandler): finally: connection.close() + def _handle_flows(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_user(connection) + if user is None: + return + raw_company = (query.get("company_id") or [None])[0] + company_id = None + if user["role"] == "company": + if raw_company is not None and raw_company != str(user["company_id"]): + self._send_json(403, {"status": "error", "message": "只能查看本公司的流水。"}) + return + company_id = user["company_id"] + elif raw_company is not None: + try: + company_id = int(raw_company) + except ValueError: + self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) + return + try: + limit = int((query.get("limit") or ["200"])[0]) + offset = int((query.get("offset") or ["0"])[0]) + except ValueError: + limit, offset = 200, 0 + payload = flows.list_flows( + connection, + company_id=company_id, + bank=(query.get("bank") or [None])[0] or None, + account=(query.get("account") or [None])[0] or None, + start=(query.get("start") or [None])[0] or None, + end=(query.get("end") or [None])[0] or None, + keyword=(query.get("keyword") or [None])[0] or None, + limit=limit, + offset=offset, + ) + payload["status"] = "ok" + self._send_json(200, payload) + finally: + connection.close() + # ------------------------------------------------------------------ # Admin endpoints # ------------------------------------------------------------------ @@ -1550,6 +1632,229 @@ class AppHandler(SimpleHTTPRequestHandler): finally: connection.close() + def _handle_admin_period_closes(self) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + payload = period_close.overview(connection) + payload["status"] = "ok" + self._send_json(200, payload) + finally: + connection.close() + + def _handle_admin_period_close_one(self, year_month: str) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + payload = period_close.close_payload(connection, year_month) + except period_close.PeriodCloseError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + body = dict(payload) + body["period_status"] = body.get("status") + body["status"] = "ok" + self._send_json(200, body) + finally: + connection.close() + + def _handle_admin_period_report(self, year_month: str) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + payload = period_close.close_payload(connection, year_month) + except period_close.PeriodCloseError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + if not payload.get("snapshot") or payload.get("status") not in ("closed", "reopened"): + self._send_json(404, {"status": "error", "message": "该账期尚无月报。"}) + return + body = json.dumps( + { + "report_no": payload["report_no"], + "snapshot_hash": payload["snapshot_hash"], + "year_month": year_month, + "snapshot": payload["snapshot"], + }, + ensure_ascii=False, + indent=2, + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header( + "Content-Disposition", + f'attachment; filename="{payload["report_no"] or year_month}.json"', + ) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + finally: + connection.close() + + def _handle_admin_period_reopens(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + status = (query.get("status") or [None])[0] or None + items = period_close.list_reopen_requests(connection, status=status) + self._send_json(200, {"status": "ok", "items": items}) + finally: + connection.close() + + def _handle_admin_period_reopen_one(self, request_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + payload = period_close.reopen_payload(connection, request_id) + except period_close.PeriodCloseError as exc: + self._send_json(404, {"status": "error", "message": str(exc)}) + return + payload["status_ok"] = "ok" + payload["ok"] = True + self._send_json(200, {"status": "ok", "item": payload}) + finally: + connection.close() + + def _handle_admin_period_audit(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + limit = int((query.get("limit") or ["100"])[0]) + except ValueError: + limit = 100 + items = period_close.list_audit_events( + connection, + action=(query.get("action") or [None])[0] or None, + year_month=(query.get("year_month") or [None])[0] or None, + company_q=(query.get("company") or [None])[0] or None, + since=(query.get("since") or [None])[0] or None, + until=(query.get("until") or [None])[0] or None, + limit=limit, + ) + self._send_json(200, {"status": "ok", "items": items}) + finally: + connection.close() + + def _handle_admin_period_execute(self, year_month: str) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() or {} + try: + payload = period_close.execute_close( + connection, + year_month, + user, + confirm=bool(data.get("confirm")), + ) + except period_close.PeriodConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except period_close.PeriodCloseError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + except Exception as exc: + period_close.mark_close_failed(connection, year_month, user, str(exc)) + self._send_json( + 500, + { + "status": "error", + "message": f"结账失败,未改动任何数据:{exc}", + }, + ) + return + auth.audit( + connection, "period_close", actor=user, + target=year_month, detail=payload.get("report_no"), ip=self._client_ip, + ) + payload["period_status"] = payload.get("status") + payload["status"] = "ok" + self._send_json(200, payload) + finally: + connection.close() + + def _handle_admin_period_reopen_request(self, year_month: str) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() or {} + try: + payload = period_close.request_reopen( + connection, + year_month, + user, + reason=str(data.get("reason") or ""), + companies_note=str(data.get("companies_note") or ""), + window_days=data.get("window_days") or 3, + ) + except period_close.PeriodConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except period_close.PeriodCloseError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, "period_reopen_request", actor=user, + target=year_month, detail=payload.get("number"), ip=self._client_ip, + ) + self._send_json(200, {"status": "ok", "item": payload}) + finally: + connection.close() + + def _handle_admin_period_reopen_decide(self, request_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() or {} + approve = bool(data.get("approve")) + try: + payload = period_close.decide_reopen( + connection, + request_id, + user, + approve=approve, + comment=str(data.get("comment") or ""), + ) + except period_close.PeriodConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except period_close.PeriodCloseError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, + "period_reopen_approve" if approve else "period_reopen_reject", + actor=user, + target=payload.get("year_month"), + detail=payload.get("number"), + ip=self._client_ip, + ) + self._send_json(200, {"status": "ok", "item": payload}) + finally: + connection.close() + # ------------------------------------------------------------------ # System settings (admin read/write, persisted + audited) # ------------------------------------------------------------------ @@ -2292,8 +2597,25 @@ class AppHandler(SimpleHTTPRequestHandler): if not row_ids: self._send_json(400, {"status": "error", "message": "必须指定 source_row_ids 或 batch_id。"}) return + writable, locked_rows = period_close.split_writable_row_ids(connection, row_ids) + late = 0 + if locked_rows: + late = period_close.record_late_arrivals(connection, locked_rows, user) + if not writable: + self._send_json( + 200, + { + "status": "ok", + "matching": { + "created_events": 0, "updated_events": 0, "unchanged": 0, + "skipped_locked": len(locked_rows), "rows": len(row_ids), + }, + "late_arrivals": late, + }, + ) + return try: - result = matching.reconcile_rows(connection, row_ids, actor=user) + result = matching.reconcile_rows(connection, writable, actor=user) except Exception as exc: self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"}) return @@ -2302,13 +2624,14 @@ class AppHandler(SimpleHTTPRequestHandler): 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(row_ids)}", - detail=f"created:{result['created_events']};updated:{result['updated_events']}", + 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}) + self._send_json(200, {"status": "ok", "matching": result, "late_arrivals": late}) finally: connection.close() @@ -2336,6 +2659,7 @@ class AppHandler(SimpleHTTPRequestHandler): self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"}) return try: + period_close.assert_event_writable(connection, event_id) payload = matching.apply_manual_decision( connection, event_id, @@ -2349,6 +2673,9 @@ class AppHandler(SimpleHTTPRequestHandler): else None, participant=data.get("participant"), ) + except period_close.PeriodLockedError as exc: + self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month}) + return except matching.MatchConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return @@ -2648,6 +2975,7 @@ class AppHandler(SimpleHTTPRequestHandler): return 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, @@ -2661,6 +2989,9 @@ class AppHandler(SimpleHTTPRequestHandler): "company_id": counterparty_company_id, }, ) + except period_close.PeriodLockedError as exc: + self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month}) + return except matching.MatchConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return @@ -3025,6 +3356,7 @@ class AppHandler(SimpleHTTPRequestHandler): ) return try: + period_close.assert_ledger_writable(connection, event_id) action = str(data.get("action") or "confirm") if action in ("return", "exception"): payload = subjects.park_subject( @@ -3047,6 +3379,9 @@ class AppHandler(SimpleHTTPRequestHandler): request_key=str(data.get("request_key") or "") or None, actor=user, ) + except period_close.PeriodLockedError as exc: + self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month}) + return except subjects.SubjectConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return @@ -3079,6 +3414,9 @@ class AppHandler(SimpleHTTPRequestHandler): self._send_json(400, {"status": "error", "message": "必须填写操作原因。"}) return try: + 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, @@ -3124,6 +3462,9 @@ class AppHandler(SimpleHTTPRequestHandler): {"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"}, ) return + except period_close.PeriodLockedError as exc: + self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month}) + return except ledger_events.LedgerConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return @@ -3157,6 +3498,13 @@ class AppHandler(SimpleHTTPRequestHandler): self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"}) return try: + existing = connection.execute( + "SELECT occurred_at FROM manual_records WHERE id = ?", (record_id,) + ).fetchone() + if existing is not None: + period_close.assert_date_writable( + connection, data.get("effective_at") or existing["occurred_at"] + ) payload = manual_records.decide( connection, record_id, @@ -3169,6 +3517,9 @@ class AppHandler(SimpleHTTPRequestHandler): target_ledger_event_id=data.get("target_ledger_event_id"), effective_at=data.get("effective_at"), ) + except period_close.PeriodLockedError as exc: + self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month}) + return except manual_records.ManualConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return @@ -3617,6 +3968,7 @@ class AppHandler(SimpleHTTPRequestHandler): if data is None: return try: + period_close.assert_date_writable(connection, str(data.get("occurred_at") or "")) payload = manual_records.submit( connection, company_id=company_id, @@ -3636,6 +3988,9 @@ class AppHandler(SimpleHTTPRequestHandler): reason=data.get("reason"), evidence=data.get("evidence"), ) + except period_close.PeriodLockedError as exc: + self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month}) + return except manual_records.ManualConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return diff --git a/src/bank_importer/auth.py b/src/bank_importer/auth.py index 36fffd4..3af1bc9 100644 --- a/src/bank_importer/auth.py +++ b/src/bank_importer/auth.py @@ -204,6 +204,7 @@ def create_session( token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() now = datetime.now(timezone.utc) + purge_expired_sessions(connection) with connection: connection.execute( """ @@ -220,6 +221,17 @@ def create_session( return token +def purge_expired_sessions(connection: sqlite3.Connection) -> int: + """Drop expired or revoked session rows so they do not accumulate.""" + now = utc_now() + with connection: + cursor = connection.execute( + "DELETE FROM sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL", + (now,), + ) + return int(cursor.rowcount or 0) + + def resolve_session(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None: """Return the user row for a live session token, else None. diff --git a/src/bank_importer/dashboard.py b/src/bank_importer/dashboard.py index 7dd40bc..46e7a42 100644 --- a/src/bank_importer/dashboard.py +++ b/src/bank_importer/dashboard.py @@ -109,11 +109,46 @@ def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]: ).fetchall() +def _period_status_for_cutoff( + connection: sqlite3.Connection, cutoff: str +) -> tuple[str | None, str]: + ym = str(cutoff or "")[:7] + exists = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'period_close_runs'" + ).fetchone() + if exists is None: + return None, "—" + row = connection.execute( + """ + SELECT status FROM period_close_runs + WHERE year_month = ? + ORDER BY version DESC LIMIT 1 + """, + (ym,), + ).fetchone() + mapping = { + "closed": ("closed", "已锁定"), + "reopened": ("reopened", "已重开"), + "failed": ("failed", "结账失败"), + "pending": ("pending", "待结账"), + "closing": ("closing", "处理中"), + } + if row is not None: + return mapping.get(row["status"], (row["status"], str(row["status"]))) + locked = connection.execute( + "SELECT 1 FROM closed_periods WHERE year_month = ?", (ym,) + ).fetchone() + if locked is not None: + return "closed", "已锁定" + return None, "—" + + def company_summaries( connection: sqlite3.Connection, *, from_date: str, cutoff: str ) -> tuple[list[dict[str, object]], dict[str, object]]: companies = _company_rows(connection) events = _load_eligible(connection, from_date=from_date, cutoff=cutoff) + period_status, period_label = _period_status_for_cutoff(connection, cutoff) debit_total = ZERO credit_total = ZERO @@ -125,8 +160,8 @@ def company_summaries( "detail_count": 0, "debit": ZERO, "credit": ZERO, - "period_status": None, - "period_status_label": "—", + "period_status": period_status, + "period_status_label": period_label, } for event in events: diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py index 589e7ad..60c5e4c 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -1096,6 +1096,106 @@ MIGRATIONS: tuple[Migration, ...] = ( CREATE INDEX idx_reminders_company ON reminders (company_id); """, ), + + Migration( + version=10, + name="00010_period_close_reopen", + # HEL-196/269: monthly close snapshots, reopen approval, late arrivals + # after lock, and an append-only period audit trail. closed_periods + # (from 0008) remains the live lock index; this table is the versioned + # report history. + up=""" + CREATE TABLE period_close_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year_month TEXT NOT NULL, + version INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ( + 'pending', 'closing', 'closed', 'failed', 'reopened' + )), + snapshot_json TEXT, + snapshot_hash TEXT, + report_no TEXT, + blockers_json TEXT, + fail_reason TEXT, + closed_at TEXT, + closed_by INTEGER REFERENCES users (id), + closed_by_username TEXT, + reopen_window_end TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (year_month, version) + ); + + CREATE TABLE period_reopen_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_close_id INTEGER NOT NULL REFERENCES period_close_runs (id), + year_month TEXT NOT NULL, + reason TEXT NOT NULL, + companies_note TEXT, + window_days INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + requester_id INTEGER REFERENCES users (id), + requester_username TEXT, + requested_at TEXT NOT NULL, + reviewer_id INTEGER REFERENCES users (id), + reviewer_username TEXT, + reviewed_at TEXT, + review_comment TEXT, + before_json TEXT, + after_json TEXT + ); + + CREATE TABLE period_late_arrivals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year_month TEXT NOT NULL, + source_row_id INTEGER NOT NULL UNIQUE REFERENCES source_rows (id), + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'absorbed')), + created_at TEXT NOT NULL, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT + ); + + CREATE TABLE period_audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT, + actor_role TEXT, + action TEXT NOT NULL, + year_month TEXT, + object_label TEXT, + reason TEXT, + before_json TEXT, + after_json TEXT, + report_no TEXT, + related_id INTEGER + ); + + CREATE INDEX idx_period_close_month ON period_close_runs (year_month, version); + CREATE INDEX idx_period_reopen_status ON period_reopen_requests (status, year_month); + CREATE INDEX idx_period_audit_created ON period_audit_events (created_at); + + CREATE TRIGGER period_audit_no_update BEFORE UPDATE ON period_audit_events + BEGIN SELECT RAISE (ABORT, 'period_audit_events rows are append-only'); END; + CREATE TRIGGER period_audit_no_delete BEFORE DELETE ON period_audit_events + BEGIN SELECT RAISE (ABORT, 'period_audit_events rows are immutable history'); END; + CREATE TRIGGER period_close_snapshot_no_update BEFORE UPDATE ON period_close_runs + WHEN OLD.snapshot_json IS NOT NULL AND NEW.snapshot_json IS NOT OLD.snapshot_json + BEGIN SELECT RAISE (ABORT, 'closed snapshots cannot be rewritten'); END; + """, + down=""" + DROP TRIGGER IF EXISTS period_close_snapshot_no_update; + DROP TRIGGER IF EXISTS period_audit_no_delete; + DROP TRIGGER IF EXISTS period_audit_no_update; + DROP INDEX IF EXISTS idx_period_audit_created; + DROP INDEX IF EXISTS idx_period_reopen_status; + DROP INDEX IF EXISTS idx_period_close_month; + DROP TABLE IF EXISTS period_audit_events; + DROP TABLE IF EXISTS period_late_arrivals; + DROP TABLE IF EXISTS period_reopen_requests; + DROP TABLE IF EXISTS period_close_runs; + """, + ), ) @@ -1108,6 +1208,10 @@ def connect(path: str | Path) -> sqlite3.Connection: connection = sqlite3.connect(str(db_path), timeout=30) connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys = ON") + # WAL lowers write-lock contention on file databases. Skip :memory: + # because WAL requires a real file. + if str(db_path) != ":memory:": + connection.execute("PRAGMA journal_mode=WAL") return connection diff --git a/src/bank_importer/flows.py b/src/bank_importer/flows.py new file mode 100644 index 0000000..0ae16c7 --- /dev/null +++ b/src/bank_importer/flows.py @@ -0,0 +1,172 @@ +"""Server-side flow listing and export from confirmed source rows. + +Lists and exports only cashier-confirmed worksheets. Match state is derived +from current transfer decisions when a source row is claimed; otherwise the +row is labelled 未归集. Company users only see their own company. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +import sqlite3 + + +def _dec(value: object) -> Decimal: + try: + return Decimal(str(value or "0")) + except (InvalidOperation, TypeError): + return Decimal("0") + + +def _direction(income: object, expense: object) -> tuple[str, Decimal]: + income_d = _dec(income) + expense_d = _dec(expense) + if expense_d > 0 and income_d <= 0: + return "付", expense_d + return "收", income_d if income_d > 0 else expense_d + + +def _status_label(classification: str | None, pairing: str | None, locked: int | None) -> tuple[str, str]: + if classification in ("unresolved", "needs_review"): + return "单边", "danger" + if classification == "intercompany" and pairing == "paired": + return "已归集", "success" + if classification == "intercompany" and locked: + return "已归集", "success" + if classification == "intercompany": + return "待确认", "warn" + if classification == "same_company": + return "同公司调拨", "muted" + if classification == "external": + return "未归集 · 外部", "muted" + if classification: + return "未归集", "muted" + return "未归集", "muted" + + +def list_flows( + connection: sqlite3.Connection, + *, + company_id: int | None = None, + bank: str | None = None, + account: str | None = None, + start: str | None = None, + end: str | None = None, + keyword: str | None = None, + limit: int = 200, + offset: int = 0, +) -> dict[str, object]: + clauses = ["rv.review_status = 'confirmed'"] + params: list[object] = [] + if company_id is not None: + clauses.append("b.company_id = ?") + params.append(int(company_id)) + if start: + clauses.append("date(r.transaction_at) >= date(?)") + params.append(start) + if end: + clauses.append("date(r.transaction_at) <= date(?)") + params.append(end) + if account: + clauses.append("r.own_account LIKE ?") + params.append(f"%{account}%") + if bank: + clauses.append("(COALESCE(ba.bank_name, '') LIKE ? OR r.own_name LIKE ?)") + params.extend([f"%{bank}%", f"%{bank}%"]) + if keyword: + like = f"%{keyword}%" + clauses.append( + "(r.counterparty_name LIKE ? OR r.summary LIKE ? OR r.reference LIKE ? " + "OR r.purpose LIKE ? OR c.name LIKE ?)" + ) + params.extend([like, like, like, like, like]) + where = " AND ".join(clauses) + limit = max(1, min(int(limit), 500)) + offset = max(0, int(offset)) + count_row = connection.execute( + f""" + SELECT COUNT(*) AS n + 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 + JOIN companies c ON c.id = b.company_id + LEFT JOIN bank_accounts ba ON ba.account_number = r.own_account + WHERE {where} + """, + params, + ).fetchone() + rows = connection.execute( + f""" + SELECT r.id, r.transaction_at, r.income, r.expense, r.balance, + r.own_account, r.own_name, r.counterparty_account, r.counterparty_name, + r.counterparty_bank, r.summary, r.purpose, r.reference, r.currency, + b.id AS batch_id, b.company_id, c.name AS company_name, + COALESCE(ba.bank_name, '') AS bank_name, + s.sheet_name, r.source_row, + d.classification, d.pairing, d.locked, d.effective_at + 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 + JOIN companies c ON c.id = b.company_id + LEFT JOIN bank_accounts ba ON ba.account_number = r.own_account + LEFT JOIN transfer_observation_claims toc ON toc.source_row_id = r.id + LEFT JOIN transfer_match_decisions d ON d.id = toc.decision_id + WHERE {where} + ORDER BY r.transaction_at DESC, r.id DESC + LIMIT ? OFFSET ? + """, + [*params, limit, offset], + ).fetchall() + items = [] + inflow = Decimal("0") + outflow = Decimal("0") + for row in rows: + direction, amount = _direction(row["income"], row["expense"]) + if direction == "收": + inflow += amount + else: + outflow += amount + status, status_kind = _status_label( + row["classification"], row["pairing"], row["locked"] + ) + tail = str(row["own_account"] or "")[-4:] + items.append( + { + "id": int(row["id"]), + "date": str(row["transaction_at"] or "")[:10], + "time": str(row["transaction_at"] or ""), + "company_id": int(row["company_id"]), + "company": row["company_name"], + "bank": row["bank_name"] or "", + "account": row["own_account"], + "account_label": ( + f"{row['bank_name']} · 尾号 {tail}" if row["bank_name"] and tail else (row["own_account"] or "—") + ), + "own_name": row["own_name"], + "direction": direction, + "peer": row["counterparty_name"] or "—", + "peer_account": row["counterparty_account"] or "—", + "peer_bank": row["counterparty_bank"] or "—", + "summary": row["summary"] or row["purpose"] or "—", + "serial": row["reference"] or "—", + "status": status, + "status_kind": status_kind, + "amount": str(amount), + "balance": str(row["balance"] or ""), + "currency": row["currency"] or "CNY", + "batch_id": int(row["batch_id"]), + "batch": f"IMP-{int(row['batch_id']):06d}", + "locator": f"{row['sheet_name']}!R{row['source_row']}", + "year_month": str(row["transaction_at"] or "")[:7], + } + ) + return { + "items": items, + "total": int(count_row["n"]), + "inflow": str(inflow), + "outflow": str(outflow), + "limit": limit, + "offset": offset, + } diff --git a/src/bank_importer/period_close.py b/src/bank_importer/period_close.py new file mode 100644 index 0000000..542b920 --- /dev/null +++ b/src/bank_importer/period_close.py @@ -0,0 +1,1180 @@ +"""Monthly close, reopen approval and locked-period write guards. + +A close freezes one calendar month: the snapshot is hashed and stored as an +append-only month report. Ordinary writes that would change that month's +results are rejected until an administrator approves a reopen request. +Bank source files remain immutable evidence and are never rewritten. +""" + +from __future__ import annotations + +from calendar import monthrange +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal, ROUND_HALF_UP +import hashlib +import json +import re +import sqlite3 + +from .db import utc_now +from . import calculation, dashboard, settings as settings_mod + + +YEAR_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") +CLOSE_STATUSES = ("pending", "closing", "closed", "failed", "reopened") +REOPEN_STATUSES = ("pending", "approved", "rejected") +DEFAULT_REOPEN_DAYS = 3 +MIN_REASON_LEN = 10 +TWOPLACES = Decimal("0.01") + +LOCK_TOAST = ( + "{year_month} 已结账锁定,不可直接修改 / 确需更正请前往「结账与期初」" + "发起重开申请,经审批后修改并留痕。" +) + + +class PeriodLockedError(ValueError): + """A write targeted a currently locked month.""" + + def __init__(self, year_month: str): + self.year_month = year_month + super().__init__(lock_message(year_month)) + + +class PeriodCloseError(ValueError): + """Close / reopen business-rule failure.""" + + +class PeriodConflictError(ValueError): + """Idempotency or state conflict.""" + + +def lock_message(year_month: str) -> str: + return LOCK_TOAST.format(year_month=year_month) + + +def today_shanghai() -> date: + return datetime.now(timezone(timedelta(hours=8))).date() + + +def validate_year_month(value: object) -> str: + text = str(value or "").strip() + if not YEAR_MONTH_RE.fullmatch(text): + raise PeriodCloseError("账期必须是 YYYY-MM。") + return text + + +def month_bounds(year_month: str) -> tuple[str, str]: + year_month = validate_year_month(year_month) + 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 add_months(year_month: str, delta: int) -> str: + year, month = (int(part) for part in validate_year_month(year_month).split("-")) + month += delta + while month < 1: + month += 12 + year -= 1 + while month > 12: + month -= 12 + year += 1 + return f"{year:04d}-{month:02d}" + + +def next_month(year_month: str) -> str: + return add_months(year_month, 1) + + +def previous_month(year_month: str) -> str: + return add_months(year_month, -1) + + +def month_of(iso_date: str | None) -> str | None: + if not iso_date: + return None + text = str(iso_date).strip() + if len(text) < 7: + return None + candidate = text[:7] + return candidate if YEAR_MONTH_RE.fullmatch(candidate) else None + + +def _q2(value: Decimal) -> str: + return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)) + + +def _canonical_json(payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _hash_payload(payload: object) -> str: + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +def _actor_name(actor: sqlite3.Row | None) -> str: + if actor is None: + return "system" + return str(actor["username"] or actor["display_name"] or "admin") + + +def _actor_role(actor: sqlite3.Row | None) -> str: + if actor is None: + return "system" + return str(actor["role"] or "admin") + + +def closing_day(connection: sqlite3.Connection) -> int: + values = settings_mod.get_settings(connection) + try: + day = int(values.get("closing_day") or "5") + except (TypeError, ValueError): + day = 5 + return max(1, min(day, 28)) + + +def last_closable_month(connection: sqlite3.Connection, *, today: date | None = None) -> str: + """The latest month that may be closed given today's date and closing_day. + + Closing day 5 of August means July becomes closable on August 5. + """ + today = today or today_shanghai() + day = closing_day(connection) + if today.day >= day: + return previous_month(f"{today.year:04d}-{today.month:02d}") + return add_months(f"{today.year:04d}-{today.month:02d}", -2) + + +def earliest_month(connection: sqlite3.Connection) -> str | None: + start = calculation.get_calculation_start_date(connection) + if start: + return month_of(start) + row = connection.execute( + "SELECT MIN(substr(transaction_at, 1, 7)) AS ym FROM source_rows" + ).fetchone() + ym = row["ym"] if row is not None else None + return ym if ym and YEAR_MONTH_RE.fullmatch(str(ym)) else None + + +def is_month_locked(connection: sqlite3.Connection, year_month: str) -> bool: + year_month = validate_year_month(year_month) + row = connection.execute( + "SELECT 1 FROM closed_periods WHERE year_month = ?", (year_month,) + ).fetchone() + return row is not None + + +def locked_month_for_date(connection: sqlite3.Connection, iso_date: str | None) -> str | None: + ym = month_of(iso_date) + if ym and is_month_locked(connection, ym): + return ym + return None + + +def assert_date_writable(connection: sqlite3.Connection, iso_date: str | None) -> None: + ym = locked_month_for_date(connection, iso_date) + if ym: + raise PeriodLockedError(ym) + + +def assert_event_writable(connection: sqlite3.Connection, event_id: int) -> None: + row = connection.execute( + """ + SELECT d.effective_at + FROM current_transfer_decisions c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE c.event_id = ? + """, + (int(event_id),), + ).fetchone() + if row is not None: + assert_date_writable(connection, row["effective_at"]) + + +def assert_ledger_writable(connection: sqlite3.Connection, ledger_event_id: int) -> None: + row = connection.execute( + """ + SELECT p.effective_at + FROM current_ledger_event_revisions cur + JOIN ledger_event_revisions p ON p.id = cur.revision_id + WHERE cur.ledger_event_id = ? + """, + (int(ledger_event_id),), + ).fetchone() + if row is not None: + assert_date_writable(connection, row["effective_at"]) + + +def split_writable_row_ids( + connection: sqlite3.Connection, source_row_ids: list[int] +) -> tuple[list[int], list[tuple[int, str]]]: + """Return (writable_ids, [(row_id, year_month), ...] for locked rows).""" + if not source_row_ids: + return [], [] + placeholders = ",".join("?" for _ in source_row_ids) + rows = connection.execute( + f"SELECT id, transaction_at FROM source_rows WHERE id IN ({placeholders})", + [int(item) for item in source_row_ids], + ).fetchall() + writable: list[int] = [] + locked: list[tuple[int, str]] = [] + for row in rows: + ym = month_of(row["transaction_at"]) + if ym and is_month_locked(connection, ym): + locked.append((int(row["id"]), ym)) + else: + writable.append(int(row["id"])) + return writable, locked + + +def record_late_arrivals( + connection: sqlite3.Connection, + items: list[tuple[int, str]], + actor: sqlite3.Row | None, +) -> int: + if not items: + return 0 + now = utc_now() + inserted = 0 + for row_id, year_month in items: + existing = connection.execute( + "SELECT id FROM period_late_arrivals WHERE source_row_id = ?", + (row_id,), + ).fetchone() + if existing is not None: + continue + connection.execute( + """ + INSERT INTO period_late_arrivals ( + year_month, source_row_id, status, created_at, actor_user_id, actor_username + ) VALUES (?, ?, 'open', ?, ?, ?) + """, + ( + year_month, + row_id, + now, + actor["id"] if actor is not None else None, + _actor_name(actor), + ), + ) + inserted += 1 + _append_audit( + connection, + actor, + "late_arrival", + year_month, + object_label=f"源行 {row_id}", + reason="闭期后到达的流水,未改写已结账快照", + after={"source_row_id": row_id}, + ) + return inserted + + +def _append_audit( + connection: sqlite3.Connection, + actor: sqlite3.Row | None, + action: str, + year_month: str | None, + *, + object_label: str | None = None, + reason: str | None = None, + before: object = None, + after: object = None, + report_no: str | None = None, + related_id: int | None = None, +) -> None: + connection.execute( + """ + INSERT INTO period_audit_events ( + created_at, actor_user_id, actor_username, actor_role, action, + year_month, object_label, reason, before_json, after_json, + report_no, related_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + utc_now(), + actor["id"] if actor is not None else None, + _actor_name(actor), + _actor_role(actor), + action, + year_month, + object_label, + reason, + _canonical_json(before) if before is not None else None, + _canonical_json(after) if after is not None else None, + report_no, + related_id, + ), + ) + + +def build_snapshot(connection: sqlite3.Connection, year_month: str) -> dict[str, object]: + start, end = month_bounds(year_month) + companies, totals = dashboard.company_summaries( + connection, from_date=start, cutoff=end + ) + event_count = connection.execute( + """ + SELECT COUNT(*) AS n FROM eligible_intercompany_events + WHERE date(effective_at) >= date(?) AND date(effective_at) <= date(?) + """, + (start, end), + ).fetchone()["n"] + pending = connection.execute( + """ + SELECT COUNT(*) AS n + FROM current_transfer_decisions c + JOIN transfer_match_decisions d ON d.id = c.decision_id + JOIN canonical_transfer_events e ON e.id = c.event_id + WHERE e.lifecycle = 'active' + AND d.classification IN ('unresolved', 'needs_review') + AND date(d.effective_at) >= date(?) + AND date(d.effective_at) <= date(?) + """, + (start, end), + ).fetchone() + openings = calculation.list_opening_balances(connection) + confirmed_openings = [ + item for item in openings if item.get("status") == "confirmed" + ] + return { + "year_month": year_month, + "window": {"start": start, "end": end}, + "companies": companies, + "totals": totals, + "event_count": int(event_count), + "pending_unconfirmed_count": int(pending["n"] if pending else 0), + "opening_pairs": [ + { + "id": item["id"], + "company_id_low": item["company_id_low"], + "company_id_high": item["company_id_high"], + "amount": item["amount"], + } + for item in confirmed_openings + ], + "carry_forward_to": next_month(year_month), + } + + +def _precondition_rows(connection: sqlite3.Connection, year_month: str) -> list[dict[str, object]]: + start, end = month_bounds(year_month) + companies = connection.execute( + "SELECT COUNT(*) AS n FROM companies WHERE status != 'disabled'" + ).fetchone()["n"] + submitted = connection.execute( + """ + SELECT COUNT(DISTINCT b.company_id) AS n + 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), + ).fetchone()["n"] + attested = connection.execute( + """ + SELECT COUNT(DISTINCT a.company_id) AS n + FROM no_business_attestations a + WHERE a.status = 'approved' + AND date(a.gap_start) <= date(?) + AND date(a.gap_end) >= date(?) + """, + (end, start), + ).fetchone()["n"] + covered = int(submitted) + int(attested) + open_gaps = connection.execute( + """ + SELECT COUNT(*) AS n FROM coverage_gaps + WHERE status = 'open' + AND date(gap_start) <= date(?) + AND date(gap_end) >= date(?) + """, + (end, start), + ).fetchone()["n"] + pending_matches = connection.execute( + """ + SELECT COUNT(*) AS n + FROM current_transfer_decisions c + JOIN transfer_match_decisions d ON d.id = c.decision_id + JOIN canonical_transfer_events e ON e.id = c.event_id + WHERE e.lifecycle = 'active' + AND d.classification IN ('unresolved', 'needs_review') + AND date(d.effective_at) >= date(?) + AND date(d.effective_at) <= date(?) + """, + (start, end), + ).fetchone()["n"] + pending_manuals = connection.execute( + """ + SELECT COUNT(*) AS n + FROM manual_records m + JOIN current_manual_record_decisions c ON c.record_id = m.id + JOIN manual_record_decisions d ON d.id = c.decision_id + WHERE d.state = 'pending' + AND date(m.occurred_at) >= date(?) + AND date(m.occurred_at) <= date(?) + """, + (start, end), + ).fetchone()["n"] + pending_accounts = connection.execute( + "SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'" + ).fetchone()["n"] + pending_attest = connection.execute( + "SELECT COUNT(*) AS n FROM no_business_attestations WHERE status = 'pending'" + ).fetchone()["n"] + review_total = int(pending_matches) + int(pending_manuals) + int(pending_accounts) + int(pending_attest) + start_date = calculation.get_calculation_start_date(connection) + opening_ok = True + opening_sub = "未设置起算日,本月按期间净变动结账" + if start_date: + summary = calculation.opening_coverage_summary(connection) + pair_count = int(summary.get("confirmed_pair_count") or 0) + opening_ok = pair_count > 0 or int(companies) <= 1 + opening_sub = ( + f"已确认 {pair_count} 对公司对期初" + if opening_ok + else "尚未录入已确认的公司对期初" + ) + + submit_ok = int(companies) == 0 or covered >= int(companies) or int(submitted) > 0 and int(open_gaps) == 0 + # Missing submissions only block when some companies exist and none have + # coverage for the month. A company with no enabled accounts is not required. + enabled_companies = connection.execute( + """ + SELECT COUNT(DISTINCT company_id) AS n FROM bank_accounts + WHERE status = 'active' + """ + ).fetchone()["n"] + if int(enabled_companies) == 0: + submit_ok = True + submit_sub = "尚无已启用银行账户,不要求流水提交" + else: + submit_ok = int(submitted) + int(attested) >= int(enabled_companies) + submit_sub = f"{int(submitted) + int(attested)} / {int(enabled_companies)} 家已覆盖(含无业务说明)" + + return [ + { + "key": "submissions", + "title": "流水提交", + "ok": submit_ok, + "blocking": not submit_ok, + "detail": submit_sub, + }, + { + "key": "coverage", + "title": "账户连续", + "ok": int(open_gaps) == 0, + "blocking": int(open_gaps) > 0, + "detail": "账户覆盖连续" if int(open_gaps) == 0 else f"{int(open_gaps)} 个账户存在断档", + }, + { + "key": "reviews", + "title": "审核完成", + "ok": review_total == 0, + "blocking": review_total > 0, + "detail": "0 项尚未处理" if review_total == 0 else f"{review_total} 项尚未处理", + }, + { + "key": "opening", + "title": "期初锁定", + "ok": opening_ok, + "blocking": not opening_ok, + "detail": opening_sub, + }, + ] + + +def evaluate_preconditions(connection: sqlite3.Connection, year_month: str) -> dict[str, object]: + checks = _precondition_rows(connection, year_month) + blockers = [item for item in checks if item["blocking"]] + return { + "year_month": year_month, + "checks": checks, + "ready": len(blockers) == 0, + "blockers": blockers, + } + + +def _current_run(connection: sqlite3.Connection, year_month: str) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM period_close_runs + WHERE year_month = ? + ORDER BY version DESC + LIMIT 1 + """, + (year_month,), + ).fetchone() + + +def _max_version(connection: sqlite3.Connection, year_month: str) -> int: + row = connection.execute( + "SELECT MAX(version) AS v FROM period_close_runs WHERE year_month = ?", + (year_month,), + ).fetchone() + return int(row["v"] or 0) + + +def ensure_pending_tasks( + connection: sqlite3.Connection, *, today: date | None = None +) -> list[str]: + """Create pending close tasks for every closable month that has none.""" + last = last_closable_month(connection, today=today) + first = earliest_month(connection) or last + created: list[str] = [] + month = first + with connection: + while month <= last: + existing = _current_run(connection, month) + if existing is None: + _insert_pending(connection, month, actor=None) + created.append(month) + if month == last: + break + month = next_month(month) + return created + + +def _insert_pending( + connection: sqlite3.Connection, year_month: str, actor: sqlite3.Row | None +) -> sqlite3.Row: + version = _max_version(connection, year_month) + 1 + now = utc_now() + connection.execute( + """ + INSERT INTO period_close_runs ( + year_month, version, status, created_at, updated_at + ) VALUES (?, ?, 'pending', ?, ?) + """, + (year_month, version, now, now), + ) + _append_audit( + connection, + actor, + "close_pending", + year_month, + object_label=f"{year_month} 待结账任务", + reason="到达结账日后自动生成待结账任务,待管理员确认锁账", + ) + row = _current_run(connection, year_month) + assert row is not None + return row + + +def _report_no(year_month: str, version: int) -> str: + compact = year_month.replace("-", "") + return f"MR-{compact}-v{version}" + + +def execute_close( + connection: sqlite3.Connection, + year_month: str, + actor: sqlite3.Row, + *, + confirm: bool = False, +) -> dict[str, object]: + year_month = validate_year_month(year_month) + if not confirm: + raise PeriodCloseError("必须勾选确认后才能执行结账。") + evaluation = evaluate_preconditions(connection, year_month) + if not evaluation["ready"]: + raise PeriodCloseError( + "存在阻断项,暂不能执行月度结账:" + + ";".join(item["detail"] for item in evaluation["blockers"]) + ) + current = _current_run(connection, year_month) + if current is not None and current["status"] == "closed": + raise PeriodConflictError(f"{year_month} 已结账,不可重复执行。") + if current is not None and current["status"] not in ("pending", "failed", "reopened"): + raise PeriodConflictError(f"{year_month} 当前状态为 {current['status']},不能结账。") + + snapshot = build_snapshot(connection, year_month) + digest = _hash_payload(snapshot) + now = utc_now() + with connection: + if current is not None and current["status"] == "pending": + version = int(current["version"]) + report_no = _report_no(year_month, version) + connection.execute( + """ + UPDATE period_close_runs + SET status = 'closed', snapshot_json = ?, snapshot_hash = ?, + report_no = ?, blockers_json = '[]', fail_reason = NULL, + closed_at = ?, closed_by = ?, closed_by_username = ?, + reopen_window_end = NULL, updated_at = ? + WHERE id = ? + """, + ( + _canonical_json(snapshot), digest, report_no, + now, actor["id"], _actor_name(actor), now, int(current["id"]), + ), + ) + else: + version = _max_version(connection, year_month) + 1 + report_no = _report_no(year_month, version) + connection.execute( + """ + INSERT INTO period_close_runs ( + year_month, version, status, snapshot_json, snapshot_hash, + report_no, blockers_json, closed_at, closed_by, closed_by_username, + created_at, updated_at + ) VALUES (?, ?, 'closed', ?, ?, ?, '[]', ?, ?, ?, ?, ?) + """, + ( + year_month, version, _canonical_json(snapshot), digest, report_no, + now, actor["id"], _actor_name(actor), now, now, + ), + ) + connection.execute( + """ + INSERT INTO closed_periods (year_month, closed_at, closed_by) + VALUES (?, ?, ?) + ON CONFLICT(year_month) DO UPDATE SET + closed_at = excluded.closed_at, + closed_by = excluded.closed_by + """, + (year_month, now, actor["id"]), + ) + _append_audit( + connection, + actor, + "close_execute", + year_month, + object_label=report_no, + reason="管理员确认后锁定账期并生成月报", + after={"snapshot_hash": digest, "report_no": report_no, "totals": snapshot["totals"]}, + report_no=report_no, + ) + return close_payload(connection, year_month) + + +def mark_close_failed( + connection: sqlite3.Connection, + year_month: str, + actor: sqlite3.Row, + reason: str, +) -> dict[str, object]: + year_month = validate_year_month(year_month) + reason = str(reason or "").strip() or "结账失败" + current = _current_run(connection, year_month) + now = utc_now() + with connection: + if current is None: + version = 1 + connection.execute( + """ + INSERT INTO period_close_runs ( + year_month, version, status, fail_reason, created_at, updated_at + ) VALUES (?, ?, 'failed', ?, ?, ?) + """, + (year_month, version, reason, now, now), + ) + else: + connection.execute( + """ + UPDATE period_close_runs + SET status = 'failed', fail_reason = ?, updated_at = ? + WHERE id = ? + """, + (reason, now, int(current["id"])), + ) + _append_audit( + connection, + actor, + "close_fail", + year_month, + reason=reason, + after={"fail_reason": reason}, + ) + return close_payload(connection, year_month) + + +def request_reopen( + connection: sqlite3.Connection, + year_month: str, + actor: sqlite3.Row, + *, + reason: str, + companies_note: str = "", + window_days: int = DEFAULT_REOPEN_DAYS, +) -> dict[str, object]: + year_month = validate_year_month(year_month) + reason = str(reason or "").strip() + if len(reason) < MIN_REASON_LEN: + raise PeriodCloseError(f"重开原因不少于 {MIN_REASON_LEN} 个字,将写入审计记录。") + try: + days = int(window_days or DEFAULT_REOPEN_DAYS) + except (TypeError, ValueError): + days = DEFAULT_REOPEN_DAYS + days = max(1, min(days, 30)) + current = _current_run(connection, year_month) + if current is None or current["status"] != "closed": + raise PeriodCloseError("只有已锁定的账期才能申请重开。") + pending = connection.execute( + """ + SELECT id FROM period_reopen_requests + WHERE year_month = ? AND status = 'pending' + """, + (year_month,), + ).fetchone() + if pending is not None: + raise PeriodConflictError("该账期已有待审批的重开申请。") + now = utc_now() + with connection: + cursor = connection.execute( + """ + INSERT INTO period_reopen_requests ( + period_close_id, year_month, reason, companies_note, window_days, + status, requester_id, requester_username, requested_at, + before_json + ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?) + """, + ( + int(current["id"]), + year_month, + reason, + str(companies_note or "").strip(), + days, + actor["id"], + _actor_name(actor), + now, + current["snapshot_json"], + ), + ) + request_id = int(cursor.lastrowid) + _append_audit( + connection, + actor, + "reopen_request", + year_month, + object_label=f"RO-{request_id:06d}", + reason=reason, + before={"status": "closed", "report_no": current["report_no"]}, + related_id=request_id, + report_no=current["report_no"], + ) + return reopen_payload(connection, request_id) + + +def decide_reopen( + connection: sqlite3.Connection, + request_id: int, + actor: sqlite3.Row, + *, + approve: bool, + comment: str = "", +) -> dict[str, object]: + row = connection.execute( + "SELECT * FROM period_reopen_requests WHERE id = ?", (int(request_id),) + ).fetchone() + if row is None: + raise PeriodCloseError("重开申请不存在。") + if row["status"] != "pending": + raise PeriodConflictError("该申请已处理。") + comment = str(comment or "").strip() + if not approve and len(comment) < 2: + raise PeriodCloseError("驳回必须填写审批意见。") + now = utc_now() + year_month = row["year_month"] + close_run = connection.execute( + "SELECT * FROM period_close_runs WHERE id = ?", (row["period_close_id"],) + ).fetchone() + with connection: + if approve: + window_end = ( + datetime.now(timezone.utc) + timedelta(days=int(row["window_days"])) + ).date().isoformat() + connection.execute( + """ + UPDATE period_reopen_requests + SET status = 'approved', reviewer_id = ?, reviewer_username = ?, + reviewed_at = ?, review_comment = ? + WHERE id = ? + """, + (actor["id"], _actor_name(actor), now, comment, int(request_id)), + ) + connection.execute( + """ + UPDATE period_close_runs + SET status = 'reopened', reopen_window_end = ?, updated_at = ? + WHERE id = ? + """, + (window_end, now, int(row["period_close_id"])), + ) + connection.execute( + "DELETE FROM closed_periods WHERE year_month = ?", (year_month,) + ) + _append_audit( + connection, + actor, + "reopen_approve", + year_month, + object_label=f"RO-{int(request_id):06d}", + reason=comment or row["reason"], + before={"status": "closed", "report_no": close_run["report_no"] if close_run else None}, + after={"status": "reopened", "window_end": window_end}, + related_id=int(request_id), + report_no=close_run["report_no"] if close_run else None, + ) + else: + connection.execute( + """ + UPDATE period_reopen_requests + SET status = 'rejected', reviewer_id = ?, reviewer_username = ?, + reviewed_at = ?, review_comment = ? + WHERE id = ? + """, + (actor["id"], _actor_name(actor), now, comment, int(request_id)), + ) + _append_audit( + connection, + actor, + "reopen_reject", + year_month, + object_label=f"RO-{int(request_id):06d}", + reason=comment, + related_id=int(request_id), + ) + return reopen_payload(connection, int(request_id)) + + +def remaining_days(window_end: str | None, *, today: date | None = None) -> int | None: + if not window_end: + return None + today = today or today_shanghai() + try: + end = date.fromisoformat(str(window_end)[:10]) + except ValueError: + return None + return (end - today).days + + +def expire_reopen_windows( + connection: sqlite3.Connection, *, today: date | None = None +) -> list[str]: + """Auto-restore lock when a reopen window has elapsed with no re-close.""" + today = today or today_shanghai() + rows = connection.execute( + """ + SELECT * FROM period_close_runs + WHERE status = 'reopened' AND reopen_window_end IS NOT NULL + AND date(reopen_window_end) < date(?) + """, + (today.isoformat(),), + ).fetchall() + restored: list[str] = [] + for row in rows: + year_month = row["year_month"] + now = utc_now() + with connection: + connection.execute( + """ + UPDATE period_close_runs + SET status = 'closed', reopen_window_end = NULL, updated_at = ? + WHERE id = ? + """, + (now, int(row["id"])), + ) + connection.execute( + """ + INSERT INTO closed_periods (year_month, closed_at, closed_by) + VALUES (?, ?, ?) + ON CONFLICT(year_month) DO UPDATE SET closed_at = excluded.closed_at + """, + (year_month, now, row["closed_by"]), + ) + _append_audit( + connection, + None, + "reopen_expire", + year_month, + object_label=row["report_no"], + reason="重开窗口到期,自动恢复锁定;原月报继续有效", + report_no=row["report_no"], + ) + restored.append(year_month) + return restored + + +def close_payload(connection: sqlite3.Connection, year_month: str) -> dict[str, object]: + year_month = validate_year_month(year_month) + run = _current_run(connection, year_month) + evaluation = evaluate_preconditions(connection, year_month) + snapshot = None + if run is not None and run["snapshot_json"]: + snapshot = json.loads(run["snapshot_json"]) + elif evaluation["ready"] or run is None: + snapshot = build_snapshot(connection, year_month) + status = run["status"] if run is not None else "pending" + remaining = remaining_days(run["reopen_window_end"] if run is not None else None) + pending_reopen = connection.execute( + """ + SELECT id FROM period_reopen_requests + WHERE year_month = ? AND status = 'pending' + ORDER BY id DESC LIMIT 1 + """, + (year_month,), + ).fetchone() + return { + "year_month": year_month, + "status": status, + "ready": evaluation["ready"] and status in ("pending", "failed", "reopened"), + "checks": evaluation["checks"], + "blockers": evaluation["blockers"], + "snapshot": snapshot, + "snapshot_hash": run["snapshot_hash"] if run is not None else None, + "report_no": run["report_no"] if run is not None else None, + "closed_at": run["closed_at"] if run is not None else None, + "closed_by_username": run["closed_by_username"] if run is not None else None, + "fail_reason": run["fail_reason"] if run is not None else None, + "reopen_window_end": run["reopen_window_end"] if run is not None else None, + "reopen_remaining_days": remaining, + "pending_reopen_id": int(pending_reopen["id"]) if pending_reopen else None, + "version": int(run["version"]) if run is not None else 0, + "locked": is_month_locked(connection, year_month), + } + + +def timeline(connection: sqlite3.Connection, *, today: date | None = None) -> list[dict[str, object]]: + today = today or today_shanghai() + last = last_closable_month(connection, today=today) + first = earliest_month(connection) or add_months(last, -5) + # Always show at least 8 months ending at current calendar month. + current = f"{today.year:04d}-{today.month:02d}" + end = current if current >= last else last + start = add_months(end, -7) + if first < start: + start = first + months: list[dict[str, object]] = [] + month = start + while month <= end: + run = _current_run(connection, month) + status = run["status"] if run is not None else ("open" if month > last else "pending") + if month == current and status in ("pending", "open"): + cell = "current" + label = "进行中" + elif status == "closed": + cell = "locked" + label = "已锁定" + elif status == "reopened": + cell = "reopened" + label = "已重开" + elif status == "failed": + cell = "failed" + label = "结账失败" + elif month < last and status == "pending": + cell = "open" + label = "待结账" + elif month > last: + cell = "open" + label = "归集中" + else: + cell = "open" + label = "待结账" + months.append( + { + "year_month": month, + "status": status, + "cell": cell, + "label": label, + "locked": is_month_locked(connection, month), + } + ) + if month == end: + break + month = next_month(month) + return months + + +def overview(connection: sqlite3.Connection, *, today: date | None = None) -> dict[str, object]: + ensure_pending_tasks(connection, today=today) + expire_reopen_windows(connection, today=today) + today = today or today_shanghai() + target = last_closable_month(connection, today=today) + history = connection.execute( + """ + SELECT year_month, closed_at, closed_by_username, report_no, status + FROM period_close_runs + WHERE status IN ('closed', 'reopened', 'failed') + ORDER BY closed_at DESC, id DESC + LIMIT 1 + """ + ).fetchone() + late = connection.execute( + "SELECT COUNT(*) AS n FROM period_late_arrivals WHERE status = 'open'" + ).fetchone()["n"] + return { + "today": today.isoformat(), + "closing_day": closing_day(connection), + "target_month": target, + "timeline": timeline(connection, today=today), + "close": close_payload(connection, target), + "history": dict(history) if history is not None else None, + "open_late_arrivals": int(late), + "lock_toast_template": LOCK_TOAST, + } + + +def list_reopen_requests( + connection: sqlite3.Connection, *, status: str | None = None +) -> list[dict[str, object]]: + params: list[object] = [] + where = "" + if status: + where = "WHERE r.status = ?" + params.append(status) + rows = connection.execute( + f""" + SELECT r.*, c.report_no, c.snapshot_hash, c.status AS close_status + FROM period_reopen_requests r + JOIN period_close_runs c ON c.id = r.period_close_id + {where} + ORDER BY r.id DESC + """, + params, + ).fetchall() + return [reopen_payload(connection, int(row["id"])) for row in rows] + + +def reopen_payload(connection: sqlite3.Connection, request_id: int) -> dict[str, object]: + row = connection.execute( + """ + SELECT r.*, c.report_no, c.snapshot_json, c.snapshot_hash, c.status AS close_status, + c.closed_at, c.closed_by_username, c.reopen_window_end + FROM period_reopen_requests r + JOIN period_close_runs c ON c.id = r.period_close_id + WHERE r.id = ? + """, + (int(request_id),), + ).fetchone() + if row is None: + raise PeriodCloseError("重开申请不存在。") + before = json.loads(row["before_json"]) if row["before_json"] else None + after = json.loads(row["after_json"]) if row["after_json"] else None + if after is None and row["close_status"] == "reopened": + after = {"status": "reopened", "window_end": row["reopen_window_end"]} + return { + "id": int(row["id"]), + "number": f"RO-{int(row['id']):06d}", + "year_month": row["year_month"], + "reason": row["reason"], + "companies_note": row["companies_note"], + "window_days": int(row["window_days"]), + "status": row["status"], + "requester_username": row["requester_username"], + "requested_at": row["requested_at"], + "reviewer_username": row["reviewer_username"], + "reviewed_at": row["reviewed_at"], + "review_comment": row["review_comment"], + "report_no": row["report_no"], + "close_status": row["close_status"], + "closed_at": row["closed_at"], + "closed_by_username": row["closed_by_username"], + "reopen_window_end": row["reopen_window_end"], + "reopen_remaining_days": remaining_days(row["reopen_window_end"]), + "before": before, + "after": after, + "diff": _diff_snapshot(before, after if after else {"status": row["close_status"]}), + } + + +def _diff_snapshot(before: object, after: object) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + + def walk(prefix: str, left: object, right: object) -> None: + if left == right: + rows.append({"path": prefix or "状态", "before": left, "after": right, "changed": False}) + return + if isinstance(left, dict) and isinstance(right, dict): + keys = sorted(set(left) | set(right)) + for key in keys: + if key in {"companies", "opening_pairs"}: + continue + walk(f"{prefix}.{key}" if prefix else key, left.get(key), right.get(key)) + return + rows.append({"path": prefix or "值", "before": left, "after": right, "changed": True}) + + if isinstance(before, dict) or isinstance(after, dict): + walk("", before if isinstance(before, dict) else {}, after if isinstance(after, dict) else {}) + else: + walk("状态", before, after) + # Keep a short, UI-friendly subset. + changed = [item for item in rows if item["changed"]] + unchanged = [item for item in rows if not item["changed"]] + return (changed + unchanged)[:12] + + +def list_audit_events( + connection: sqlite3.Connection, + *, + action: str | None = None, + year_month: str | None = None, + company_q: str | None = None, + since: str | None = None, + until: str | None = None, + limit: int = 100, +) -> list[dict[str, object]]: + clauses: list[str] = [] + params: list[object] = [] + if action: + clauses.append("action = ?") + params.append(action) + if year_month: + clauses.append("year_month = ?") + params.append(validate_year_month(year_month)) + if since: + clauses.append("date(created_at) >= date(?)") + params.append(since) + if until: + clauses.append("date(created_at) <= date(?)") + params.append(until) + if company_q: + clauses.append("(object_label LIKE ? OR reason LIKE ?)") + like = f"%{company_q}%" + params.extend([like, like]) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + limit = max(1, min(int(limit), 500)) + rows = connection.execute( + f""" + SELECT * FROM period_audit_events + {where} + ORDER BY id DESC + LIMIT ? + """, + [*params, limit], + ).fetchall() + items = [] + for row in rows: + items.append( + { + "id": int(row["id"]), + "created_at": row["created_at"], + "actor_username": row["actor_username"], + "actor_role": row["actor_role"], + "action": row["action"], + "year_month": row["year_month"], + "object_label": row["object_label"], + "reason": row["reason"], + "before": json.loads(row["before_json"]) if row["before_json"] else None, + "after": json.loads(row["after_json"]) if row["after_json"] else None, + "report_no": row["report_no"], + } + ) + return items + + +def month_status_label(connection: sqlite3.Connection, year_month: str) -> tuple[str | None, str]: + run = _current_run(connection, year_month) + if run is None: + return None, "—" + mapping = { + "closed": ("closed", "已锁定"), + "reopened": ("reopened", "已重开"), + "failed": ("failed", "结账失败"), + "pending": ("pending", "待结账"), + "closing": ("closing", "处理中"), + } + return mapping.get(run["status"], (run["status"], run["status"])) diff --git a/tests/test_company_confirm_status_color.py b/tests/test_company_confirm_status_color.py index 2665341..f09bd1a 100644 --- a/tests/test_company_confirm_status_color.py +++ b/tests/test_company_confirm_status_color.py @@ -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=13", html) + self.assertIn("app.js?v=15", html) # 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"') diff --git a/tests/test_company_transfers_page.py b/tests/test_company_transfers_page.py index 40562f0..123b254 100644 --- a/tests/test_company_transfers_page.py +++ b/tests/test_company_transfers_page.py @@ -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=6", html) - self.assertIn("app.js?v=13", html) + self.assertIn("design-system.css?v=7", html) + self.assertIn("app.js?v=15", html) # 侧栏顺序:流水管理 → 转账往来 → 往来确认 flows = html.index('data-view="flows"') transfers = html.index('data-view="transfers"') @@ -54,6 +54,10 @@ class TransfersPageSourceContractTests(unittest.TestCase): self.assertIn("initTransfers()", js) self.assertIn("期间净变动", js) self.assertIn("has_opening", js) + self.assertNotIn("FLOW_DEMO", js) + self.assertNotIn("COMPANY_FLOWS", js) + self.assertNotIn("IMP-DEMO", js) + self.assertNotIn("ledger-demo-manual-records", js) # 不得把「期末余额」写死为无期初时的标签 self.assertNotRegex(js, r'netLabelForWindow[^{]+{[^}]*return "期末余额"') @@ -112,10 +116,8 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase): page = browser.new_page() for width in (360, 820, 1440): page.set_viewport_size({"width": width, "height": 900}) - page.set_content( - html.replace('src="app.js?v=13"', 'src=""'), - base_url=self.base, - ) + page.route("**/app.js**", lambda route: route.abort()) + page.goto(f"{self.base}/company.html") page.evaluate( """() => { document.querySelectorAll('.app-view').forEach((el) => { diff --git a/tests/test_period_close.py b/tests/test_period_close.py new file mode 100644 index 0000000..7bdf793 --- /dev/null +++ b/tests/test_period_close.py @@ -0,0 +1,171 @@ +"""Monthly close, reopen approval, snapshot hash and locked-period writes.""" + +from __future__ import annotations + +from datetime import date +import hashlib +import json +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from bank_importer import period_close +from ledger_helpers import LedgerBase + + +class PeriodCloseTests(LedgerBase): + TODAY = date(2026, 8, 30) + MONTH = "2026-07" + + def _cover_month(self) -> None: + self.add_row( + self.company_a, own_account="6222000000000001", + expense="100.00", at="2026-07-10T10:00:00", + ) + self.add_row( + self.company_b, own_account="6222000000000002", + income="80.00", at="2026-07-12T10:00:00", + ) + + def _close(self, month: str = MONTH): + period_close.ensure_pending_tasks(self.connection, today=self.TODAY) + return period_close.execute_close( + self.connection, month, self.admin, confirm=True, + ) + + def test_execute_requires_confirm_checkbox(self) -> None: + self._cover_month() + with self.assertRaises(period_close.PeriodCloseError) as ctx: + period_close.execute_close( + self.connection, self.MONTH, self.admin, confirm=False, + ) + self.assertIn("勾选", str(ctx.exception)) + + def test_blockers_reject_close(self) -> None: + period_close.ensure_pending_tasks(self.connection, today=self.TODAY) + evaluation = period_close.evaluate_preconditions(self.connection, self.MONTH) + self.assertFalse(evaluation["ready"]) + with self.assertRaises(period_close.PeriodCloseError): + period_close.execute_close( + self.connection, self.MONTH, self.admin, confirm=True, + ) + + def test_close_snapshot_hash_stable_and_idempotent(self) -> None: + self._cover_month() + period_close.ensure_pending_tasks(self.connection, today=self.TODAY) + before = period_close.build_snapshot(self.connection, self.MONTH) + digest_before = hashlib.sha256( + json.dumps(before, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + first = period_close.execute_close( + self.connection, self.MONTH, self.admin, confirm=True, + ) + self.assertEqual("closed", first["status"]) + self.assertTrue(first["report_no"].startswith("MR-202607-")) + digest = first["snapshot_hash"] + self.assertEqual(64, len(digest)) + self.assertEqual(digest_before, digest) + run = self.connection.execute( + "SELECT snapshot_json, snapshot_hash FROM period_close_runs WHERE year_month = ?", + (self.MONTH,), + ).fetchone() + self.assertEqual(digest, run["snapshot_hash"]) + self.assertEqual( + digest, + hashlib.sha256(run["snapshot_json"].encode("utf-8")).hexdigest(), + ) + self.assertEqual( + digest, + hashlib.sha256(run["snapshot_json"].encode("utf-8")).hexdigest(), + ) + with self.assertRaises(period_close.PeriodConflictError): + period_close.execute_close( + self.connection, self.MONTH, self.admin, confirm=True, + ) + + def test_locked_month_rejects_writes(self) -> None: + self._cover_month() + self._close() + with self.assertRaises(period_close.PeriodLockedError) as ctx: + period_close.assert_date_writable(self.connection, "2026-07-15") + self.assertIn("2026-07", str(ctx.exception)) + self.assertIn("已结账锁定", str(ctx.exception)) + period_close.assert_date_writable(self.connection, "2026-08-01") + + def test_late_arrivals_do_not_rewrite_snapshot(self) -> None: + self._cover_month() + closed = self._close() + digest = closed["snapshot_hash"] + late_id = self.add_row( + self.company_a, own_account="6222000000000001", + expense="12.00", at="2026-07-28T11:00:00", + ) + writable, locked = period_close.split_writable_row_ids(self.connection, [late_id]) + self.assertEqual([], writable) + self.assertEqual([(late_id, "2026-07")], locked) + n = period_close.record_late_arrivals(self.connection, locked, self.admin) + self.assertEqual(1, n) + again = period_close.close_payload(self.connection, self.MONTH) + self.assertEqual(digest, again["snapshot_hash"]) + + def test_snapshot_row_cannot_be_updated(self) -> None: + self._cover_month() + self._close() + with self.assertRaises(Exception): + with self.connection: + self.connection.execute( + "UPDATE period_close_runs SET snapshot_json = '{}' WHERE year_month = ?", + (self.MONTH,), + ) + + def test_reopen_reject_then_approve_and_reclose_version_chain(self) -> None: + self._cover_month() + first = self._close() + with self.assertRaises(period_close.PeriodCloseError): + period_close.request_reopen( + self.connection, self.MONTH, self.admin, reason="太短", + ) + req = period_close.request_reopen( + self.connection, self.MONTH, self.admin, + reason="补录金牛煤业七月运输费并核对金额", + companies_note="甲公司 ↔ 乙公司", + window_days=3, + ) + self.assertEqual("pending", req["status"]) + rejected = period_close.decide_reopen( + self.connection, req["id"], self.admin, approve=False, comment="证据不足", + ) + self.assertEqual("rejected", rejected["status"]) + self.assertTrue(period_close.is_month_locked(self.connection, self.MONTH)) + req2 = period_close.request_reopen( + self.connection, self.MONTH, self.admin, + reason="已补齐银行回单,申请重开更正科目", + ) + approved = period_close.decide_reopen( + self.connection, req2["id"], self.admin, approve=True, comment="同意", + ) + self.assertEqual("approved", approved["status"]) + self.assertFalse(period_close.is_month_locked(self.connection, self.MONTH)) + period_close.assert_date_writable(self.connection, "2026-07-15") + second = period_close.execute_close( + self.connection, self.MONTH, self.admin, confirm=True, + ) + self.assertEqual("closed", second["status"]) + self.assertNotEqual(first["report_no"], second["report_no"]) + versions = self.connection.execute( + "SELECT version, report_no FROM period_close_runs WHERE year_month = ? ORDER BY version", + (self.MONTH,), + ).fetchall() + self.assertGreaterEqual(len(versions), 2) + self.assertEqual(1, versions[0]["version"]) + self.assertEqual(2, versions[-1]["version"]) + + def test_wal_on_file_database(self) -> None: + mode = self.connection.execute("PRAGMA journal_mode").fetchone()[0] + self.assertEqual("wal", str(mode).lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_period_close_page.py b/tests/test_period_close_page.py new file mode 100644 index 0000000..6a5b532 --- /dev/null +++ b/tests/test_period_close_page.py @@ -0,0 +1,101 @@ +"""HEL-269: 月结面板、重开审批、审计记录页结构与三档宽度。""" + +from __future__ import annotations + +import threading +import unittest +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "web" + +try: + from playwright.sync_api import sync_playwright +except ImportError: # pragma: no cover + sync_playwright = None + + +class PeriodClosePageContractTests(unittest.TestCase): + def test_admin_has_closing_reopen_and_audit_surfaces(self) -> None: + html = (WEB / "admin.html").read_text(encoding="utf-8") + self.assertIn('id="closingPanel"', html) + self.assertIn('id="periodTimeline"', html) + self.assertIn('data-page="period-audit"', html) + self.assertIn('data-view="period-audit"', html) + self.assertIn("重开审批", html) + self.assertIn('id="reopenQueue"', html) + self.assertIn('id="reopenRequestDialog"', html) + self.assertIn('id="reopenDecideDialog"', html) + self.assertIn("btn-warn", html) + settings = html.index('data-view="settings"') + audit_nav = html.index('data-view="period-audit"') + reminders = html.index('data-view="reminders"') + self.assertLess(settings, audit_nav) + self.assertLess(audit_nav, reminders) + css = (WEB / "design-system.css").read_text(encoding="utf-8") + self.assertIn(".btn-warn", css) + self.assertIn(".pill-lock", css) + self.assertIn(".tl-cell.locked", css) + self.assertIn(".diff-grid", css) + self.assertIn(".empty-icon", css) + js = (WEB / "app.js").read_text(encoding="utf-8") + self.assertIn("/api/admin/period-closes", js) + self.assertIn("/api/admin/period-reopens", js) + self.assertIn("/api/admin/period-audit", js) + self.assertIn("/api/flows", js) + self.assertIn("/api/company/manual-records", js) + + +def _chromium_available() -> bool: + try: + import ctypes.util + return bool(ctypes.util.find_library("atk-1.0")) + except Exception: + return False + + +@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟") +@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟") +class PeriodCloseLayoutSmokeTests(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 test_settings_and_audit_no_horizontal_overflow(self) -> None: + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.route("**/app.js**", lambda route: route.abort()) + for width in (360, 820, 1440): + page.set_viewport_size({"width": width, "height": 900}) + page.goto(f"{self.base}/admin.html") + for view in ("settings", "period-audit"): + page.evaluate( + """(view) => { + document.querySelectorAll('.app-view').forEach((el) => { + el.classList.toggle('is-active', el.dataset.page === view); + }); + }""", + view, + ) + overflow = page.evaluate( + "() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + self.assertFalse(overflow, f"{width}px {view} 出现横向溢出") + browser.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 4998f99..0174042 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase): class MigrationTests(PersistenceTestCase): def test_migrate_creates_schema_and_is_idempotent(self) -> None: first = applied_versions(self.connection) - self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], first) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], first) self.assertEqual([], migrate(self.connection)) self.assertEqual(first, applied_versions(self.connection)) tables = { @@ -89,19 +89,23 @@ class MigrationTests(PersistenceTestCase): "coverage_gaps", "no_business_attestations", "reminders_legacy_manual", + "period_close_runs", + "period_reopen_requests", + "period_late_arrivals", + "period_audit_events", "schema_migrations", ): self.assertIn(table, tables) def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None: - self.assertEqual([9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0)) + self.assertEqual([10, 9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0)) self.assertEqual([], applied_versions(self.connection)) remaining = self.connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'" ).fetchone() self.assertIsNone(remaining) - self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], migrate(self.connection)) - self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], applied_versions(self.connection)) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], migrate(self.connection)) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], applied_versions(self.connection)) def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None: self.import_sample() @@ -109,7 +113,7 @@ class MigrationTests(PersistenceTestCase): "SELECT COUNT(*) AS n FROM source_rows" ).fetchone()["n"] self.assertGreater(row_count, 0) - self.assertEqual([9, 8, 7, 6, 5], rollback(self.connection, 4)) + self.assertEqual([10, 9, 8, 7, 6, 5], rollback(self.connection, 4)) # The pre-migration evidence and schema are untouched. self.assertEqual( row_count, diff --git a/tests/test_reminders.py b/tests/test_reminders.py index abe0664..c465f45 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -477,8 +477,8 @@ class MigrationTests(unittest.TestCase): versions = connection.execute( "SELECT version FROM schema_migrations ORDER BY version" ).fetchall() - self.assertEqual(9, versions[-1]["version"]) - connection.execute("DELETE FROM schema_migrations WHERE version = 9") + self.assertEqual(10, versions[-1]["version"]) + connection.execute("DELETE FROM schema_migrations WHERE version = 10") connection.executescript( """ DROP TRIGGER IF EXISTS reminders_no_delete; diff --git a/tests/test_reminders_page.py b/tests/test_reminders_page.py index 5e52335..14c2880 100644 --- a/tests/test_reminders_page.py +++ b/tests/test_reminders_page.py @@ -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=9", html) - self.assertIn("app.js?v=14", html) + self.assertIn("design-system.css?v=10", html) + self.assertIn("app.js?v=15", html) pending = html.index('id="pending-reminders-card"') history = html.index('id="reminder-history-card"') send = html.index('id="send-reminder-card"') @@ -115,7 +115,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase): html = (WEB / "admin.html").read_text(encoding="utf-8") page.set_viewport_size({"width": width, "height": 900}) page.set_content( - html.replace('src="app.js?v=14"', 'src=""'), + html.replace('src="app.js?v=15"', 'src=""'), base_url=self.base, ) page.evaluate( @@ -138,7 +138,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=14", html) + self.assertIn("app.js?v=15", html) with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() diff --git a/web/admin.html b/web/admin.html index 21eecf1..c935ce6 100644 --- a/web/admin.html +++ b/web/admin.html @@ -5,7 +5,7 @@ 管理端 · 金牛集团 - + @@ -25,6 +25,7 @@ 公司与账号 结账与期初 + 审计记录 提醒管理
@@ -241,7 +242,7 @@ @@ -336,6 +337,7 @@ +
@@ -343,7 +345,7 @@
-
+
@@ -364,6 +366,19 @@ 审核人:系统管理员 · 操作实时写入审核日志 +
@@ -426,9 +441,14 @@
+
共 0 笔 - 数据范围:加载中… +
@@ -491,15 +511,8 @@
账期时间轴全局起算日 2026-01-01 起,每月 5 日结账
-
-
2026-01
已结账
-
2026-02
已结账
-
2026-03
已结账
-
2026-04
已结账
-
2026-05
已结账
-
2026-06
已结账
-
2026-07
进行中
-
2026-08
归集中
+
+
加载中
@@ -579,54 +592,146 @@
- 月度结账2026 年 7 月 · 当前未达到结账条件 - 已阻断 + 月度结账加载账期状态… +
+
-
- 公司流水提交 -
流水提交
5 / 6 家已完成
-
待处理
+
+ 流水提交 +
流水提交
+
-
- 账户连续性 -
账户连续
2 个账户存在断档
-
阻断
+
+ 账户连续 +
账户连续
+
-
- 审核事项 -
审核完成
0 项尚未处理
-
阻断
+
+ 审核完成 +
审核完成
+
-
- 期初余额 -
期初锁定
已锁定 2026.01.01
-
已通过
+
+ 期初锁定 +
期初锁定
+
+
+
账期
+
期末归集
+
结转去向
+
月报编号
尚未生成
+
+ + + +
-
- 最近结账:2026 年 6 月 · 系统管理员 · 2026.07.05 18:10 +
+ + + - +
+
+
+
+

审计记录

+

谁、什么时间、改了什么、为什么。记录只增不改,可倒查至银行原始流水。

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + +
时间操作人动作对象与原因前后变化凭证
+
+ 共 0 条 + 审计记录只增不改 · 永久保留 +
+
+
+ +
+
@@ -913,29 +1018,99 @@ + + + +
- + diff --git a/web/app.js b/web/app.js index 4d829f5..e208948 100644 --- a/web/app.js +++ b/web/app.js @@ -3,11 +3,20 @@ const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)] const portal = document.body.dataset.portal || "entry"; const viewNames = portal === "admin" - ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } + ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", "period-audit": "审计记录", reminders: "提醒管理" } : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" }; -const storageKeys = { - manual: "ledger-demo-manual-records", +const SUBJECT_CODE_LABEL = { + receivable: "应收", + other_receivable: "其他应收", + payable: "应付", + other_payable: "其他应付", +}; +const SUBJECT_LABEL_CODE = { + 应收: "receivable", + 其他应收: "other_receivable", + 应付: "payable", + 其他应付: "other_payable", }; const accountStatusLabels = { @@ -75,25 +84,6 @@ function initMotion() { }); } -function readStoredRecords(key) { - try { - const value = JSON.parse(localStorage.getItem(key) || "[]"); - return Array.isArray(value) ? value : []; - } catch { - return []; - } -} - -function writeStoredRecords(key, records) { - try { - localStorage.setItem(key, JSON.stringify(records)); - return true; - } catch { - showToast("本机演示数据保存失败", "请检查浏览器是否允许本地存储", "danger"); - return false; - } -} - function recordStatus(status) { if (["已启用", "已确认"].includes(status)) return { className: "success", label: status }; if (status === "已退回") return { className: "danger", label: status }; @@ -153,7 +143,15 @@ function showToast(title, detail = "", kind = "info") { toast.style.opacity = "0"; toast.style.transition = "opacity 0.2s ease"; window.setTimeout(() => toast.remove(), 200); - }, 3400); + }, 4200); +} + +function toastIfLocked(result) { + const msg = String(result?.message || ""); + if (!(result?.year_month || /已结账锁定/.test(msg))) return false; + const parts = msg.split(" / "); + showToast(parts[0] || "账期已锁定", parts.slice(1).join(" / "), "warn"); + return true; } function closeNavigation({ restoreFocus = false } = {}) { @@ -201,6 +199,11 @@ function showView(view) { } } state.transfersKeepDetail = false; + if (portal === "admin" && view === "settings") loadPeriodClose(); + if (portal === "admin" && view === "period-audit") loadPeriodAudit(); + if (portal === "admin" && view === "audit") loadReopenQueue(); + if (view === "flows") loadFlows(); + if (portal === "company" && view === "manual") loadCompanyManualRecords(); } // 原型占位,非本司真待办/真断档数据:detailContent 仅用于演示总览/工作台事项 @@ -516,49 +519,13 @@ function initShell() { initMotion(); } -function pairData(from, to, endDate) { - const companies = ["A公司", "B公司", "C公司", "D公司", "E公司", "F公司"]; - const fromIndex = companies.indexOf(from) + 1; - const toIndex = companies.indexOf(to) + 1; - const low = Math.min(fromIndex, toIndex); - const high = Math.max(fromIndex, toIndex); - const seed = low * 13 + high * 7; - const canonical = { - receivable: 280 + seed * 18, - otherReceivable: 120 + seed * 9, - payable: 160 + ((seed * 11) % 720), - otherPayable: 80 + ((seed * 5) % 360), - }; - const reversed = fromIndex > toIndex; - const receivable = reversed ? canonical.payable : canonical.receivable; - const otherReceivable = reversed ? canonical.otherPayable : canonical.otherReceivable; - const payable = reversed ? canonical.receivable : canonical.payable; - const otherPayable = reversed ? canonical.otherReceivable : canonical.otherPayable; - const debit = receivable + otherReceivable; - const credit = payable + otherPayable; - const opening = ((fromIndex + toIndex) % 3) * 60 * (reversed ? -1 : 1); - const final = opening + debit - credit; - const end = new Date(`${endDate}T12:00:00`); - const dateBefore = (days) => { - const date = new Date(end); - date.setDate(date.getDate() - days); - return date.toISOString().slice(0, 10); - }; - return { - opening, debit, credit, final, - totals: { 应收: receivable, 其他应收: otherReceivable, 应付: payable, 其他应付: otherPayable }, - rows: [ - [dateBefore(2), "转出", "应收", "中信 · 5316", `${to} · 9481`, "往来款", "双边匹配", receivable], - [dateBefore(9), "转出", "其他应收", "建行 · 0845", `${to} · 2046`, "资金调拨", "双边匹配", otherReceivable], - [dateBefore(17), "转入", "应付", "中信 · 5316", `${to} · 9481`, "归还往来款", "双边匹配", payable], - [dateBefore(25), "转入", "其他应付", "农行 · 3650", `${to} · 6120`, "临时往来", "单边待核", otherPayable], - ], - }; +function companyByName(name) { + return (state.companies || []).find((item) => item.name === name); } const pairSubjectOrder = ["应收", "其他应收", "应付", "其他应付"]; -function setPair(from, to, endDate = "2026-07-31") { +async function setPair(from, to, endDate = "2026-07-31") { $$('[data-pair-from]').forEach((item) => { item.textContent = from; }); $$('[data-pair-to]').forEach((item) => { item.textContent = to; }); $$("[data-pair-form]").forEach((form) => { @@ -570,39 +537,105 @@ function setPair(from, to, endDate = "2026-07-31") { if (endInput) endInput.value = endDate; }); if (!$("#pairReport")) return; - const data = pairData(from, to, endDate); - const format = (value) => value.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - $("#pairPeriod").textContent = `统计口径 2026.01.01—${endDate}`; - $("#pairOpening").innerHTML = `${format(data.opening)}万元`; - $("#pairDebit").innerHTML = `${format(data.debit)}万元`; - $("#pairCredit").innerHTML = `${format(data.credit)}万元`; - $("#pairFinal").innerHTML = `${data.final >= 0 ? "应收" : "应付"} ${format(Math.abs(data.final))}万元`; - $("#pairReviewStatus").textContent = "含 1 笔待审核"; - $('[data-subject-total="all"]').textContent = `${data.rows.length} 笔`; - Object.entries(data.totals).forEach(([subject, value]) => { $(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(format(value))); }); - state.pairContext = { from, to, endDate }; - state.pairRows = data.rows.map(([date, direction, subject, ownAccount, counterparty, summary, match, amount]) => ({ date, direction, subject, ownAccount, counterparty, summary, match, amount })); + const fromCo = companyByName(from); + const toCo = companyByName(to); + const notice = $("#pairNotice"); + if (!fromCo || !toCo) { + state.pairRows = []; + renderPairRows(); + if (notice) notice.style.display = ""; + return; + } + const start = state.calculationStart || "2026-01-01"; + const pairResp = await fetch(`/api/admin/intercompany/pairs/${fromCo.id}/${toCo.id}?from=${encodeURIComponent(start)}&cutoff=${encodeURIComponent(endDate)}`).catch(() => null); + const eventsResp = await fetch(`/api/admin/intercompany/events?company_a=${fromCo.id}&company_b=${toCo.id}&from=${encodeURIComponent(start)}&cutoff=${encodeURIComponent(endDate)}&limit=200`).catch(() => null); + const pairResult = await pairResp?.json().catch(() => null); + const eventsResult = await eventsResp?.json().catch(() => null); + if (!pairResp?.ok || pairResult?.status !== "ok") { + showToast("往来查询失败", pairResult?.message || "请稍后重试", "danger"); + return; + } + const item = (pairResult.items || [])[0] || {}; + const a = item.a || { period: {}, result: {} }; + const debit = Number(a.period?.debit || 0); + const credit = Number(a.period?.credit || 0); + const opening = Number(item.opening?.amount || 0); + const signed = Number(a.result?.signed_amount || (debit - credit)); + $("#pairPeriod").textContent = `统计口径 ${start}—${endDate}`; + $("#pairOpening").innerHTML = formatWanHtml(opening); + $("#pairDebit").innerHTML = formatWanHtml(debit); + $("#pairCredit").innerHTML = formatWanHtml(credit); + const dirLabel = signed > 0 ? "应收" : signed < 0 ? "应付" : "持平"; + $("#pairFinal").innerHTML = `${dirLabel} ${formatWanHtml(Math.abs(signed))}`; + const unresolved = Number(item.unresolved?.count || 0); + $("#pairReviewStatus").textContent = unresolved ? `含 ${unresolved} 笔待审核` : "无待审核"; + const subjects = item.subjects || {}; + const totals = { 应收: 0, 其他应收: 0, 应付: 0, 其他应付: 0 }; + Object.values(subjects).forEach((bucket) => { + const label = bucket.label || SUBJECT_CODE_LABEL[bucket.subject_code]; + if (label && totals[label] != null) { + totals[label] = Number(bucket.a_debit || 0) + Number(bucket.a_credit || 0); + } + }); + const events = eventsResult?.items || []; + $('[data-subject-total="all"]').textContent = `${events.length} 笔`; + Object.entries(totals).forEach(([subject, value]) => { + $(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(formatCurrency(yuanToWan(value) || 0))); + }); + if (notice) notice.style.display = events.length ? "none" : ""; + state.pairContext = { from, to, endDate, fromId: fromCo.id, toId: toCo.id }; + state.pairRows = events.map((event) => { + const outgoing = Number(event.payer_company_id) === Number(fromCo.id); + return { + date: String(event.effective_at || "").slice(0, 10), + direction: outgoing ? "转出" : "转入", + subject: event.subject_label || SUBJECT_CODE_LABEL[event.subject_code] || "—", + ownAccount: event.own_account_label || event.own_account || "—", + counterparty: event.counterparty_company_name || (outgoing ? event.payee_company_name : event.payer_company_name) || to, + summary: event.summary || event.purpose || "—", + match: event.state === "confirmed" ? "双边匹配" : "待确认", + amount: Number(event.amount || 0), + serial: event.reference || event.source_id || "—", + batch: event.import_batch_id ? `IMP-${String(event.import_batch_id).padStart(6, "0")}` : "—", + peerAccount: event.counterparty_account || "—", + ledgerEventId: event.ledger_event_id, + }; + }); renderPairRows(); } function renderPairRows() { const tbody = $("#pairTransactions"); if (!tbody) return; - const format = (value) => Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - tbody.innerHTML = state.pairRows.map((row, index) => { - const matched = row.match === "双边匹配"; - return ` - ${row.date} - ${row.direction} - ${row.subject} - ${row.ownAccount} - ${row.counterparty} - ${row.summary} - ${row.match} - ${format(row.amount)} - - `; - }).join(""); + tbody.replaceChildren(); + (state.pairRows || []).forEach((row, index) => { + const tr = document.createElement("tr"); + tr.dataset.subject = row.subject; + tr.dataset.pairIdx = String(index); + [["td", "num", row.date], ["td", "", row.direction], ["td", "", row.subject], ["td", "", row.ownAccount], ["td", "", row.counterparty], ["td", "wrap", row.summary]].forEach(([tag, cls, value]) => { + const td = document.createElement(tag); + if (cls) td.className = cls; + td.textContent = value; + tr.append(td); + }); + const matchTd = document.createElement("td"); + const pill = document.createElement("span"); + pill.className = `pill ${row.match === "双边匹配" ? "pill-success" : "pill-warn"}`; + pill.textContent = row.match; + matchTd.append(pill); + const amt = document.createElement("td"); + amt.className = `num-col ${row.direction === "转出" ? "amt-out" : "amt-in"}`; + amt.textContent = formatCurrency(row.amount); + const act = document.createElement("td"); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-sm"; + btn.dataset.trace = String(index); + btn.textContent = "穿透"; + act.append(btn); + tr.append(matchTd, amt, act); + tbody.append(tr); + }); } function openTrace(index) { @@ -611,16 +644,16 @@ function openTrace(index) { const row = state.pairRows?.[index]; if (!row) return; const ctx = state.pairContext || { from: "—", to: "—" }; - $("#traceSub").textContent = `${ctx.from} ↔ ${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 万元`; - $("#kvOwnTx").textContent = "—(演示数据,未关联银行流水)"; + $("#traceSub").textContent = `${ctx.from} ↔ ${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 元`; + $("#kvOwnTx").textContent = row.serial || "—"; $("#kvOwnAcct").textContent = row.ownAccount; $("#kvPeerCo").textContent = ctx.to; - $("#kvPeerAcct").textContent = row.counterparty; + $("#kvPeerAcct").textContent = row.peerAccount || "—"; $("#kvTime").textContent = row.date; - $("#kvAmt").textContent = `${formatCurrency(row.amount)} 万元`; - $("#kvBatch").textContent = "—"; + $("#kvAmt").textContent = `${formatCurrency(row.amount)} 元`; + $("#kvBatch").textContent = row.batch || "—"; const evidence = $("#kvEvidence"); - if (row.match === "双边匹配") { evidence.textContent = "—"; evidence.style.color = ""; } + if (row.match === "双边匹配") { evidence.textContent = "已匹配"; evidence.style.color = ""; } else { evidence.textContent = "待对方提供"; evidence.style.color = "var(--warn)"; } modal.classList.add("open"); } @@ -678,67 +711,6 @@ function initPairQueries() { $("#traceModal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); }); } -function manualStatusMeta(status) { - if (status === "已确认") return { cls: "pill-success", label: "已通过" }; - if (status === "已驳回") return { cls: "pill-danger", label: "已驳回" }; - return { cls: "pill-info", label: "待审核" }; -} - -function renderCompanyManualRecords() { - const tbody = $("#manualRecordRows"); - if (!tbody) return; - tbody.replaceChildren(); - const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司"); - [...records].reverse().forEach((record) => { - const row = document.createElement("tr"); - row.dataset.storedRecord = record.id; - - const date = document.createElement("td"); date.className = "num"; date.textContent = record.transactionDate || "—"; - - const direction = document.createElement("td"); direction.textContent = record.direction || "—"; - - const counterparty = document.createElement("td"); - const counterpartyName = document.createElement("span"); counterpartyName.className = "cell-main"; counterpartyName.textContent = record.counterparty; - const counterpartyType = document.createElement("span"); counterpartyType.className = "cell-sub"; counterpartyType.textContent = record.counterpartyType || ""; - counterparty.append(counterpartyName, counterpartyType); - - const subject = document.createElement("td"); subject.innerHTML = `${record.subject || "—"}`; - - const isIn = record.direction === "收款"; - const amount = document.createElement("td"); - amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`; - amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`; - - const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—"; - - const statusMeta = manualStatusMeta(record.status); - const statusCell = document.createElement("td"); - statusCell.innerHTML = `${statusMeta.label}`; - - const action = document.createElement("td"); - if (record.status === "待管理复核") { - action.innerHTML = ''; - } else { - action.innerHTML = ''; - } - - row.append(date, direction, counterparty, subject, amount, summary, statusCell, action); - tbody.append(row); - }); - updateManualCounts(); -} - -function updateManualCounts() { - const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司"); - const pending = records.filter((record) => record.status === "待管理复核").length; - const pendingEl = $("#manualPendingStatus"); - if (pendingEl) pendingEl.textContent = pending; - const foot = $("#manualFoot"); - if (foot) foot.textContent = `共 ${records.length} 条 · 待复核 ${pending} 条`; - const empty = $("#manualEmpty"); - if (empty) empty.hidden = records.length > 0; -} - function fillAccountSelects(accounts) { const usable = accounts.filter((account) => account.usable); [$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => { @@ -1244,6 +1216,7 @@ async function loadCalculationSettings() { startInput.dataset.locked = data.locked ? "1" : "0"; startInput.dataset.current = data.calculation_start_date || ""; } + if (data.calculation_start_date) state.calculationStart = data.calculation_start_date; if (hint) { if (!data.calculation_start_date) { hint.textContent = "未设置起算日,系统暂按期间净变动口径显示"; @@ -2119,6 +2092,477 @@ async function loadAdminCompanies() { if (companies.length >= 2) setPair(companies[0].name, companies[1].name); } +function nextYearMonth(ym) { + const [y, m] = String(ym).split("-").map(Number); + const month = m === 12 ? 1 : m + 1; + const year = m === 12 ? y + 1 : y; + return `${year}-${String(month).padStart(2, "0")}`; +} + +function collapseTimeline(items) { + if (window.innerWidth > 460 || !items?.length) return items; + const locked = items.filter((item) => item.cell === "locked"); + const rest = items.filter((item) => item.cell !== "locked"); + if (locked.length < 2) return items; + return [ + { + year_month: `${locked[0].year_month}—${locked[locked.length - 1].year_month.slice(5)}`, + cell: "locked", + label: "均已锁定", + merged: true, + }, + ...rest, + ]; +} + +function renderTimeline(items) { + const root = $("#periodTimeline"); + if (!root) return; + root.replaceChildren(); + collapseTimeline(items || []).forEach((item) => { + const cell = document.createElement("div"); + const klass = item.cell === "locked" ? "locked" : item.cell === "current" ? "current" : item.cell === "reopened" ? "reopened" : item.cell === "failed" ? "failed" : "open"; + cell.className = `tl-cell ${klass}`; + const month = document.createElement("div"); + month.className = "tl-month"; + month.textContent = item.year_month; + const stateEl = document.createElement("div"); + stateEl.className = "tl-state"; + stateEl.textContent = item.label; + cell.append(month, stateEl); + root.append(cell); + }); +} + +function setClosingChecks(checks) { + (checks || []).forEach((check) => { + const row = $(`#closingPanel [data-closing-key="${check.key}"]`); + if (!row) return; + const ok = !!check.ok; + const mark = $("[data-closing-check]", row); + const sub = $("[data-closing-sub]", row); + const stateEl = $("[data-closing-state]", row); + if (mark) { + mark.className = `pill ${ok ? "pill-success" : "pill-danger"}`; + mark.textContent = check.title; + } + if (sub) sub.textContent = check.detail || "—"; + if (stateEl) { + stateEl.className = `pill ${ok ? "pill-success" : check.blocking ? "pill-danger" : "pill-warn"}`; + stateEl.textContent = ok ? "已通过" : check.blocking ? "阻断" : "待处理"; + } + }); +} + +function applyClosePanel(overview) { + state.periodClose = overview; + const close = overview.close || {}; + const ym = close.year_month || overview.target_month || "—"; + renderTimeline(overview.timeline); + const start = state.calculationStart || "—"; + if ($("#timelineSub")) $("#timelineSub").textContent = `全局起算日 ${start} 起,每月 ${overview.closing_day || "—"} 日结账`; + setClosingChecks(close.checks); + $("#ckMonth").textContent = ym; + const totals = close.snapshot?.totals || {}; + const net = totals.net_wan ?? totals.debit_wan; + if ($("#ckNet")) $("#ckNet").textContent = net != null ? `${formatCurrency(net)} 万元` : "—"; + $("#ckCarry").textContent = ym !== "—" ? `结转至 ${nextYearMonth(ym)} 期初` : "—"; + $("#ckReport").textContent = close.report_no || "尚未生成"; + ["block-notice", "failed-notice", "closed-notice", "reopened-notice"].forEach((id) => { + const el = $(`#${id}`); + if (el) el.style.display = "none"; + }); + const status = $("#closingStatus"); + const btn = $("#executeClosing"); + const panel = $("#closingPanel"); + const busy = $("#closingBusy"); + const dl = $("#downloadMonthReport"); + panel?.classList.remove("is-processing"); + if (busy) busy.hidden = true; + if (dl) dl.hidden = !close.report_no; + const history = overview.history; + if ($("#closingHistory")) { + $("#closingHistory").textContent = history + ? `最近:${history.year_month} · ${history.closed_by_username || "—"} · ${String(history.closed_at || "").slice(0, 16).replace("T", " ")}` + : "尚无结账记录"; + } + const desc = $("#closingDescription"); + if (close.status === "closing") { + panel?.classList.add("is-processing"); + if (busy) busy.hidden = false; + status.className = "pill pill-info"; + status.textContent = "处理中"; + desc.textContent = `${ym} · 正在锁定账期并生成月报`; + btn.disabled = true; + return; + } + if (close.status === "failed") { + status.className = "pill pill-danger"; + status.textContent = "结账失败"; + desc.textContent = `${ym} · 未改动任何数据`; + const failed = $("#failed-notice"); + if (failed) { + failed.style.display = ""; + $("#failed-notice-body").textContent = `${close.fail_reason || "结账失败"} · 未改动任何数据,可重新执行。`; + } + btn.disabled = !close.ready; + btn.className = "btn btn-primary"; + btn.textContent = "重新执行结账"; + return; + } + if (close.status === "closed" || close.locked) { + status.className = "pill pill-lock"; + status.textContent = "已锁定"; + desc.textContent = `${ym} · 已结账锁定`; + const closed = $("#closed-notice"); + if (closed) { + closed.style.display = ""; + $("#closed-notice-title").textContent = `${ym} 已结账`; + $("#closed-notice-body").textContent = `月报 ${close.report_no || "—"} · 结账人 ${close.closed_by_username || "—"} · ${String(close.closed_at || "").slice(0, 16).replace("T", " ")}`; + } + btn.disabled = false; + btn.className = "btn"; + btn.textContent = `申请重开 ${ym} 账期`; + btn.dataset.mode = "reopen"; + return; + } + if (close.status === "reopened") { + status.className = "pill pill-warn"; + status.textContent = "已重开"; + const days = close.reopen_remaining_days; + desc.textContent = `${ym} · 重开窗口内可更正`; + const reopened = $("#reopened-notice"); + if (reopened) { + reopened.style.display = ""; + $("#reopened-notice-body").textContent = `审批通过后窗口截止 ${close.reopen_window_end || "—"} · 剩 ${days == null ? "—" : days} 天,到期自动恢复锁定。`; + } + btn.disabled = !close.ready; + btn.className = "btn btn-warn"; + btn.textContent = "提前结束重开并重新结账"; + btn.dataset.mode = "close"; + return; + } + const blocked = (close.blockers || []).length > 0 || !close.ready; + status.className = `pill ${blocked ? "pill-danger" : "pill-success"}`; + status.textContent = blocked ? "已阻断" : "可结账"; + desc.textContent = blocked ? `${ym} · 当前未达到结账条件` : `${ym} · 全部前置检查已通过`; + if (blocked) { + const block = $("#block-notice"); + if (block) { + block.style.display = ""; + $("#block-notice-title").textContent = `存在阻断项,暂不能执行 ${ym} 月度结账`; + $("#block-notice-body").textContent = (close.blockers || []).map((item) => item.detail).join(";") || "请先处理待审核事项与账户断档。"; + } + } + btn.disabled = blocked; + btn.className = "btn btn-primary"; + btn.textContent = `执行 ${ym} 月度结账`; + btn.dataset.mode = "close"; + if (blocked) btn.title = "请先处理全部阻断事项"; + else btn.removeAttribute("title"); +} + +async function loadPeriodClose() { + if (!$("#closingPanel")) return; + const response = await fetch("/api/admin/period-closes").catch(() => null); + if (response?.status === 401) { window.location.href = "index.html"; return; } + const result = await response?.json().catch(() => null); + if (!response?.ok || result?.status !== "ok") { + showToast("结账状态加载失败", result?.message || "请稍后重试", "danger"); + return; + } + applyClosePanel(result); +} + +function initPeriodClose() { + $("#runClosingCheck")?.addEventListener("click", () => loadPeriodClose()); + $("#downloadMonthReport")?.addEventListener("click", () => { + const ym = state.periodClose?.close?.year_month; + if (ym) window.location.href = `/api/admin/period-closes/${ym}/report.json`; + }); + $("#executeClosing")?.addEventListener("click", () => { + const mode = $("#executeClosing")?.dataset.mode || "close"; + const ym = state.periodClose?.close?.year_month || ""; + if (mode === "reopen") { + $("#reopenRequestTitle").textContent = `申请重开 ${ym} 账期`; + openModal("reopenRequestDialog"); + return; + } + $("#closingDialogTitle").textContent = `确认执行 ${ym} 月度结账`; + $("#closingDialogSub").textContent = `结账后 ${ym} 流水与往来确认将锁定,不可再直接修改。`; + $("#cdMonth").textContent = ym; + $("#cdChecks").textContent = (state.periodClose?.close?.checks || []).every((c) => c.ok) ? "全部前置检查已通过" : "仍有未通过项"; + $("#cdCarry").textContent = `往来净额将结转至 ${nextYearMonth(ym)} 期初`; + const box = $("#closingConfirmBox"); + if (box) box.checked = false; + openModal("closingDialog"); + }); + $$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog"))); + $("#closingForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const ym = state.periodClose?.close?.year_month; + if (!ym || !$("#closingConfirmBox")?.checked) { + showToast("请先勾选确认", "须复核结账结果并知晓锁定后果", "warn"); + return; + } + closeModal("closingDialog"); + $("#closingPanel")?.classList.add("is-processing"); + if ($("#closingBusy")) $("#closingBusy").hidden = false; + const response = await fetch(`/api/admin/period-closes/${ym}/execute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: true }), + }).catch(() => null); + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + showToast("结账失败", result?.message || "未改动任何数据", "danger"); + await loadPeriodClose(); + return; + } + showToast(`${ym} 已完成结账`, `月报 ${result.report_no || ""} 已生成并锁定`, "success"); + await loadPeriodClose(); + }); + $$("[data-close-reopen-req]").forEach((button) => button.addEventListener("click", () => closeModal("reopenRequestDialog"))); + $("#reopenRequestForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const ym = state.periodClose?.close?.year_month; + const reason = $("#reopenReason")?.value.trim() || ""; + if (reason.length < 10) { + showToast("原因过短", "重开原因不少于 10 个字,将写入审计记录", "warn"); + return; + } + const response = await fetch(`/api/admin/period-closes/${ym}/reopen`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reason, + companies_note: $("#reopenCompanies")?.value || "", + window_days: Number($("#reopenDays")?.value || 3), + }), + }).catch(() => null); + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + showToast("申请失败", result?.message || "请稍后重试", "danger"); + return; + } + closeModal("reopenRequestDialog"); + showToast("重开申请已提交", result.item?.number || "", "success"); + await loadPeriodClose(); + await loadReopenQueue(); + }); + $$("[data-close-reopen-dec]").forEach((button) => button.addEventListener("click", () => closeModal("reopenDecideDialog"))); + $("#reopenApproveBtn")?.addEventListener("click", () => decideReopen(true)); + $("#reopenRejectBtn")?.addEventListener("click", () => decideReopen(false)); + $("#paApply")?.addEventListener("click", () => loadPeriodAudit()); + $("#paReload")?.addEventListener("click", () => loadPeriodAudit()); + $("#paReset")?.addEventListener("click", () => { + ["paSince", "paUntil", "paCompany"].forEach((id) => { if ($(`#${id}`)) $(`#${id}`).value = ""; }); + if ($("#paAction")) $("#paAction").value = ""; + loadPeriodAudit(); + }); +} + +function renderDiffGrid(diff) { + const root = $("#reopenDiff"); + if (!root) return; + root.replaceChildren(); + (diff || []).forEach((row) => { + const wrap = document.createElement("div"); + wrap.className = `diff-row${row.changed ? " changed" : ""}`; + const label = document.createElement("div"); + label.className = "diff-label"; + label.textContent = row.path || "状态"; + const before = document.createElement("div"); + before.className = "diff-before"; + before.textContent = row.before == null ? "—" : typeof row.before === "object" ? JSON.stringify(row.before) : String(row.before); + const arrow = document.createElement("div"); + arrow.className = "diff-arrow"; + arrow.textContent = "→"; + const after = document.createElement("div"); + after.className = "diff-after"; + after.textContent = row.after == null ? "—" : typeof row.after === "object" ? JSON.stringify(row.after) : String(row.after); + wrap.append(label, before, arrow, after); + root.append(wrap); + }); +} + +async function openReopenDecide(id) { + const response = await fetch(`/api/admin/period-reopens/${id}`).catch(() => null); + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + showToast("加载失败", result?.message || "请稍后重试", "danger"); + return; + } + const item = result.item || {}; + state.reopenDecideId = item.id; + $("#reopenDecideTitle").textContent = `重开审批 · ${item.year_month}`; + $("#reopenDecideSub").textContent = `${item.number || ""} · ${item.requester_username || ""} · ${String(item.requested_at || "").slice(0, 16).replace("T", " ")}`; + const kv = $("#reopenDecideKv"); + if (kv) { + kv.replaceChildren(); + [["账期", item.year_month], ["原因", item.reason], ["涉及", item.companies_note || "—"], ["窗口", `${item.window_days} 天`], ["原月报", item.report_no || "—"]].forEach(([dt, dd]) => { + const t = document.createElement("dt"); t.textContent = dt; + const d = document.createElement("dd"); d.textContent = dd; + kv.append(t, d); + }); + } + renderDiffGrid(item.diff); + $("#reopenComment").value = ""; + $("#reopenApproveBtn").textContent = `同意重开 ${item.year_month}`; + openModal("reopenDecideDialog"); +} + +async function decideReopen(approve) { + const id = state.reopenDecideId; + const comment = $("#reopenComment")?.value.trim() || ""; + if (!approve && comment.length < 2) { + showToast("请填写驳回意见", "驳回必须填写审批意见", "warn"); + return; + } + const response = await fetch(`/api/admin/period-reopens/${id}/decide`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approve, comment }), + }).catch(() => null); + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + showToast("审批失败", result?.message || "请稍后重试", "danger"); + return; + } + closeModal("reopenDecideDialog"); + showToast(approve ? "已同意重开" : "已驳回申请", result.item?.number || "", approve ? "warn" : "danger"); + await loadPeriodClose(); + await loadReopenQueue(); +} + +function reopenStatusPill(status) { + if (status === "approved") return { cls: "pill-warn", label: "已通过" }; + if (status === "rejected") return { cls: "pill-danger", label: "已驳回" }; + return { cls: "pill-info", label: "待审批" }; +} + +async function loadReopenQueue() { + const list = $("#reopenQueueList"); + if (!list) return; + const response = await fetch("/api/admin/period-reopens").catch(() => null); + const result = await response?.json().catch(() => ({})); + const items = result.items || []; + const pending = items.filter((item) => item.status === "pending").length; + if ($("#reopenTabCount")) $("#reopenTabCount").textContent = String(pending); + list.replaceChildren(); + items.forEach((item) => { + const row = document.createElement("div"); + row.className = "list-row"; + const pill = document.createElement("span"); + const meta = reopenStatusPill(item.status); + pill.className = `pill ${meta.cls}`; + pill.textContent = meta.label; + const main = document.createElement("div"); + main.className = "lr-main"; + const title = document.createElement("div"); + title.className = "lr-title"; + title.textContent = `申请重开 ${item.year_month}`; + const sub = document.createElement("div"); + sub.className = "lr-sub"; + sub.textContent = item.reason || ""; + const metaLine = document.createElement("div"); + metaLine.className = "meta"; + metaLine.textContent = `${item.number} · ${item.requester_username || ""} · ${String(item.requested_at || "").slice(0, 16).replace("T", " ")}`; + main.append(title, sub, metaLine); + const side = document.createElement("div"); + side.className = "lr-side"; + if (item.status === "pending") { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-sm btn-primary"; + btn.textContent = "审批"; + btn.addEventListener("click", () => openReopenDecide(item.id)); + side.append(btn); + } else { + const num = document.createElement("span"); + num.className = "meta"; + num.textContent = item.number; + side.append(num); + } + row.append(pill, main, side); + list.append(row); + }); + const empty = $("#reopenQueueEmpty"); + if (empty) empty.hidden = items.length > 0; +} + +function auditActionPill(action) { + if (action === "close_execute") return { cls: "pill-success", label: "执行月结" }; + if (action === "reopen_request") return { cls: "pill-info", label: "重开申请" }; + if (action === "reopen_approve") return { cls: "pill-warn", label: "重开审批通过" }; + if (action === "close_fail") return { cls: "pill-danger", label: "月结失败" }; + if (action === "reopen_reject") return { cls: "pill-danger", label: "重开驳回" }; + return { cls: "pill-muted", label: action || "—" }; +} + +async function loadPeriodAudit() { + const tbody = $("#periodAuditRows"); + if (!tbody) return; + showTableLoading(tbody, 6); + $("#paError").style.display = "none"; + const params = new URLSearchParams(); + if ($("#paSince")?.value) params.set("since", $("#paSince").value); + if ($("#paUntil")?.value) params.set("until", $("#paUntil").value); + if ($("#paAction")?.value) params.set("action", $("#paAction").value); + if ($("#paCompany")?.value) params.set("company", $("#paCompany").value); + const response = await fetch(`/api/admin/period-audit?${params.toString()}`).catch(() => null); + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + showTableError(tbody, 6); + $("#paError").style.display = ""; + return; + } + const items = result.items || []; + tbody.replaceChildren(); + const cards = $("#periodAuditCards"); + if (cards) cards.replaceChildren(); + items.forEach((item) => { + const tr = document.createElement("tr"); + const time = document.createElement("td"); time.className = "num"; time.textContent = String(item.created_at || "").slice(0, 19).replace("T", " "); + const actor = document.createElement("td"); + const name = document.createElement("div"); name.textContent = item.actor_username || "—"; + const role = document.createElement("div"); role.className = "cell-sub"; role.textContent = item.actor_role || ""; + actor.append(name, role); + const action = document.createElement("td"); + const pill = document.createElement("span"); + const meta = auditActionPill(item.action); + pill.className = `pill ${meta.cls}`; + pill.textContent = meta.label; + action.append(pill); + const obj = document.createElement("td"); obj.className = "wrap"; obj.textContent = `${item.year_month || ""} ${item.reason || item.object_label || ""}`.trim(); + const change = document.createElement("td"); change.className = "num"; + const before = item.before?.snapshot_hash || item.before?.status || ""; + const after = item.after?.snapshot_hash || item.after?.status || ""; + change.textContent = before || after ? `${String(before).slice(0, 8)} → ${String(after).slice(0, 8)}` : "—"; + const proof = document.createElement("td"); proof.textContent = item.report_no || "—"; + tr.append(time, actor, action, obj, change, proof); + tbody.append(tr); + if (cards) { + const card = document.createElement("div"); + card.className = "card"; + card.style.padding = "12px 14px"; + [["时间", time.textContent], ["操作人", item.actor_username], ["动作", meta.label], ["对象", obj.textContent], ["变化", change.textContent], ["凭证", item.report_no || "—"]].forEach(([k, v]) => { + const line = document.createElement("div"); + line.className = "row-between"; + const l = document.createElement("span"); l.className = "meta"; l.textContent = k; + const r = document.createElement("span"); r.textContent = v || "—"; + line.append(l, r); + card.append(line); + }); + cards.append(card); + } + }); + $("#periodAuditFoot").textContent = `共 ${items.length} 条`; + const empty = $("#periodAuditEmpty"); + if (empty) empty.hidden = items.length > 0; +} + + function initAdmin() { renderStoredAdminReviews(); updateAuditCounts(); @@ -2128,6 +2572,8 @@ function initAdmin() { loadCalculationChanges(); loadAdminCoverageGaps(); initDashboard(); + loadPeriodClose(); + loadReopenQueue(); let activeAuditType = "all"; function filterAuditRows() { @@ -2145,7 +2591,11 @@ function initAdmin() { item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); - filterAuditRows(); + const reopen = activeAuditType === "reopen"; + if ($("#auditTableWrap")) $("#auditTableWrap").hidden = reopen; + if ($("#reopenQueue")) $("#reopenQueue").hidden = !reopen; + if (reopen) loadReopenQueue(); + else filterAuditRows(); })); $("#auditCompany")?.addEventListener("change", filterAuditRows); @@ -2175,7 +2625,7 @@ function initAdmin() { } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { - showToast("审核结果提交失败", result?.message || "请稍后重试", "danger"); + if (!toastIfLocked(result)) showToast("审核结果提交失败", result?.message || "请稍后重试", "danger"); return; } reviewedAccount = result.account; @@ -2199,7 +2649,7 @@ function initAdmin() { } const result = await response?.json().catch(() => ({})); if (!response || !response.ok) { - showToast("手工单审核失败", result?.message || "请稍后重试", "danger"); + if (!toastIfLocked(result)) showToast("手工单审核失败", result?.message || "请稍后重试", "danger"); return; } storedStatus = approved ? "已确认" : "已退回"; @@ -2536,52 +2986,8 @@ function initAdmin() { showToast("系统计算口径已保存", "结账日与起算日变更已留痕 · 提醒扫描参数已同步更新", "success"); }); - $("#runClosingCheck")?.addEventListener("click", () => { - const unresolved = $$(".audit-table tbody tr").filter((row) => row.dataset.resolved !== "true").length; - const blockNotice = $("#block-notice"); - if (unresolved) { - if (blockNotice) blockNotice.style.display = ""; - $("#closingDescription").textContent = `2026 年 7 月 · 仍有 ${unresolved} 项审核事项未处理`; - $("#closingStatus").className = "pill pill-danger"; - $("#closingStatus").textContent = "已阻断"; - $("#executeClosing").disabled = true; - showToast("结账检查未通过", `仍有 ${unresolved} 项审核事项,已打开审核中心`, "warn"); - showView("audit"); - } else { - if (blockNotice) blockNotice.style.display = "none"; - $$("#closingPanel [data-closing-state]").forEach((item) => { - item.className = "pill pill-success"; - item.textContent = "已通过"; - }); - $("#closingDescription").textContent = "2026 年 7 月 · 全部前置检查已通过"; - $("#closingStatus").className = "pill pill-success"; - $("#closingStatus").textContent = "可结账"; - $("#executeClosing").disabled = false; - $("#executeClosing").removeAttribute("title"); - showToast("结账检查通过", "执行结账按钮已解锁", "success"); - } - }); - - $("#executeClosing")?.addEventListener("click", () => openModal("closingDialog")); - $$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog"))); - $("#closingForm")?.addEventListener("submit", (event) => { - event.preventDefault(); - closeModal("closingDialog"); - $("#closingDescription").textContent = "2026 年 7 月 · 已完成集团结账"; - $("#closingStatus").className = "pill pill-success"; - $("#closingStatus").textContent = "已结账"; - $("#closingHistory").textContent = `${new Date().toLocaleString("zh-CN", { hour12: false })} · 系统管理员执行 2026 年 7 月结账 · 已写入审计记录`; - $("#runClosingCheck").disabled = true; - $("#executeClosing").disabled = true; - $("#executeClosing").textContent = "7 月已结账"; - const closedNotice = $("#closed-notice"); - if (closedNotice) closedNotice.style.display = ""; - const tlCurrent = $("#tl-current"); - tlCurrent?.classList.remove("current"); - tlCurrent?.classList.add("closed"); - $("#tl-current-state").textContent = "已结账"; - showToast("2026 年 7 月已完成结账", "本期结果已锁定,后续补录将进入重开流程", "success"); - }); + initPeriodClose(); + loadPeriodClose(); $("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog")); $$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog"))); @@ -2663,174 +3069,146 @@ function initAdmin() { loadSystemSettings(); } -const FLOW_DEMO = [ - { date: "2026-07-01", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "平顶山市恒源电力燃料有限公司", summary: "煤炭销售款(6 月结算)", serial: "ICBC202607010031825", status: "未归集", statusPill: "pill-muted", amount: "1,860,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-01 09:42:17", peer: "平顶山市恒源电力燃料有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 6641 20", amount: "¥ 1,860,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外客户,不进入内部往来归集,仅作银行流水留档。" } }, - { date: "2026-07-03", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "光伏支架材料款", serial: "ICBC202607030094417", status: "单边", statusPill: "pill-danger", amount: "1,620,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-03 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,620,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-002", note: "贸易侧 7 月上报流水中未找到对应收款,已挂起为单边流水,待贸易侧补充银行凭证佐证。" } }, - { date: "2026-07-03", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 12 号楼)", serial: "BOC202607030552108", status: "未归集", statusPill: "pill-muted", amount: "4,150,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-03 10:26:31", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 4,150,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入,属对外经营收款,不参与集团内部往来归集。" } }, - { date: "2026-07-05", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "矿区运输费(6 月)", serial: "ICBC202607050127663", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-05 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 1,240,000.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JC-2026-07-014", note: "与物流侧建行尾号 5562 账户 07-05 收款流水双向匹配,金额一致。" } }, - { date: "2026-07-05", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛煤业有限公司", summary: "矿区运输费(6 月)", serial: "CCB202607050312940", status: "已归集", statusPill: "pill-success", amount: "1,240,000.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-05 11:15:36", peer: "河南金牛煤业有限公司", peerAcct: "工行平顶山分行 1702 0231 0900 8133 05", amount: "¥ 1,240,000.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛煤业", subject: "应收(物流侧)", batch: "JC-2026-07-014", note: "与煤业侧工行尾号 3305 账户 07-05 付款流水双向匹配,金额一致。" } }, - { date: "2026-07-08", company: "金牛煤业", bank: "工行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(7 月)", serial: "ICBC202607080208554", status: "待确认", statusPill: "pill-warn", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "—(待归集)", note: "置业中行尾号 8821 账户 07-06 至 07-16 流水断档,对方付款凭证缺失,暂无法完成双边匹配,已列入审核中心高风险事项。" } }, - { date: "2026-07-09", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "收", dirPill: "pill-success", peer: "洛阳建工集团有限公司", summary: "钢材销售款(6 月发货)", serial: "ABC202607090773261", status: "未归集", statusPill: "pill-muted", amount: "2,480,000.00", amtClass: "amt-in", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-09 09:58:44", peer: "洛阳建工集团有限公司", peerAcct: "中行洛阳分行 2546 2201 0500 7915", amount: "¥ 2,480,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外钢材销售回款,不参与集团内部往来归集。" } }, - { date: "2026-07-11", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "电缆及配电柜采购款", serial: "ICBC202607110158902", status: "单边", statusPill: "pill-danger", amount: "1,950,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-11 16:32:08", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,950,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-005", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } }, - { date: "2026-07-14", company: "金牛煤业", bank: "中行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "平顶山天安煤业设备租赁有限公司", summary: "综采设备租赁费(7 月)", serial: "BOC202607140416337", status: "未归集", statusPill: "pill-muted", amount: "920,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-14 10:11:57", peer: "平顶山天安煤业设备租赁有限公司", peerAcct: "建行平顶山分行 4105 0229 8080 1347", amount: "¥ 920,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "综采设备月度租赁支出,对方为集团外供应商,不参与内部归集。" } }, - { date: "2026-07-18", company: "金牛煤业", bank: "交行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "付", dirPill: "pill-danger", peer: "平顶山市安泰矿山设备有限公司", summary: "提升机大修款", serial: "BOCOM202607180062194", status: "待确认", statusPill: "pill-warn", amount: "685,400.00", amtClass: "amt-out", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-18 13:29:40", peer: "平顶山市安泰矿山设备有限公司", peerAcct: "工行平顶山分行 1702 0218 0902 9075 63", amount: "¥ 685,400.00(付)", status: "待确认", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "付款账户(交行尾号 7710)为新开户,尚在账户审核流程中,流水暂挂待确认,审核通过后自动归档为外部交易。" } }, - { date: "2026-07-21", company: "金牛置业", bank: "中行", account: "8821", acctLabel: "中行 · 尾号 8821", dir: "收", dirPill: "pill-success", peer: "郑州市商品房预售资金监管专户", summary: "商品房预售款(A 区 15 号楼)", serial: "BOC202607210588420", status: "未归集", statusPill: "pill-muted", amount: "3,780,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛置业有限公司 · 一般户 2546 0387 0200 8821", time: "2026-07-21 09:35:12", peer: "郑州市商品房预售资金监管专户", peerAcct: "中行郑州郑东新区支行 2546 1180 0200 3477", amount: "¥ 3,780,000.00(收)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "预售监管资金划入。该账户 07-06 至 07-16 存在流水断档,本笔为断档后首笔入账。" } }, - { date: "2026-07-22", company: "金牛物流", bank: "建行", account: "5562", acctLabel: "建行 · 尾号 5562", dir: "收", dirPill: "pill-success", peer: "河南金牛贸易有限公司", summary: "钢材干线运输费(6-7 月)", serial: "CCB202607220347815", status: "已归集", statusPill: "pill-success", amount: "462,800.00", amtClass: "amt-in", detail: { bank: "建设银行", account: "河南金牛物流有限公司 · 基本户 4105 0167 8080 5562", time: "2026-07-22 14:52:26", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 462,800.00(收)", status: "已归集", pair: "金牛物流 ↔ 金牛贸易", subject: "应收(物流侧)", batch: "JC-2026-07-021", note: "与贸易侧农行尾号 2208 账户 07-22 付款流水双向匹配,金额一致,已计入 7 月往来批次。" } }, - { date: "2026-07-24", company: "金牛新能源", bank: "工行", account: "6649", acctLabel: "工行 · 尾号 6649", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "组件辅材结算款", serial: "ICBC202607240221476", status: "单边", statusPill: "pill-danger", amount: "1,290,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛新能源有限公司 · 一般户 1704 0512 0900 2266 49", time: "2026-07-24 11:06:33", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 1,290,000.00(付)", status: "单边", pair: "金牛新能源 ↔ 金牛贸易", subject: "其他应付(新能源侧)", batch: "UNI-2026-07-009", note: "贸易侧无对应收款记录,单边挂起。新能源↔贸易本月累计 3 笔单边流水,合计 486 万元。" } }, - { date: "2026-07-25", company: "金牛贸易", bank: "农行", account: "2208", acctLabel: "农行 · 尾号 2208", dir: "付", dirPill: "pill-danger", peer: "安阳钢铁集团有限责任公司", summary: "螺纹钢采购款(7 月)", serial: "ABC202607250819673", status: "未归集", statusPill: "pill-muted", amount: "5,620,000.00", amtClass: "amt-out", detail: { bank: "农业银行", account: "河南金牛贸易有限公司 · 基本户 1606 3301 0400 0220 8", time: "2026-07-25 10:19:05", peer: "安阳钢铁集团有限责任公司", peerAcct: "工行安阳分行 1706 0211 0900 4428 17", amount: "¥ 5,620,000.00(付)", status: "未归集", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外螺纹钢采购付款,不参与集团内部往来归集。" } }, -]; - -// 原型占位,非本司真流水:COMPANY_FLOWS 仅用于演示公司端流水列表界面, -// 真实流水来自 /api/parse 导入与 /api/batches 批次,切勿把它当成真数据源。 -const COMPANY_FLOWS = [ - { date: "2026-07-02", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "煤炭采购款(2026 年 6 月供煤合同结算)", serial: "ICBC2026070200185347", status: "已归集", statusPill: "pill-success", amount: "3,200,000.00", amtClass: "amt-in", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-02 15:47:22", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 3,200,000.00(收)", status: "已归集", pair: "金牛煤业 ↔ 金牛置业", subject: "应收(煤业侧)", batch: "JH-202607-014", note: "与置业侧中行尾号 8821 账户付款流水已双向匹配。" } }, - { date: "2026-07-03", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "山西晋城王坡煤矿有限责任公司", summary: "原料煤采购预付款", serial: "ICBC2026070300221091", status: "未归集 · 外部", statusPill: "pill-muted", amount: "860,000.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-03 10:26:31", peer: "山西晋城王坡煤矿有限责任公司", peerAcct: "工行晋城分行 1702 0218 0902 6641 20", amount: "¥ 860,000.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对方为集团外供应商,不参与内部往来归集,仅作银行流水留档。" } }, - { date: "2026-07-06", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南神火运销有限公司", summary: "动力煤销售货款(7 月第一批)", serial: "BOC2026070600772018", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,246,800.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-06 09:58:44", peer: "河南神火运销有限公司", peerAcct: "工行永城分行 1702 0218 0902 9075 63", amount: "¥ 1,246,800.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外动力煤销售回款,不参与集团内部往来归集。" } }, - { date: "2026-07-08", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "河南金牛物流有限公司", summary: "6 月煤炭公路运输费结算", serial: "ICBC2026070800311276", status: "已归集", statusPill: "pill-success", amount: "486,500.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-08 11:15:09", peer: "河南金牛物流有限公司", peerAcct: "建行郑州经开区支行 4105 0167 8080 5562", amount: "¥ 486,500.00(付)", status: "已归集", pair: "金牛煤业 ↔ 金牛物流", subject: "应付(煤业侧)", batch: "JH-202607-014", note: "与物流侧建行收款流水已双向匹配,运单 42 张随附。" } }, - { date: "2026-07-10", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛贸易有限公司", summary: "选煤设备配件代购款", serial: "BOC2026071000819455", status: "待确认", statusPill: "pill-warn", amount: "214,700.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-10 14:08:52", peer: "河南金牛贸易有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 214,700.00(付)", status: "待确认", pair: "金牛煤业 ↔ 金牛贸易", subject: "其他应付(待复核)", batch: "JH-202607-021", note: "贸易侧已确认收款,科目待双方复核(应付 / 其他应付)。" } }, - { date: "2026-07-13", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国网河南省电力公司新密市供电公司", summary: "7 月工业电费", serial: "ICBC2026071300458820", status: "未归集 · 外部", statusPill: "pill-muted", amount: "1,528,300.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-13 10:19:05", peer: "国网河南省电力公司新密市供电公司", peerAcct: "工行新密支行 1706 0211 0900 4428 17", amount: "¥ 1,528,300.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外电费支出,不参与集团内部往来归集。" } }, - { date: "2026-07-15", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "河南金牛新能源有限公司", summary: "场区租赁费返还(二季度)", serial: "BOC2026071500923314", status: "单边", statusPill: "pill-danger", amount: "95,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-15 11:06:33", peer: "河南金牛新能源有限公司", peerAcct: "工行郑州分行 1704 0512 0900 2266 49", amount: "¥ 95,000.00(收)", status: "单边", pair: "金牛煤业 ↔ 金牛新能源", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "新能源侧尚未提报对应付款流水,形成单边。" } }, - { date: "2026-07-18", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "国家税务总局新密市税务局", summary: "增值税及附加税费(6 月属期)", serial: "ICBC2026071800506639", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,073,450.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-18 13:29:40", peer: "国家税务总局新密市税务局", peerAcct: "国库专户", amount: "¥ 2,073,450.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "税费缴库,不参与集团内部往来归集。" } }, - { date: "2026-07-21", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "收", dirPill: "pill-success", peer: "永城煤电控股集团有限公司", summary: "块煤销售款(年度长协第 7 批)", serial: "BOC2026072101054472", status: "未归集 · 外部", statusPill: "pill-muted", amount: "3,864,000.00", amtClass: "amt-in", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-21 09:35:12", peer: "永城煤电控股集团有限公司", peerAcct: "工行永城分行 1702 0218 0902 3477 12", amount: "¥ 3,864,000.00(收)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "对外块煤销售回款,不参与集团内部往来归集。" } }, - { date: "2026-07-24", company: "金牛煤业", bank: "交通银行", account: "7710", acctLabel: "交行 · 尾号 7710", dir: "收", dirPill: "pill-success", peer: "河南金牛置业有限公司", summary: "临时往来款归还", serial: "BCM2026072400088116", status: "待确认", statusPill: "pill-warn", amount: "1,500,000.00", amtClass: "amt-in", detail: { bank: "交通银行", account: "河南金牛煤业有限公司 · 一般户 4110 6120 0181 0077 10(账户待审核)", time: "2026-07-24 14:52:26", peer: "河南金牛置业有限公司", peerAcct: "中行郑州郑东新区支行 2546 0387 0200 8821", amount: "¥ 1,500,000.00(收)", status: "待确认", pair: "金牛煤业 ↔ 金牛置业", subject: "其他应收(煤业侧)", batch: "JH-202607-021", note: "交行 7710 账户尚处待审核,归集结果以账户审核通过后为准。" } }, - { date: "2026-07-27", company: "金牛煤业", bank: "工商银行", account: "3305", acctLabel: "工行 · 尾号 3305", dir: "付", dirPill: "pill-danger", peer: "煤业职工工资代发(2026 年 7 月)", summary: "7 月职工工资及奖金代发,共 612 人", serial: "ICBC2026072700582241", status: "未归集 · 外部", statusPill: "pill-muted", amount: "2,416,780.00", amtClass: "amt-out", detail: { bank: "工商银行", account: "河南金牛煤业有限公司 · 基本户 1702 0231 0900 8133 05", time: "2026-07-27 10:11:57", peer: "煤业职工工资代发(2026 年 7 月)", peerAcct: "代发专户", amount: "¥ 2,416,780.00(付)", status: "未归集 · 外部", pair: "—(外部单位交易)", subject: "—", batch: "—", note: "工资代发,不参与集团内部往来归集。" } }, - { date: "2026-07-30", company: "金牛煤业", bank: "中国银行", account: "9916", acctLabel: "中行 · 尾号 9916", dir: "付", dirPill: "pill-danger", peer: "河南金牛农业科技发展有限公司", summary: "临时周转借款(约定 8 月归还)", serial: "BOC2026073001187903", status: "单边", statusPill: "pill-danger", amount: "800,000.00", amtClass: "amt-out", detail: { bank: "中国银行", account: "河南金牛煤业有限公司 · 一般户 2546 0387 1100 9916", time: "2026-07-30 16:32:08", peer: "河南金牛农业科技发展有限公司", peerAcct: "农行郑州金水支行 1606 3301 0400 0220 8", amount: "¥ 800,000.00(付)", status: "单边", pair: "金牛煤业 ↔ 金牛农业", subject: "其他应收(煤业侧)", batch: "—(待归集)", note: "农业 7 月未提交流水,暂无对方侧证据。" } }, -]; - -function currentFlowData() { - return portal === "admin" ? FLOW_DEMO : COMPANY_FLOWS; +async function loadFlows() { + const tbody = $("#flowTable tbody"); + if (!tbody) return; + const params = new URLSearchParams(); + const companyName = $("#flowCompany")?.value; + if (companyName && companyName !== "全部公司") { + const company = companyByName(companyName); + if (company) params.set("company_id", String(company.id)); + } + const bank = $("#flowBank")?.value; + if (bank && bank !== "全部银行") params.set("bank", bank); + const account = $("#flowAccount")?.value; + if (account && account !== "全部账户") params.set("account", account); + if ($("#flowStart")?.value) params.set("start", $("#flowStart").value); + if ($("#flowEnd")?.value) params.set("end", $("#flowEnd").value); + if ($("#flowKeyword")?.value.trim()) params.set("keyword", $("#flowKeyword").value.trim()); + params.set("limit", "200"); + const response = await fetch(`/api/flows?${params.toString()}`).catch(() => null); + if (response?.status === 401) { window.location.href = "index.html"; return; } + const result = await response?.json().catch(() => null); + if (!response?.ok || result?.status !== "ok") { + showTableError(tbody, portal === "admin" ? 9 : 8); + showToast("流水加载失败", result?.message || "请稍后重试", "danger"); + return; + } + state.flowRows = result.items || []; + state.flowTotal = result.total; + renderFlowRows(); } function renderFlowRows() { const tbody = $("#flowTable tbody"); if (!tbody) return; - const data = currentFlowData(); - tbody.innerHTML = data.map((row, index) => { - const companyCell = portal === "admin" ? `${row.company}` : ""; - return ` - ${row.date} - ${companyCell} - ${row.acctLabel} - ${row.dir} - ${row.peer} - ${row.summary} - ${row.serial} - ${row.status} - ¥ ${row.amount} - `; - }).join(""); + const data = state.flowRows || []; + tbody.replaceChildren(); + data.forEach((row, index) => { + const tr = document.createElement("tr"); + tr.className = "clickable"; + tr.dataset.flowIdx = String(index); + tr.dataset.company = row.company || ""; + tr.dataset.bank = row.bank || ""; + tr.dataset.account = row.account || ""; + tr.dataset.date = row.date || ""; + tr.dataset.dir = row.direction || ""; + const date = document.createElement("td"); date.className = "num"; date.textContent = row.date || "—"; + tr.append(date); + if (portal === "admin") { + const company = document.createElement("td"); + company.className = "cell-main"; + company.textContent = row.company || "—"; + tr.append(company); + } + const acct = document.createElement("td"); acct.textContent = row.account_label || row.account || "—"; + const dir = document.createElement("td"); + const dirPill = document.createElement("span"); + dirPill.className = `pill ${row.direction === "收" ? "pill-success" : "pill-danger"}`; + dirPill.textContent = row.direction || "—"; + dir.append(dirPill); + const peer = document.createElement("td"); peer.className = "wrap"; peer.textContent = row.peer || "—"; + const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = row.summary || "—"; + const serial = document.createElement("td"); serial.className = "num"; serial.textContent = row.serial || "—"; + const status = document.createElement("td"); + const statusPill = document.createElement("span"); + const kind = row.status_kind === "success" ? "pill-success" : row.status_kind === "warn" ? "pill-warn" : row.status_kind === "danger" ? "pill-danger" : "pill-muted"; + statusPill.className = `pill ${kind}`; + statusPill.textContent = row.status || "未归集"; + status.append(statusPill); + const amt = document.createElement("td"); + amt.className = `num-col ${row.direction === "收" ? "amt-in" : "amt-out"}`; + amt.textContent = `¥ ${formatCurrency(row.amount)}`; + tr.append(acct, dir, peer, summary, serial, status, amt); + tbody.append(tr); + }); const count = $("#flowCount"); - if (count) count.textContent = `共 ${data.length} 笔`; + if (count) count.textContent = `共 ${resultTotalLabel(data.length, state.flowTotal)}`; const empty = $("#flowEmpty"); if (empty) empty.hidden = data.length > 0; const sum = $("#flowSum"); if (sum) { - let inflow = 0, outflow = 0; - data.forEach((row) => { if (row.dir === "收") inflow += Number(row.amount.replace(/,/g, "")); else outflow += Number(row.amount.replace(/,/g, "")); }); - sum.innerHTML = data.length ? `收 +¥ ${formatCurrency(inflow)} · 付 -¥ ${formatCurrency(outflow)}` : ""; + sum.replaceChildren(); + if (!data.length) return; + const inflow = document.createElement("span"); + inflow.className = "amt-in"; + inflow.textContent = `+¥ ${formatCurrency(data.filter((r) => r.direction === "收").reduce((n, r) => n + Number(r.amount || 0), 0))}`; + const outflow = document.createElement("span"); + outflow.className = "amt-out"; + outflow.textContent = `-¥ ${formatCurrency(data.filter((r) => r.direction !== "收").reduce((n, r) => n + Number(r.amount || 0), 0))}`; + sum.append(document.createTextNode("收 "), inflow, document.createTextNode(" · 付 "), outflow); } } +function resultTotalLabel(shown, total) { + if (total != null && total !== shown) return `${total} 笔 · 本页 ${shown}`; + return `${shown} 笔`; +} + function openFlowDetail(index) { const modal = $("#tx-modal"); if (!modal) return; - const row = currentFlowData()[index]; + const row = (state.flowRows || [])[index]; if (!row) return; - $("#tx-modal-sub").textContent = `${row.company} · ${row.acctLabel} · ${row.date}`; - const d = row.detail; - $("#d-serial").textContent = row.serial; - $("#d-bank").textContent = d.bank; - $("#d-account").textContent = d.account; - $("#d-time").textContent = d.time; - $("#d-peer").textContent = d.peer; - $("#d-peer-acct").textContent = d.peerAcct; - $("#d-amount").textContent = d.amount; - $("#d-status").textContent = d.status; - $("#d-pair").textContent = d.pair; - $("#d-subject").textContent = d.subject; - $("#d-batch").textContent = d.batch; - $("#d-note").textContent = d.note; + $("#tx-modal-sub").textContent = `${row.company || ""} · ${row.account_label || ""} · ${row.date || ""}`; + $("#d-serial").textContent = row.serial || "—"; + $("#d-bank").textContent = row.bank || "—"; + $("#d-account").textContent = row.own_name ? `${row.own_name} · ${row.account || ""}` : (row.account || "—"); + $("#d-time").textContent = row.time || row.date || "—"; + $("#d-peer").textContent = row.peer || "—"; + $("#d-peer-acct").textContent = row.peer_account || "—"; + $("#d-amount").textContent = `¥ ${formatCurrency(row.amount)}(${row.direction || ""})`; + $("#d-status").textContent = row.status || "—"; + $("#d-pair").textContent = "—"; + $("#d-subject").textContent = "—"; + $("#d-batch").textContent = row.batch || "—"; + $("#d-note").textContent = row.locator ? `源行 ${row.locator}` : "—"; modal.classList.add("open"); } -function visibleRows(table) { - return $$('tbody tr', table).filter((row) => !row.hidden); -} - -function filterFlows() { - const table = $("#flowTable"); - if (!table) return; - const company = $("#flowCompany")?.value || "全部公司"; - const bank = $("#flowBank")?.value || "全部银行"; - const account = $("#flowAccount")?.value || "全部账户"; - const startDate = $("#flowStart")?.value || "0000-01-01"; - const endDate = $("#flowEnd")?.value || "9999-12-31"; - const keyword = $("#flowKeyword")?.value.trim().toLowerCase() || ""; - if (startDate > endDate) { - showToast("日期范围无效", "开始日期不能晚于结束日期", "warn"); - return; - } - let count = 0; - $$("tbody tr", table).forEach((row) => { - const rowDate = row.dataset.date || $("td", row).textContent.trim().replaceAll(".", "-"); - const matchesCompany = company === "全部公司" || row.dataset.company === company; - const matchesBank = bank === "全部银行" || row.dataset.bank === bank; - const matchesAccount = account === "全部账户" || row.dataset.account === account || (row.dataset.account === undefined && row.textContent.includes(account)); - const matchesDate = rowDate >= startDate && rowDate <= endDate; - const matchesKeyword = !keyword || row.textContent.toLowerCase().includes(keyword); - row.hidden = !(matchesCompany && matchesBank && matchesAccount && matchesDate && matchesKeyword); - if (!row.hidden) count += 1; - }); - $("#flowCount").textContent = count === 0 ? "共 0 条 · 无匹配记录" : `共 ${count} 笔 · 本页 1-${count}`; - const empty = $("#flowEmpty"); - if (empty) empty.hidden = count > 0; - const sum = $("#flowSum"); - if (sum) { - let inflow = 0, outflow = 0; - $$("tbody tr", table).forEach((row) => { - if (row.hidden) return; - const amount = Number(($("td:last-child", row)?.textContent || "").replace(/[^\d.]/g, "")); - if (row.dataset.dir === "收") inflow += amount; else outflow += amount; - }); - sum.innerHTML = count ? `收 +¥ ${formatCurrency(inflow)} · 付 -¥ ${formatCurrency(outflow)}` : ""; - } - showToast("查询完成", `当前显示 ${count} 笔流水`); -} - function exportFlows() { - const table = $("#flowTable"); - const rows = visibleRows(table); - const headers = ["交易日期", "公司", "银行账户", "方向", "对方户名", "摘要", "银行流水号", "归集状态", "金额", "导入批次", "源行定位"]; - const records = rows.map((row, index) => { - const cells = $$('td', row).map((cell) => cell.innerText.replace(/\n/g, " ").trim()); - const imp = `IMP-DEMO-${String(index + 1).padStart(3, "0")}`; - const loc = `Sheet1!R${index + 8}`; - const companyName = state.me?.company_name || "本公司"; - if (portal === "admin") return [...cells.slice(0, 9), imp, loc]; - return [cells[0], companyName, cells[1], ...cells.slice(2), imp, loc]; - }); - const csv = [headers, ...records].map((record) => record.map((value) => `"${String(value ?? "").replace(/"/g, '""')}"`).join(",")).join("\r\n"); - const link = document.createElement("a"); - link.href = URL.createObjectURL(new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" })); - link.download = `${portal === "admin" ? "集团" : (state.me?.company_name || "本公司")}银行流水_202607.csv`; - link.click(); - URL.revokeObjectURL(link.href); - showToast("导出已生成", `共 ${rows.length} 笔,已保留银行标识与源行定位`, "success"); + const params = new URLSearchParams(); + const companyName = $("#flowCompany")?.value; + if (companyName && companyName !== "全部公司") { + const company = companyByName(companyName); + if (company) params.set("company_id", String(company.id)); + } + window.location.href = `/api/export.csv${params.toString() ? `?${params}` : ""}`; + showToast("开始导出", "仅含出纳已确认工作表的银行原始流水", "success"); } function initFlowTools() { - $("#applyFlowFilters")?.addEventListener("click", filterFlows); + $("#applyFlowFilters")?.addEventListener("click", loadFlows); $("#exportFlows")?.addEventListener("click", exportFlows); - renderFlowRows(); $("#resetFlowFilters")?.addEventListener("click", () => { const company = $("#flowCompany"); if (company) company.value = "全部公司"; - $("#flowBank").value = "全部银行"; - $("#flowAccount").value = "全部账户"; - $("#flowStart").value = "2026-07-01"; - $("#flowEnd").value = portal === "admin" ? "2026-07-31" : "2026-08-20"; - $("#flowKeyword").value = ""; - filterFlows(); + if ($("#flowBank")) $("#flowBank").value = "全部银行"; + if ($("#flowAccount")) $("#flowAccount").value = "全部账户"; + if ($("#flowStart")) $("#flowStart").value = ""; + if ($("#flowEnd")) $("#flowEnd").value = ""; + if ($("#flowKeyword")) $("#flowKeyword").value = ""; + loadFlows(); }); $("#flowTable tbody")?.addEventListener("click", (event) => { const row = event.target.closest("tr[data-flow-idx]"); @@ -2841,6 +3219,82 @@ function initFlowTools() { $("#tx-modal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); }); } +function manualApiStatus(stateName) { + if (stateName === "approved") return { cls: "pill-success", label: "已通过" }; + if (stateName === "returned" || stateName === "reversed") return { cls: "pill-danger", label: stateName === "returned" ? "已退回" : "已冲销" }; + if (stateName === "exception") return { cls: "pill-danger", label: "异常" }; + return { cls: "pill-info", label: "待审核" }; +} + +function renderCompanyManualRecords(records) { + const tbody = $("#manualRecordRows"); + if (!tbody) return; + tbody.replaceChildren(); + const rows = records || state.manualRecords || []; + rows.forEach((record) => { + const tr = document.createElement("tr"); + tr.dataset.recordId = String(record.id); + const date = document.createElement("td"); date.className = "num"; date.textContent = String(record.occurred_at || "").slice(0, 10); + const direction = document.createElement("td"); direction.textContent = record.direction === "incoming" ? "收款" : "付款"; + const counterparty = document.createElement("td"); + const name = document.createElement("span"); name.className = "cell-main"; name.textContent = record.counterparty_company_name || "—"; + counterparty.append(name); + const subject = document.createElement("td"); + const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = SUBJECT_CODE_LABEL[record.requested_subject] || record.requested_subject || "—"; + subject.append(tag); + const isIn = record.direction === "incoming"; + const amount = document.createElement("td"); + amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`; + amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`; + const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—"; + const statusMeta = manualApiStatus(record.state); + const statusCell = document.createElement("td"); + const pill = document.createElement("span"); pill.className = `pill ${statusMeta.cls}`; pill.textContent = statusMeta.label; + statusCell.append(pill); + const action = document.createElement("td"); + action.innerHTML = ''; + tr.append(date, direction, counterparty, subject, amount, summary, statusCell, action); + tbody.append(tr); + }); + const pending = rows.filter((record) => record.state === "pending").length; + const pendingEl = $("#manualPendingStatus"); + if (pendingEl) pendingEl.textContent = pending; + const foot = $("#manualFoot"); + if (foot) foot.textContent = `共 ${rows.length} 条 · 待复核 ${pending} 条`; + const empty = $("#manualEmpty"); + if (empty) empty.hidden = rows.length > 0; +} + +async function loadCompanyManualRecords() { + if (!$("#manualRecordRows")) return; + const response = await fetch("/api/company/manual-records").catch(() => null); + if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + showToast("手工记录加载失败", result?.message || "请稍后重试", "danger"); + return; + } + state.manualRecords = result.records || []; + renderCompanyManualRecords(state.manualRecords); +} + +async function loadCompanyPeerSelect() { + const select = $("#manualCounterparty"); + if (!select) return; + const response = await fetch("/api/company/companies").catch(() => null); + const result = await response?.json().catch(() => ({})); + const companies = (result.companies || []).filter((item) => String(item.id) !== String(state.me?.company_id)); + const first = select.options[0]; + select.replaceChildren(first); + companies.forEach((company) => { + const option = document.createElement("option"); + option.value = String(company.id); + option.textContent = company.name; + select.append(option); + }); +} + + function resetUpload() { state.selectedFile = null; state.parseResult = null; @@ -3708,12 +4162,6 @@ function renderWorkspaceTransfersCard(summary) { confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null, { signed: true }, ); - // formatWanHtml with negative value already adds −; ensure unit - if (confirmed.outflow_total != null) { - const wan = yuanToWan(confirmed.outflow_total); - const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - outflow.innerHTML = `−${abs}万元`; - } } if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`; if (net) { @@ -4205,7 +4653,8 @@ function initCompany() { showToast("已提交无业务说明", "等待管理员审核,通过后仅关闭断档提醒", "success"); }); - renderCompanyManualRecords(); + loadCompanyPeerSelect(); + loadCompanyManualRecords(); loadCompanyAccounts(); loadImportBatches(); @@ -4275,38 +4724,40 @@ function initCompany() { }); // ── 手工记录:提交 + 撤回 ── - $("#manualEntryForm")?.addEventListener("submit", (event) => { + $("#manualEntryForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const data = new FormData(form); - const evidence = data.get("evidence"); - if (evidence instanceof File && evidence.size > 20 * 1024 * 1024) { - showToast("证明附件超过限制", "请选择不超过 20 MB 的文件", "warn"); + const counterpartyId = Number(data.get("counterparty")); + if (!counterpartyId) { + showToast("请选择对方公司", "手工记录仅登记集团内公司往来", "warn"); + return; + } + const source = String(data.get("sourceAccount") || ""); + const funding = source.includes("个人过账") ? "personal_transit" : (source ? "approved_bank_account" : "other"); + const response = await fetch("/api/company/manual-records", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + counterparty_company_id: counterpartyId, + occurred_at: String(data.get("transactionDate") || ""), + direction: String(data.get("direction")) === "收款" ? "incoming" : "outgoing", + amount: String(data.get("amount") || ""), + currency: "CNY", + funding_source: funding === "approved_bank_account" ? "other" : funding, + requested_subject: SUBJECT_LABEL_CODE[String(data.get("subject") || "")] || "other_receivable", + request_key: `manual-${Date.now()}`, + summary: String(data.get("summary") || "").trim(), + reason: String(data.get("remark") || "").trim(), + }), + }).catch(() => null); + const result = await response?.json().catch(() => ({})); + if (!response?.ok) { + if (!toastIfLocked(result)) showToast("提交失败", result?.message || "请稍后重试", "danger"); return; } - const records = readStoredRecords(storageKeys.manual); - const record = { - id: `MR-${Date.now().toString().slice(-10)}`, - company: "A公司", - transactionDate: String(data.get("transactionDate")), - direction: String(data.get("direction")), - amount: Number(data.get("amount")), - sourceAccount: String(data.get("sourceAccount")), - counterpartyType: String(data.get("counterpartyType")), - counterparty: String(data.get("counterparty")).trim(), - counterpartyAccount: String(data.get("counterpartyAccount") || "").trim(), - subject: String(data.get("subject")), - summary: String(data.get("summary")).trim(), - remark: String(data.get("remark")).trim(), - bankReference: String(data.get("bankReference") || "").trim(), - evidenceName: evidence instanceof File ? evidence.name : "", - status: "待管理复核", - createdAt: new Date().toLocaleString("zh-CN", { hour12: false }), - }; - records.push(record); - if (!writeStoredRecords(storageKeys.manual, records)) return; - renderCompanyManualRecords(); form.reset(); + await loadCompanyManualRecords(); showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success"); }); @@ -4324,19 +4775,8 @@ function initCompany() { openModal("withdraw-modal"); }); $("#wd-confirm")?.addEventListener("click", () => { - if (manualWithdrawRow) { - const id = manualWithdrawRow.dataset.storedRecord; - if (id) { - const records = readStoredRecords(storageKeys.manual).filter((r) => r.id !== id); - writeStoredRecords(storageKeys.manual, records); - } else { - manualWithdrawRow.remove(); - } - manualWithdrawRow = null; - } closeModal("withdraw-modal"); - renderCompanyManualRecords(); - showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success"); + showToast("已提交记录不可在公司端撤回", "请联系管理员退回后重新登记", "warn"); }); // ── 转账往来(方案 A)── diff --git a/web/company.html b/web/company.html index 98c461b..8507c28 100644 --- a/web/company.html +++ b/web/company.html @@ -5,7 +5,7 @@ 公司业务端 · 金牛集团 - + @@ -301,7 +301,7 @@
-
+
@@ -1009,6 +1009,6 @@
- + diff --git a/web/design-system.css b/web/design-system.css index 8da73d4..be0bf75 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -1177,3 +1177,117 @@ body[data-portal="company"] .side-nav a[data-view="transfers"].active svg { border-top: 1px solid var(--border); } } + +/* ─── 月结 / 重开 / 审计(HEL-268,仅 5 条组件规则) ───────────── */ +.btn-warn { + background: var(--warn); + border-color: var(--warn); + color: var(--surface); +} +.btn-warn:hover { + background: color-mix(in oklch, var(--warn) 88%, black); + border-color: color-mix(in oklch, var(--warn) 88%, black); +} +.btn-warn[disabled] { + opacity: 1; + background: var(--fg-soft); + border-color: var(--border); + color: var(--muted); +} + +.pill-lock { + background: var(--fg-soft); + color: var(--muted); + height: 23px; +} +.pill-lock::before { + width: 11px; + height: 11px; + border-radius: 0; + background-color: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Crect x='5' y='11' width='14' height='10' rx='1.5'/%3E%3Cpath d='M8 11V8a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E") center / contain no-repeat; +} + +.tl-cell.locked { background: var(--fg-soft); } +.tl-cell.locked .tl-state { color: var(--muted); } +.tl-cell.locked .tl-state::before { + content: ""; + width: 11px; + height: 11px; + background-color: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Crect x='5' y='11' width='14' height='10' rx='1.5'/%3E%3Cpath d='M8 11V8a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E") center / contain no-repeat; +} +.tl-cell.reopened { background: var(--warn-soft); } +.tl-cell.reopened .tl-state { color: color-mix(in oklch, var(--warn) 80%, black); } +.tl-cell.failed { background: var(--danger-soft); } +.tl-cell.failed .tl-state { color: var(--danger); } + +.diff-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px 12px; + align-items: start; +} +.diff-grid .diff-row { + display: contents; +} +.diff-grid .diff-label { + grid-column: 1 / -1; + font-size: 12px; + color: var(--muted); +} +.diff-grid .diff-before { + color: var(--muted); + text-decoration: line-through; + font-family: var(--font-mono); + font-size: 13px; +} +.diff-grid .diff-arrow { + color: var(--muted); + align-self: center; +} +.diff-grid .diff-after { + color: color-mix(in oklch, var(--warn) 78%, black); + font-family: var(--font-mono); + font-size: 13px; +} +.diff-grid .diff-row.changed .diff-before, +.diff-grid .diff-row.changed .diff-after, +.diff-grid .diff-row.changed .diff-arrow { + box-shadow: inset 3px 0 0 var(--warn); + padding-left: 8px; +} +@media (max-width: 860px) { + .diff-grid { grid-template-columns: minmax(0, 1fr); } + .diff-grid .diff-arrow { transform: rotate(90deg); justify-self: center; } +} + +.empty-icon { + width: 48px; + height: 48px; + margin: 0 auto 12px; + color: var(--border); + display: block; +} + +.closing-panel.is-processing { + pointer-events: none; + opacity: 0.55; +} +.closing-kv { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--border); +} +.closing-kv .ck-label { font-size: 12px; color: var(--muted); } +.closing-kv .ck-value { font-family: var(--font-mono); font-size: 13px; margin-top: 4px; } +.lock-view-only { color: var(--muted); font-size: 12px; } +.audit-event-cards { display: none; } +@media (max-width: 460px) { + .closing-kv { grid-template-columns: 1fr 1fr; } + [data-page="period-audit"] .table-wrap { display: none; } + .audit-event-cards { display: grid; gap: 10px; } +}