Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
212b1f9b9b | ||
|
|
f17636183d | ||
|
|
951b353765 | ||
|
|
20d967a4a4 | ||
|
|
31fdc2e456 | ||
|
|
1d07812fa8 | ||
|
|
2fe06b74ae | ||
|
|
307f9ba76f | ||
|
|
020b556801 | ||
|
|
7938a274f2 | ||
|
|
dc0fb76f8f | ||
|
|
5a6a629597 | ||
|
|
5f215271af | ||
|
|
3b10a58d93 | ||
|
|
6d23711e6c | ||
|
|
a621ebc323 |
@@ -19,6 +19,3 @@ nul
|
||||
.agent_context/
|
||||
.kimi/
|
||||
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md`、`项目需求.md`、`最新进度.md` 和 `任务清单.md`。
|
||||
|
||||
# 项目交接说明
|
||||
|
||||
更新时间:2026-08-06
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 总账项目手册
|
||||
|
||||
最后核对:2026-08-23。核对依据是当前 `main` 最新代码(`5f21527`)、数据库迁移、接口、测试和近期提交记录。
|
||||
|
||||
这是接手本项目的第一站。阅读顺序如下:
|
||||
|
||||
1. [项目需求](项目需求.md):先知道系统要解决什么问题,以及哪些规则绝不能碰。
|
||||
2. [最新进度](最新进度.md):再确认哪些能力已经真实完成,哪些仍未完成。
|
||||
3. [任务清单](任务清单.md):最后挑选下一项工作,避免重复做已经完成的事。
|
||||
4. 需要技术细节时,再查看 `decisions/` 中的技术决定和 `issues/` 中保留的历史任务规格。
|
||||
|
||||
## 维护规矩
|
||||
|
||||
- 每新增、开始、完成、取消一项任务,负责的智能体都要在同一次提交里更新 `最新进度.md` 和 `任务清单.md`。
|
||||
- 新功能改变产品规则时,同时更新 `项目需求.md`;技术方案有变化时,补充 `decisions/` 的决定记录。
|
||||
- 写进手册的“已完成”必须能在代码、测试或已合并提交中找到依据;只有计划、页面演示或口头讨论的内容,一律写为“未完成”。
|
||||
- 不删除历史文档。旧内容与当前代码不一致时,在旧文件开头加“已过时,仅留档备查”提示,并在本目录写新版说明。
|
||||
|
||||
## 旧交接说明
|
||||
|
||||
`HANDOFF.md` 是 2026-08-06 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
|
||||
@@ -1,87 +0,0 @@
|
||||
# 006: Intercompany positions, subject review and drill-down evidence (B-44)
|
||||
|
||||
Status: accepted (2026-08-19)
|
||||
|
||||
## Decision
|
||||
|
||||
The intercompany ledger adds one canonical layer above B-43's eligible events:
|
||||
|
||||
- **Append-only ledger events.** A canonical `ledger_event` carries an
|
||||
append-only `ledger_event_revisions` chain. A B-43 eligible bank event first
|
||||
becomes a `pending_subject` revision; an administrator confirms the subject
|
||||
into a `confirmed` revision. Corrections are never in-place edits — a
|
||||
reversal or adjustment is a *new* ledger event with its own effective date,
|
||||
and the original event keeps its history so earlier cutoffs are not
|
||||
rewritten.
|
||||
- **Manual records as immutable submitted facts.** Companies submit
|
||||
`manual_records`; only an administrator-approved record becomes a confirmed
|
||||
ledger event (`approve_new`) or joins one (`approve_link`). Returned,
|
||||
exception and pending records never affect a balance and never leak to the
|
||||
counterparty. Approved facts change only through `reverse` (a new opposite
|
||||
event) — the original is never edited.
|
||||
- **One perspective, fixed mirror.** Subjects are stored from one participating
|
||||
company's perspective (`receivable/payable/other_receivable/other_payable`);
|
||||
the other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the
|
||||
two companies can never book conflicting subjects.
|
||||
- **Subjects are confirmed, never auto-posted.** Bank summary/purpose text only
|
||||
feeds a deterministic *suggestion* dictionary (`subject-suggest-draft-v1`,
|
||||
not group-approved). Without an approved trade dictionary every bank event
|
||||
stays in subject review until an administrator confirms.
|
||||
- **Decimal-only aggregation.** All money is stored as TEXT decimal strings and
|
||||
aggregated with Python `Decimal`. SQLite `SUM`, JavaScript `Number` and
|
||||
Python `float` never touch financial math. Different currencies are
|
||||
aggregated and displayed separately; nothing is converted to a group total.
|
||||
- **Conservation is asserted.** For every company pair and currency the two
|
||||
perspectives must mirror exactly (`C_A == -C_B`); a violation raises a
|
||||
calculation exception instead of rendering an unbalanced number.
|
||||
- **Unresolved is absolute gross.** Unresolved amounts are summed by absolute
|
||||
value per currency (never netted), broken down by reason
|
||||
(`subject_review`, `unmatched_single`, `manual_pending`), with count and
|
||||
gross amount exposed on every balance response.
|
||||
- **B-45 boundary.** Until the B-45 opening balance exists, every response
|
||||
returns `opening.status=unavailable`, `opening.amount=null` and
|
||||
`result.kind=period_net_change`; the UI labels this "期间净变动", never
|
||||
"期末余额".
|
||||
|
||||
## Background
|
||||
|
||||
B-43 produces `eligible_intercompany_events` as the only bank-event entry
|
||||
point. Before B-44 there was no canonical financial event, no statutory
|
||||
subject, no manual-record approval, and no server-side balance API. The
|
||||
revision-chain design is inherited from `transfer_match_decisions` in
|
||||
migration 5 and from the append-only audit posture of the rest of the system.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** balances are deterministic, conservable, auditable and
|
||||
drillable from a group directory down to bank source rows; manual records
|
||||
cannot double count; corrections never mutate evidence; company portals are
|
||||
tenant-scoped on the server.
|
||||
- **Negative:** pending bank events and returned/exception manual records are
|
||||
intentionally invisible to counterparties, which can surprise cashiers who
|
||||
expect symmetric disclosure; subject confirmation is manual until a
|
||||
group-approved dictionary exists.
|
||||
- **Operational:** migration 6 is forward-only for production once
|
||||
approvals/revisions exist; pre-production it can be rolled back with
|
||||
`--rollback-to 5`. Read aggregation runs against the rebuildable current
|
||||
projection (no day snapshots yet); if B-45 monthly close needs them, immutable
|
||||
monthly snapshots can be added behind the same API contract.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/bank_importer/ledger_events.py` — event lifecycle, revision chain,
|
||||
bank reconciliation, reversal/adjustment/reopen, projection rebuild.
|
||||
- `src/bank_importer/subjects.py` — subject constants/mirror, suggestion
|
||||
dictionary, `confirm_subject`.
|
||||
- `src/bank_importer/manual_records.py` — submission, approval
|
||||
(new/link), return/exception/reverse, candidate hints, idempotency.
|
||||
- `src/bank_importer/positions.py` — Decimal aggregation, directories, pairs,
|
||||
events, evidence visibility, unresolved buckets, keyset pagination.
|
||||
- `server.py` — `/api/admin/intercompany/*` and `/api/company/intercompany/*`
|
||||
plus `/api/admin/subject-reviews` and `/api/admin/manual-records`.
|
||||
- `db.py` migration 6 — `manual_records`, `manual_record_decisions`,
|
||||
`ledger_events`, `ledger_event_revisions`, current-pointer projections,
|
||||
source claim tables, `ledger_subject_suggestions`, `eligible_position_events`.
|
||||
- Tests: `test_ledger_events.py`, `test_manual_records.py`,
|
||||
`test_positions.py`, `test_positions_api.py`, extended
|
||||
`test_persistence.py`.
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 仓库银行样本数据分级与脱敏治理
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 持久化层与不可变银行导入基础
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 正式认证、RBAC 与公司级数据隔离
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 动态公司、用户、账户和别名主数据
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 银行导入 API 加固与回归测试
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 规范转账事件、双边匹配与调拨排除
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 任务清单
|
||||
|
||||
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
|
||||
|
||||
## 正在做
|
||||
|
||||
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
|
||||
|
||||
## 已做完
|
||||
|
||||
- 六类银行流水模板识别与解析。
|
||||
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
|
||||
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
|
||||
- 动态公司、用户、银行账户、别名与账户审核流程。
|
||||
- 导入接口加固、逐工作表确认、失败诊断和导出。
|
||||
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
|
||||
- 登录页视觉融合改版。
|
||||
|
||||
## 还没安排
|
||||
|
||||
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
|
||||
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
|
||||
- 月结、重开、调整/冲销审批与审计报告。
|
||||
- 服务端流水查询、筛选、分页和可追溯导出。
|
||||
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
|
||||
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
|
||||
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
|
||||
|
||||
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 最新进度
|
||||
|
||||
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
|
||||
|
||||
## 已经真实完成
|
||||
|
||||
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
|
||||
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
|
||||
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
|
||||
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
|
||||
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
|
||||
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
|
||||
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
|
||||
|
||||
## 仍未完成或不能当成已完成
|
||||
|
||||
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
|
||||
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
|
||||
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
|
||||
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
|
||||
|
||||
## 最近验证
|
||||
|
||||
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 项目需求
|
||||
|
||||
## 这个系统做什么
|
||||
|
||||
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
|
||||
|
||||
系统有两个独立入口:
|
||||
|
||||
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
|
||||
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
|
||||
|
||||
## 必须做到的业务规则
|
||||
|
||||
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
|
||||
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
|
||||
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
|
||||
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
|
||||
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
|
||||
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
|
||||
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
|
||||
|
||||
## 当前技术边界
|
||||
|
||||
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls` 与 `.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
|
||||
|
||||
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
|
||||
@@ -1,343 +0,0 @@
|
||||
"""Admin dashboard aggregates over existing B-43 eligible events and review queues.
|
||||
|
||||
No fabricated demo amounts. Opening balances are unavailable until B-45; peer
|
||||
group ``opening`` is always null and ``ending`` equals period net change.
|
||||
Period closing status is unknown without the monthly-close module — returned
|
||||
as null so the UI shows an em dash rather than a guessed label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import sqlite3
|
||||
|
||||
|
||||
WAN = Decimal("10000")
|
||||
ZERO = Decimal("0")
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def today_shanghai() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
|
||||
|
||||
def _q2(value: Decimal) -> str:
|
||||
return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _to_wan(amount: Decimal) -> str:
|
||||
return _q2(amount / WAN)
|
||||
|
||||
|
||||
def _parse_day(iso_ts: str | None) -> str | None:
|
||||
if not iso_ts:
|
||||
return None
|
||||
text = str(iso_ts)
|
||||
return text[:10] if len(text) >= 10 else None
|
||||
|
||||
|
||||
def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
"""Derive pending review counts from real queues only.
|
||||
|
||||
Priority mapping (aligned with current admin audit UI semantics):
|
||||
- high: unresolved / needs_review match exceptions
|
||||
- medium: pending bank-account registrations
|
||||
- low: reserved for future low-risk queues (currently always 0)
|
||||
"""
|
||||
high = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN canonical_transfer_events e ON e.id = c.event_id
|
||||
WHERE e.lifecycle = 'active'
|
||||
AND d.classification IN ('unresolved', 'needs_review')
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'"
|
||||
).fetchone()["n"]
|
||||
low = 0
|
||||
return {
|
||||
"total": int(high) + int(medium) + int(low),
|
||||
"high": int(high),
|
||||
"medium": int(medium),
|
||||
"low": int(low),
|
||||
}
|
||||
|
||||
|
||||
def _load_eligible(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency,
|
||||
e.payer_company_id, e.payee_company_id, e.pairing,
|
||||
cpayer.name AS payer_name, cpayee.name AS payee_name
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN companies cpayer ON cpayer.id = e.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = e.payee_company_id
|
||||
WHERE date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
ORDER BY e.effective_at, e.event_id
|
||||
""",
|
||||
(from_date, cutoff),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, name FROM companies
|
||||
WHERE status != 'disabled'
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def company_summaries(
|
||||
connection: sqlite3.Connection, *, from_date: str, cutoff: str
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
companies = _company_rows(connection)
|
||||
events = _load_eligible(connection, from_date=from_date, cutoff=cutoff)
|
||||
|
||||
debit_total = ZERO
|
||||
credit_total = ZERO
|
||||
by_id: dict[int, dict[str, object]] = {}
|
||||
for company in companies:
|
||||
by_id[int(company["id"])] = {
|
||||
"id": int(company["id"]),
|
||||
"name": company["name"],
|
||||
"detail_count": 0,
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"period_status": None,
|
||||
"period_status_label": "—",
|
||||
}
|
||||
|
||||
for event in events:
|
||||
amount = Decimal(str(event["amount"]))
|
||||
debit_total += amount
|
||||
credit_total += amount
|
||||
payer_id = int(event["payer_company_id"])
|
||||
payee_id = int(event["payee_company_id"])
|
||||
if payer_id in by_id:
|
||||
row = by_id[payer_id]
|
||||
row["detail_count"] = int(row["detail_count"]) + 1
|
||||
row["debit"] = Decimal(row["debit"]) + amount
|
||||
if payee_id in by_id:
|
||||
row = by_id[payee_id]
|
||||
row["detail_count"] = int(row["detail_count"]) + 1
|
||||
row["credit"] = Decimal(row["credit"]) + amount
|
||||
|
||||
items: list[dict[str, object]] = []
|
||||
for company in companies:
|
||||
row = by_id[int(company["id"])]
|
||||
debit = Decimal(row["debit"])
|
||||
credit = Decimal(row["credit"])
|
||||
# Payee inflow − payer outflow = signed net from this company's view.
|
||||
net = credit - debit
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"detail_count": row["detail_count"],
|
||||
"debit_wan": _to_wan(debit),
|
||||
"credit_wan": _to_wan(credit),
|
||||
"net_wan": _to_wan(net),
|
||||
"period_status": row["period_status"],
|
||||
"period_status_label": row["period_status_label"],
|
||||
}
|
||||
)
|
||||
|
||||
totals = {
|
||||
"company_count": len(items),
|
||||
"detail_count": len(events),
|
||||
"debit_wan": _to_wan(debit_total),
|
||||
"credit_wan": _to_wan(credit_total),
|
||||
"net_wan": _to_wan(debit_total - credit_total),
|
||||
}
|
||||
return items, totals
|
||||
|
||||
|
||||
def company_peer_groups(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
*,
|
||||
from_date: str,
|
||||
cutoff: str,
|
||||
) -> dict[str, object]:
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise KeyError(company_id)
|
||||
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.effective_at, e.amount, e.currency,
|
||||
e.payer_company_id, e.payee_company_id,
|
||||
cpayer.name AS payer_name, cpayee.name AS payee_name
|
||||
FROM eligible_intercompany_events e
|
||||
JOIN companies cpayer ON cpayer.id = e.payer_company_id
|
||||
JOIN companies cpayee ON cpayee.id = e.payee_company_id
|
||||
WHERE (e.payer_company_id = ? OR e.payee_company_id = ?)
|
||||
AND date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
ORDER BY e.effective_at, e.event_id
|
||||
""",
|
||||
(company_id, company_id, from_date, cutoff),
|
||||
).fetchall()
|
||||
|
||||
groups: dict[int, dict[str, object]] = {}
|
||||
for event in events:
|
||||
amount = Decimal(str(event["amount"]))
|
||||
payer_id = int(event["payer_company_id"])
|
||||
payee_id = int(event["payee_company_id"])
|
||||
if payer_id == company_id:
|
||||
peer_id = payee_id
|
||||
peer_name = event["payee_name"]
|
||||
direction = "debit"
|
||||
summary = f"付往 {peer_name}"
|
||||
else:
|
||||
peer_id = payer_id
|
||||
peer_name = event["payer_name"]
|
||||
direction = "credit"
|
||||
summary = f"收自 {peer_name}"
|
||||
|
||||
bucket = groups.get(peer_id)
|
||||
if bucket is None:
|
||||
bucket = {
|
||||
"peer_id": peer_id,
|
||||
"peer_name": peer_name,
|
||||
"count": 0,
|
||||
"opening": None,
|
||||
"opening_status": "unavailable",
|
||||
"debit": ZERO,
|
||||
"credit": ZERO,
|
||||
"lines": [],
|
||||
}
|
||||
groups[peer_id] = bucket
|
||||
|
||||
bucket["count"] = int(bucket["count"]) + 1
|
||||
if direction == "debit":
|
||||
bucket["debit"] = Decimal(bucket["debit"]) + amount
|
||||
else:
|
||||
bucket["credit"] = Decimal(bucket["credit"]) + amount
|
||||
|
||||
day = _parse_day(event["effective_at"]) or ""
|
||||
bucket["lines"].append(
|
||||
{
|
||||
"event_id": int(event["event_id"]),
|
||||
"date": day,
|
||||
"direction": direction,
|
||||
"summary": summary,
|
||||
"amount_wan": _to_wan(amount),
|
||||
"currency": event["currency"] or "CNY",
|
||||
}
|
||||
)
|
||||
|
||||
result_groups: list[dict[str, object]] = []
|
||||
for peer_id in sorted(groups.keys(), key=lambda i: groups[i]["peer_name"]):
|
||||
bucket = groups[peer_id]
|
||||
debit = Decimal(bucket["debit"])
|
||||
credit = Decimal(bucket["credit"])
|
||||
ending = credit - debit
|
||||
result_groups.append(
|
||||
{
|
||||
"peer_id": bucket["peer_id"],
|
||||
"peer_name": bucket["peer_name"],
|
||||
"count": bucket["count"],
|
||||
"opening": None,
|
||||
"opening_status": "unavailable",
|
||||
"debit_wan": _to_wan(debit),
|
||||
"credit_wan": _to_wan(credit),
|
||||
"ending_wan": _to_wan(ending),
|
||||
"result_kind": "period_net_change",
|
||||
"lines": bucket["lines"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"company_id": int(company["id"]),
|
||||
"company_name": company["name"],
|
||||
"from_date": from_date,
|
||||
"cutoff": cutoff,
|
||||
"groups": result_groups,
|
||||
}
|
||||
|
||||
|
||||
def weekly_flow(
|
||||
connection: sqlite3.Connection, *, cutoff: str, days: int = 7
|
||||
) -> dict[str, object]:
|
||||
end = datetime.strptime(cutoff, "%Y-%m-%d").date()
|
||||
start = end - timedelta(days=days - 1)
|
||||
labels: list[str] = []
|
||||
inflow = [ZERO] * days
|
||||
outflow = [ZERO] * days
|
||||
index: dict[str, int] = {}
|
||||
for offset in range(days):
|
||||
day = start + timedelta(days=offset)
|
||||
key = day.isoformat()
|
||||
index[key] = offset
|
||||
labels.append(f"{day.month:02d}-{day.day:02d}")
|
||||
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT date(e.effective_at) AS day, e.amount
|
||||
FROM eligible_intercompany_events e
|
||||
WHERE date(e.effective_at) >= date(?)
|
||||
AND date(e.effective_at) <= date(?)
|
||||
""",
|
||||
(start.isoformat(), cutoff),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
day = row["day"]
|
||||
if day not in index:
|
||||
continue
|
||||
amount = Decimal(str(row["amount"]))
|
||||
# Group-level flow: every eligible transfer is both an outflow (payer)
|
||||
# and an inflow (payee); plot both series with the same absolute amount.
|
||||
inflow[index[day]] += amount
|
||||
outflow[index[day]] += amount
|
||||
|
||||
return {
|
||||
"labels": labels,
|
||||
"inflow_wan": [_to_wan(v) for v in inflow],
|
||||
"outflow_wan": [_to_wan(v) for v in outflow],
|
||||
}
|
||||
|
||||
|
||||
def build_dashboard(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
from_date: str = "2026-01-01",
|
||||
cutoff: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
cutoff_date = cutoff or today_shanghai()
|
||||
try:
|
||||
datetime.strptime(from_date, "%Y-%m-%d")
|
||||
datetime.strptime(cutoff_date, "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise ValueError("日期必须是 YYYY-MM-DD") from exc
|
||||
if from_date > cutoff_date:
|
||||
raise ValueError("from 不能晚于 cutoff")
|
||||
|
||||
period = datetime.strptime(cutoff_date, "%Y-%m-%d")
|
||||
# Display month for the status column header: use the calendar month of cutoff.
|
||||
period_month = period.month
|
||||
companies, totals = company_summaries(
|
||||
connection, from_date=from_date, cutoff=cutoff_date
|
||||
)
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"cutoff": cutoff_date,
|
||||
"period_month": period_month,
|
||||
"period_label": f"{period.year}-{period.month:02d}",
|
||||
"audit": audit_counts(connection),
|
||||
"totals": totals,
|
||||
"companies": companies,
|
||||
"weekly_flow": weekly_flow(connection, cutoff=cutoff_date),
|
||||
"opening_status": "unavailable",
|
||||
}
|
||||
+49
-254
@@ -571,279 +571,74 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
),
|
||||
Migration(
|
||||
version=6,
|
||||
name="0006_intercompany_ledger_events",
|
||||
# B-44 canonical intercompany ledger layer. Manual records and the
|
||||
# ledger event revision chain are append-only facts; current pointers
|
||||
# (current revision per ledger event / manual decision, source claims)
|
||||
# are rebuildable projections. Bank events enter only through
|
||||
# ``eligible_intercompany_events``; nothing here rewrites bank rows.
|
||||
name="0006_reminders",
|
||||
up="""
|
||||
CREATE TABLE manual_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
counterparty_company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
occurred_at TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('outgoing', 'incoming')),
|
||||
amount TEXT NOT NULL,
|
||||
amount_scale INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
funding_source TEXT NOT NULL CHECK (funding_source IN (
|
||||
'approved_bank_account', 'personal_transit', 'other'
|
||||
)),
|
||||
bank_account_id INTEGER REFERENCES bank_accounts (id),
|
||||
personal_transit_mapping_id INTEGER REFERENCES personal_transit_mappings (id),
|
||||
related_source_row_id INTEGER REFERENCES source_rows (id),
|
||||
requested_subject TEXT NOT NULL CHECK (requested_subject IN (
|
||||
'receivable', 'payable', 'other_receivable', 'other_payable'
|
||||
)),
|
||||
summary TEXT,
|
||||
reason TEXT,
|
||||
evidence_json TEXT,
|
||||
request_key TEXT NOT NULL,
|
||||
supersedes_record_id INTEGER REFERENCES manual_records (id),
|
||||
submitted_by INTEGER REFERENCES users (id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (company_id, request_key),
|
||||
CHECK (counterparty_company_id != company_id)
|
||||
);
|
||||
|
||||
CREATE TABLE manual_record_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
record_id INTEGER NOT NULL REFERENCES manual_records (id),
|
||||
revision INTEGER NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN (
|
||||
'pending', 'approved', 'returned', 'exception', 'reversed'
|
||||
)),
|
||||
action TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
idempotency_key TEXT,
|
||||
supersedes_decision_id INTEGER REFERENCES manual_record_decisions (id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (record_id, revision)
|
||||
);
|
||||
|
||||
CREATE TABLE current_manual_record_decisions (
|
||||
record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
|
||||
decision_id INTEGER NOT NULL UNIQUE REFERENCES manual_record_decisions (id)
|
||||
);
|
||||
|
||||
CREATE TABLE ledger_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lifecycle TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (lifecycle IN ('active', 'superseded')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ledger_event_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
|
||||
revision INTEGER NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending_subject', 'confirmed')),
|
||||
effective_at TEXT NOT NULL,
|
||||
amount TEXT NOT NULL,
|
||||
amount_scale INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
payer_company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
payee_company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
perspective_company_id INTEGER REFERENCES companies (id),
|
||||
subject_code TEXT CHECK (subject_code IN (
|
||||
'receivable', 'payable', 'other_receivable', 'other_payable'
|
||||
)),
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('bank', 'manual', 'adjustment')),
|
||||
source_revision_token TEXT,
|
||||
posting_kind TEXT NOT NULL CHECK (posting_kind IN (
|
||||
'normal', 'reversal', 'adjustment'
|
||||
)),
|
||||
reverses_ledger_event_id INTEGER REFERENCES ledger_events (id),
|
||||
adjusts_ledger_event_id INTEGER REFERENCES ledger_events (id),
|
||||
rule_version TEXT,
|
||||
evidence_json TEXT,
|
||||
idempotency_key TEXT,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
reason TEXT,
|
||||
supersedes_revision_id INTEGER REFERENCES ledger_event_revisions (id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (ledger_event_id, revision),
|
||||
CHECK (payer_company_id != payee_company_id),
|
||||
CHECK (state = 'confirmed' OR subject_code IS NULL),
|
||||
CHECK (state = 'confirmed' OR perspective_company_id IS NULL),
|
||||
CHECK (
|
||||
state != 'confirmed'
|
||||
OR (perspective_company_id IS NOT NULL AND subject_code IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE current_ledger_event_revisions (
|
||||
ledger_event_id INTEGER PRIMARY KEY REFERENCES ledger_events (id),
|
||||
revision_id INTEGER NOT NULL UNIQUE REFERENCES ledger_event_revisions (id)
|
||||
);
|
||||
|
||||
CREATE TABLE ledger_event_bank_sources (
|
||||
bank_event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
|
||||
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
|
||||
UNIQUE (ledger_event_id, bank_event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE ledger_event_manual_sources (
|
||||
manual_record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
|
||||
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
|
||||
UNIQUE (ledger_event_id, manual_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE ledger_subject_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
|
||||
source_revision_id INTEGER NOT NULL REFERENCES ledger_event_revisions (id),
|
||||
suggested_perspective_company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
suggested_subject_code TEXT NOT NULL CHECK (suggested_subject_code IN (
|
||||
'receivable', 'payable', 'other_receivable', 'other_payable'
|
||||
)),
|
||||
rule_version TEXT,
|
||||
evidence_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ledger_revisions_event ON ledger_event_revisions (ledger_event_id, revision);
|
||||
CREATE INDEX idx_ledger_revisions_effective ON ledger_event_revisions (effective_at);
|
||||
CREATE INDEX idx_ledger_revisions_pair_currency
|
||||
ON ledger_event_revisions (payer_company_id, payee_company_id, currency);
|
||||
CREATE INDEX idx_ledger_revisions_state
|
||||
ON ledger_event_revisions (state, effective_at);
|
||||
CREATE INDEX idx_manual_records_company ON manual_records (company_id, occurred_at);
|
||||
CREATE INDEX idx_manual_decisions_record ON manual_record_decisions (record_id, revision);
|
||||
CREATE INDEX idx_manual_decisions_state ON manual_record_decisions (state);
|
||||
CREATE INDEX idx_subject_suggestions_event ON ledger_subject_suggestions (ledger_event_id);
|
||||
|
||||
CREATE VIEW eligible_position_events AS
|
||||
SELECT le.id AS ledger_event_id,
|
||||
cur.revision_id AS ledger_revision_id,
|
||||
r.effective_at AS effective_at,
|
||||
r.amount AS amount, r.amount_scale AS amount_scale,
|
||||
r.currency AS currency,
|
||||
r.payer_company_id AS payer_company_id,
|
||||
r.payee_company_id AS payee_company_id,
|
||||
r.perspective_company_id AS perspective_company_id,
|
||||
r.subject_code AS subject_code,
|
||||
r.source_kind AS source_kind, r.posting_kind AS posting_kind,
|
||||
r.reverses_ledger_event_id AS reverses_ledger_event_id,
|
||||
r.adjusts_ledger_event_id AS adjusts_ledger_event_id,
|
||||
COALESCE(
|
||||
(SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
|
||||
WHERE bs.ledger_event_id = le.id LIMIT 1),
|
||||
(SELECT ms.manual_record_id FROM ledger_event_manual_sources ms
|
||||
WHERE ms.ledger_event_id = le.id LIMIT 1)
|
||||
) AS source_id,
|
||||
((SELECT COUNT(*) FROM ledger_event_bank_sources bs
|
||||
WHERE bs.ledger_event_id = le.id)
|
||||
+ (SELECT COUNT(*) FROM ledger_event_manual_sources ms
|
||||
WHERE ms.ledger_event_id = le.id)) AS evidence_count
|
||||
FROM ledger_events le
|
||||
JOIN current_ledger_event_revisions cur ON cur.ledger_event_id = le.id
|
||||
JOIN ledger_event_revisions r ON r.id = cur.revision_id
|
||||
WHERE le.lifecycle = 'active' AND r.state = 'confirmed';
|
||||
|
||||
CREATE TRIGGER ledger_events_no_delete BEFORE DELETE ON ledger_events
|
||||
BEGIN SELECT RAISE (ABORT, 'ledger_events rows are immutable'); END;
|
||||
CREATE TRIGGER ledger_events_no_update BEFORE UPDATE ON ledger_events
|
||||
BEGIN
|
||||
SELECT RAISE (ABORT, 'ledger_events only allow lifecycle changes')
|
||||
WHERE OLD.lifecycle = NEW.lifecycle
|
||||
OR OLD.id IS NOT NEW.id
|
||||
OR OLD.created_at IS NOT NEW.created_at;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER ledger_event_revisions_no_update BEFORE UPDATE ON ledger_event_revisions
|
||||
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
|
||||
CREATE TRIGGER ledger_event_revisions_no_delete BEFORE DELETE ON ledger_event_revisions
|
||||
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
|
||||
|
||||
CREATE TRIGGER ledger_subject_suggestions_no_update BEFORE UPDATE ON ledger_subject_suggestions
|
||||
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
|
||||
CREATE TRIGGER ledger_subject_suggestions_no_delete BEFORE DELETE ON ledger_subject_suggestions
|
||||
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
|
||||
|
||||
CREATE TRIGGER manual_records_no_update BEFORE UPDATE ON manual_records
|
||||
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
|
||||
CREATE TRIGGER manual_records_no_delete BEFORE DELETE ON manual_records
|
||||
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
|
||||
|
||||
CREATE TRIGGER manual_record_decisions_no_update BEFORE UPDATE ON manual_record_decisions
|
||||
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
|
||||
CREATE TRIGGER manual_record_decisions_no_delete BEFORE DELETE ON manual_record_decisions
|
||||
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
|
||||
""",
|
||||
down="""
|
||||
DROP VIEW IF EXISTS eligible_position_events;
|
||||
DROP TRIGGER IF EXISTS manual_record_decisions_no_delete;
|
||||
DROP TRIGGER IF EXISTS manual_record_decisions_no_update;
|
||||
DROP TRIGGER IF EXISTS manual_records_no_delete;
|
||||
DROP TRIGGER IF EXISTS manual_records_no_update;
|
||||
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_delete;
|
||||
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_update;
|
||||
DROP TRIGGER IF EXISTS ledger_event_revisions_no_delete;
|
||||
DROP TRIGGER IF EXISTS ledger_event_revisions_no_update;
|
||||
DROP TRIGGER IF EXISTS ledger_events_no_update;
|
||||
DROP TRIGGER IF EXISTS ledger_events_no_delete;
|
||||
DROP TABLE IF EXISTS ledger_subject_suggestions;
|
||||
DROP TABLE IF EXISTS ledger_event_manual_sources;
|
||||
DROP TABLE IF EXISTS ledger_event_bank_sources;
|
||||
DROP TABLE IF EXISTS current_ledger_event_revisions;
|
||||
DROP TABLE IF EXISTS ledger_event_revisions;
|
||||
DROP TABLE IF EXISTS ledger_events;
|
||||
DROP TABLE IF EXISTS current_manual_record_decisions;
|
||||
DROP TABLE IF EXISTS manual_record_decisions;
|
||||
DROP TABLE IF EXISTS manual_records;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=7,
|
||||
name="0007_system_settings_and_reminders",
|
||||
# System settings (closing day, global start date, auto-reminder) are
|
||||
# persisted as a key/value table with an append-only change history
|
||||
# (operator + before/after) for the audit requirement. Reminders are a
|
||||
# separate append-only table so reminder history survives re-sends.
|
||||
up="""
|
||||
CREATE TABLE system_settings (
|
||||
CREATE TABLE reminder_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users (id),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE system_setting_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
before_value TEXT,
|
||||
after_value TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO reminder_settings (key, value, updated_at) VALUES
|
||||
('monthly_start_day', '5', datetime('now')),
|
||||
('gap_days', '5', datetime('now')),
|
||||
('scan_time', '08:00', datetime('now'));
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
kind TEXT NOT NULL,
|
||||
rule_key TEXT NOT NULL CHECK (rule_key IN (
|
||||
'unsubmitted', 'gap', 'pending_review', 'manual'
|
||||
)),
|
||||
dedupe_key TEXT NOT NULL UNIQUE,
|
||||
rule_params TEXT NOT NULL DEFAULT '{}',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'unread'
|
||||
CHECK (status IN ('unread', 'done')),
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
source TEXT NOT NULL CHECK (source IN ('auto', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
send_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_sent_at TEXT,
|
||||
last_sent_at TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
action_link TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
CREATE INDEX idx_reminders_status ON reminders (status);
|
||||
|
||||
CREATE TABLE reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reminder_id INTEGER NOT NULL REFERENCES reminders (id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN (
|
||||
'sent', 'acknowledged', 'resolved', 'escalated'
|
||||
)),
|
||||
actor TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminder_events_reminder ON reminder_events (reminder_id);
|
||||
|
||||
CREATE TRIGGER reminder_events_no_update BEFORE UPDATE ON reminder_events
|
||||
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
|
||||
CREATE TRIGGER reminder_events_no_delete BEFORE DELETE ON reminder_events
|
||||
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
|
||||
CREATE TRIGGER reminders_no_delete BEFORE DELETE ON reminders
|
||||
BEGIN SELECT RAISE (ABORT, 'reminders rows are immutable history'); END;
|
||||
""",
|
||||
down="""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_update;
|
||||
DROP INDEX IF EXISTS idx_reminder_events_reminder;
|
||||
DROP TABLE IF EXISTS reminder_events;
|
||||
DROP INDEX IF EXISTS idx_reminders_status;
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS system_setting_changes;
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
DROP TABLE IF EXISTS reminder_settings;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ import sqlite3
|
||||
import tempfile
|
||||
|
||||
from . import auth
|
||||
from . import ledger_events
|
||||
from . import matching
|
||||
from .db import utc_now
|
||||
from .models import SheetResult, StatementBatch
|
||||
@@ -649,7 +648,6 @@ def review_sheets(
|
||||
[item["id"] for item in confirmed_rows],
|
||||
actor=actor,
|
||||
)
|
||||
ledger_events.reconcile_bank_events(connection, actor=actor)
|
||||
if began:
|
||||
connection.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
"""Canonical intercompany ledger events and the revision chain (B-44).
|
||||
|
||||
Bank source rows and approved manual records are immutable evidence. This
|
||||
module turns them into one canonical ledger event each through ``reconcile_*``
|
||||
functions and an append-only revision chain. Corrections are never in-place
|
||||
edits: a reversal or adjustment is a new ledger event with its own effective
|
||||
date, and the original event keeps its history so no earlier cutoff is
|
||||
rewritten. Current revisions and source claims are rebuildable projections.
|
||||
|
||||
Only B-43 ``eligible_intercompany_events`` feeds bank facts here; same-company
|
||||
transfers, external transactions, unresolved rows and unlocked single
|
||||
observations never reach the confirmed balance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .subjects import MIRROR, SUBJECTS, mirror_subject
|
||||
|
||||
|
||||
class LedgerConflictError(ValueError):
|
||||
"""A revision/claim/idempotency conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class LedgerInputError(ValueError):
|
||||
"""Invalid input for a ledger operation (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
|
||||
|
||||
|
||||
def amount_scale(amount: object) -> int:
|
||||
"""Decimal places of a decimal-string amount, never negative."""
|
||||
try:
|
||||
exponent = Decimal(str(amount)).as_tuple().exponent
|
||||
except InvalidOperation:
|
||||
return 0
|
||||
return max(0, -int(exponent))
|
||||
|
||||
|
||||
def parse_amount(amount: object) -> Decimal:
|
||||
"""Parse a positive, valid decimal-string amount."""
|
||||
try:
|
||||
value = Decimal(str(amount))
|
||||
except InvalidOperation:
|
||||
raise LedgerInputError("金额不是有效的十进制数。") from None
|
||||
if not value.is_finite() or value <= 0:
|
||||
raise LedgerInputError("金额必须大于零。")
|
||||
return value
|
||||
|
||||
|
||||
def _company_exists(connection: sqlite3.Connection, company_id: int, label: str) -> None:
|
||||
row = connection.execute(
|
||||
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LedgerInputError(f"{label}指向的公司不存在。")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revision helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_transaction(connection: sqlite3.Connection) -> bool:
|
||||
"""Begin an immediate transaction unless one is already open.
|
||||
|
||||
Write helpers may run standalone (they own the transaction) or nested
|
||||
inside a caller's transaction (e.g. the sheet-confirm flow); nested calls
|
||||
never start their own commit.
|
||||
"""
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
return began
|
||||
|
||||
|
||||
def current_revision(connection: sqlite3.Connection, ledger_event_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT r.* FROM current_ledger_event_revisions c
|
||||
JOIN ledger_event_revisions r ON r.id = c.revision_id
|
||||
WHERE c.ledger_event_id = ?
|
||||
""",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _event_lifecycle(connection: sqlite3.Connection, ledger_event_id: int) -> str | None:
|
||||
row = connection.execute(
|
||||
"SELECT lifecycle FROM ledger_events WHERE id = ?", (ledger_event_id,)
|
||||
).fetchone()
|
||||
return row["lifecycle"] if row is not None else None
|
||||
|
||||
|
||||
def _next_revision_number(connection: sqlite3.Connection, ledger_event_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(revision), 0) AS m FROM ledger_event_revisions WHERE ledger_event_id = ?",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
return int(row["m"]) + 1
|
||||
|
||||
|
||||
def create_event(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
state: str,
|
||||
effective_at: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
payer_company_id: int,
|
||||
payee_company_id: int,
|
||||
perspective_company_id: int | None,
|
||||
subject_code: str | None,
|
||||
source_kind: str,
|
||||
source_revision_token: str | None,
|
||||
posting_kind: str,
|
||||
reverses_ledger_event_id: int | None = None,
|
||||
adjusts_ledger_event_id: int | None = None,
|
||||
rule_version: str | None = None,
|
||||
evidence_json: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
reason: str | None = None,
|
||||
supersedes_revision_id: int | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Insert a new ledger event with one revision. Returns ``(event_id, revision_id)``."""
|
||||
if state == "confirmed":
|
||||
if perspective_company_id is None or subject_code is None:
|
||||
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
|
||||
if subject_code not in SUBJECTS:
|
||||
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if int(payer_company_id) == int(payee_company_id):
|
||||
raise LedgerInputError("付款公司与收款公司不能相同。")
|
||||
if perspective_company_id is not None and perspective_company_id not in (
|
||||
int(payer_company_id), int(payee_company_id),
|
||||
):
|
||||
raise LedgerInputError("视角公司必须是事件参与方。")
|
||||
began = _ensure_transaction(connection)
|
||||
try:
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
)
|
||||
event_id = int(cursor.lastrowid)
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
event_id,
|
||||
state=state,
|
||||
effective_at=effective_at,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
payer_company_id=payer_company_id,
|
||||
payee_company_id=payee_company_id,
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=subject_code,
|
||||
source_kind=source_kind,
|
||||
source_revision_token=source_revision_token,
|
||||
posting_kind=posting_kind,
|
||||
reverses_ledger_event_id=reverses_ledger_event_id,
|
||||
adjusts_ledger_event_id=adjusts_ledger_event_id,
|
||||
rule_version=rule_version,
|
||||
evidence_json=evidence_json,
|
||||
idempotency_key=idempotency_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
supersedes_revision_id=supersedes_revision_id,
|
||||
)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
def append_revision(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
state: str,
|
||||
effective_at: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
payer_company_id: int,
|
||||
payee_company_id: int,
|
||||
perspective_company_id: int | None,
|
||||
subject_code: str | None,
|
||||
source_kind: str,
|
||||
source_revision_token: str | None,
|
||||
posting_kind: str,
|
||||
reverses_ledger_event_id: int | None = None,
|
||||
adjusts_ledger_event_id: int | None = None,
|
||||
rule_version: str | None = None,
|
||||
evidence_json: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
reason: str | None = None,
|
||||
supersedes_revision_id: int | None = None,
|
||||
) -> int:
|
||||
"""Append one immutable revision and repoint the current projection."""
|
||||
if _event_lifecycle(connection, ledger_event_id) != "active":
|
||||
raise LedgerConflictError("该事件已停用,不能追加修订。")
|
||||
if state == "confirmed":
|
||||
if perspective_company_id is None or subject_code is None:
|
||||
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
|
||||
if subject_code not in SUBJECTS:
|
||||
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if perspective_company_id not in (int(payer_company_id), int(payee_company_id)):
|
||||
raise LedgerInputError("视角公司必须是事件参与方。")
|
||||
elif state != "pending_subject":
|
||||
raise LedgerInputError("事件状态必须是 pending_subject 或 confirmed。")
|
||||
revision = _next_revision_number(connection, ledger_event_id)
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_revisions (
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale, currency, payer_company_id, payee_company_id,
|
||||
perspective_company_id, subject_code, source_kind,
|
||||
source_revision_token, posting_kind, reverses_ledger_event_id,
|
||||
adjusts_ledger_event_id, rule_version, evidence_json,
|
||||
idempotency_key, actor_user_id, actor_username, reason,
|
||||
supersedes_revision_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale(amount), currency, payer_company_id, payee_company_id,
|
||||
perspective_company_id, subject_code, source_kind,
|
||||
source_revision_token, posting_kind, reverses_ledger_event_id,
|
||||
adjusts_ledger_event_id, rule_version, evidence_json,
|
||||
idempotency_key,
|
||||
actor["id"] if actor is not None else None,
|
||||
actor["username"] if actor is not None else None,
|
||||
reason, supersedes_revision_id, now,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(ledger_event_id, cursor.lastrowid),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bank_source_claim(connection: sqlite3.Connection, bank_event_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"SELECT * FROM ledger_event_bank_sources WHERE bank_event_id = ?",
|
||||
(bank_event_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def manual_source_claim(connection: sqlite3.Connection, manual_record_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(manual_record_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _facts_of(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object]:
|
||||
revision = current_revision(connection, ledger_event_id)
|
||||
if revision is None:
|
||||
return {}
|
||||
return {
|
||||
"amount": revision["amount"],
|
||||
"currency": revision["currency"],
|
||||
"effective_at": revision["effective_at"],
|
||||
"payer_company_id": revision["payer_company_id"],
|
||||
"payee_company_id": revision["payee_company_id"],
|
||||
}
|
||||
|
||||
|
||||
def _eligible_facts(event: sqlite3.Row) -> dict[str, object]:
|
||||
return {
|
||||
"amount": event["amount"],
|
||||
"currency": event["currency"],
|
||||
"effective_at": event["effective_at"],
|
||||
"payer_company_id": event["payer_company_id"],
|
||||
"payee_company_id": event["payee_company_id"],
|
||||
}
|
||||
|
||||
|
||||
def _has_reversal(connection: sqlite3.Connection, original_event_id: int) -> bool:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1 FROM ledger_event_revisions r
|
||||
JOIN current_ledger_event_revisions c ON c.revision_id = r.id
|
||||
WHERE r.reverses_ledger_event_id = ? AND r.posting_kind = 'reversal'
|
||||
LIMIT 1
|
||||
""",
|
||||
(original_event_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def create_reversal(
|
||||
connection: sqlite3.Connection,
|
||||
original_event_id: int,
|
||||
*,
|
||||
source_kind: str,
|
||||
source_revision_token: str | None = None,
|
||||
effective_at: str | None = None,
|
||||
reason: str,
|
||||
actor: sqlite3.Row | None,
|
||||
idempotency_key: str | None = None,
|
||||
rule_version: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Create an equal-amount, opposite-direction reversal as a new ledger event.
|
||||
|
||||
The subject mirrors the original (应收<->应付, 其他应收<->其他应付). ``effective_at``
|
||||
defaults to the original event's effective date so an earlier cutoff keeps
|
||||
the original impact and later cutoffs see the net zero. The original event
|
||||
is never modified or deleted.
|
||||
"""
|
||||
original = current_revision(connection, original_event_id)
|
||||
if original is None:
|
||||
raise LedgerConflictError("原事件不存在或没有当前修订。")
|
||||
if original["state"] != "confirmed":
|
||||
raise LedgerInputError("只有已确认事件才能生成冲销。")
|
||||
perspective = mirror_perspective(original)
|
||||
if effective_at is None:
|
||||
effective_at = original["effective_at"]
|
||||
return create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=effective_at,
|
||||
amount=original["amount"],
|
||||
currency=original["currency"],
|
||||
payer_company_id=original["payee_company_id"],
|
||||
payee_company_id=original["payer_company_id"],
|
||||
perspective_company_id=perspective,
|
||||
subject_code=mirror_subject(original["subject_code"]),
|
||||
source_kind=source_kind,
|
||||
source_revision_token=source_revision_token,
|
||||
posting_kind="reversal",
|
||||
reverses_ledger_event_id=original_event_id,
|
||||
rule_version=rule_version or original["rule_version"],
|
||||
idempotency_key=idempotency_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def mirror_perspective(revision: sqlite3.Row) -> int:
|
||||
"""The counterparty company from ``revision``'s perspective."""
|
||||
perspective = int(revision["perspective_company_id"])
|
||||
if perspective == int(revision["payer_company_id"]):
|
||||
return int(revision["payee_company_id"])
|
||||
return int(revision["payer_company_id"])
|
||||
|
||||
|
||||
def create_adjustment(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
effective_at: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
payer_company_id: int,
|
||||
payee_company_id: int,
|
||||
perspective_company_id: int,
|
||||
subject_code: str,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
rule_version: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Create an audit adjustment event; the original event stays unchanged."""
|
||||
return create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=effective_at,
|
||||
amount=str(parse_amount(amount)),
|
||||
currency=currency,
|
||||
payer_company_id=payer_company_id,
|
||||
payee_company_id=payee_company_id,
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=subject_code,
|
||||
source_kind="adjustment",
|
||||
source_revision_token=None,
|
||||
posting_kind="adjustment",
|
||||
adjusts_ledger_event_id=ledger_event_id,
|
||||
rule_version=rule_version or SUBJECT_RULE_VERSION,
|
||||
idempotency_key=idempotency_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def reopen_subject(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
reason: str,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Reverse a confirmed event and re-open it for subject re-review.
|
||||
|
||||
Creates an equal-amount reversal plus a fresh ``pending_subject`` event
|
||||
that re-claims the original bank source, so the administrator can confirm
|
||||
a corrected subject. The original event and its reversal keep history.
|
||||
"""
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
if current is None or current["state"] != "confirmed":
|
||||
raise LedgerConflictError("只有已确认事件可以重新进入科目审核。")
|
||||
bank_claim = connection.execute(
|
||||
"SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ?",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
if bank_claim is None:
|
||||
raise LedgerInputError(
|
||||
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
|
||||
)
|
||||
if not _has_reversal(connection, ledger_event_id):
|
||||
create_reversal(
|
||||
connection, ledger_event_id,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
reason="科目复核:原确认事件冲销",
|
||||
actor=actor,
|
||||
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
|
||||
rule_version=current["rule_version"],
|
||||
)
|
||||
ev = connection.execute(
|
||||
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
|
||||
(bank_claim["bank_event_id"],),
|
||||
).fetchone()
|
||||
if ev is None:
|
||||
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
|
||||
event_id, revision_id = _create_bank_event(
|
||||
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
|
||||
replacing_claim=bank_claim,
|
||||
)
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bank event reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def reconcile_bank_events(
|
||||
connection: sqlite3.Connection, actor: sqlite3.Row | None = None
|
||||
) -> dict[str, object]:
|
||||
"""Reconcile the current eligible intercompany events into ledger events.
|
||||
|
||||
Idempotent: first sight creates a ``pending_subject`` event; a changed B-43
|
||||
decision updates a still-pending event's revision, or (for a confirmed
|
||||
event) creates a reversal plus a fresh pending event. A source that left
|
||||
the eligible set with a confirmed impact gets one reversal. Runs inside the
|
||||
caller's transaction when one is open, otherwise in its own transaction.
|
||||
"""
|
||||
began = _ensure_transaction(connection)
|
||||
try:
|
||||
eligible = {
|
||||
row["event_id"]: row
|
||||
for row in connection.execute(
|
||||
"SELECT * FROM eligible_intercompany_events"
|
||||
).fetchall()
|
||||
}
|
||||
claims = {
|
||||
row["bank_event_id"]: row
|
||||
for row in connection.execute(
|
||||
"SELECT * FROM ledger_event_bank_sources"
|
||||
).fetchall()
|
||||
}
|
||||
stats = {
|
||||
"created": 0, "updated_pending": 0, "reversal": 0,
|
||||
"reopened": 0, "unchanged": 0, "sources": len(eligible),
|
||||
}
|
||||
for bank_event_id, event in sorted(eligible.items()):
|
||||
claim = claims.get(bank_event_id)
|
||||
if claim is None:
|
||||
_create_bank_event(
|
||||
connection, event, actor, reason="B-43 事件首次入账,待确认科目"
|
||||
)
|
||||
stats["created"] += 1
|
||||
continue
|
||||
current = current_revision(connection, claim["ledger_event_id"])
|
||||
if current is None or _facts_of(connection, claim["ledger_event_id"]) != _eligible_facts(event):
|
||||
if current is not None and current["state"] == "confirmed":
|
||||
if not _has_reversal(connection, claim["ledger_event_id"]):
|
||||
create_reversal(
|
||||
connection, claim["ledger_event_id"],
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
reason="B-43 事件事实变更,原确认事件冲销",
|
||||
actor=actor,
|
||||
)
|
||||
stats["reversal"] += 1
|
||||
_create_bank_event(
|
||||
connection, event, actor,
|
||||
reason="B-43 事件事实变更后重新入账,待确认科目",
|
||||
replacing_claim=claim,
|
||||
)
|
||||
stats["reopened"] += 1
|
||||
elif current is None or current["state"] == "pending_subject":
|
||||
_append_bank_pending_revision(
|
||||
connection, claim["ledger_event_id"], event, actor
|
||||
)
|
||||
stats["updated_pending"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
for bank_event_id, claim in sorted(claims.items()):
|
||||
if bank_event_id in eligible:
|
||||
continue
|
||||
current = current_revision(connection, claim["ledger_event_id"])
|
||||
if current is not None and current["state"] == "confirmed":
|
||||
if not _has_reversal(connection, claim["ledger_event_id"]):
|
||||
create_reversal(
|
||||
connection, claim["ledger_event_id"],
|
||||
source_kind="bank",
|
||||
source_revision_token=None,
|
||||
reason="B-43 事件不再纳入往来,原确认事件冲销",
|
||||
actor=actor,
|
||||
)
|
||||
stats["reversal"] += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def _create_bank_event(
|
||||
connection: sqlite3.Connection,
|
||||
event: sqlite3.Row,
|
||||
actor: sqlite3.Row | None,
|
||||
*,
|
||||
reason: str,
|
||||
replacing_claim: sqlite3.Row | None = None,
|
||||
) -> tuple[int, int]:
|
||||
event_id, revision_id = create_event(
|
||||
connection,
|
||||
state="pending_subject",
|
||||
effective_at=event["effective_at"],
|
||||
amount=event["amount"],
|
||||
currency=event["currency"],
|
||||
payer_company_id=event["payer_company_id"],
|
||||
payee_company_id=event["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
posting_kind="normal",
|
||||
rule_version=SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(
|
||||
{
|
||||
"bank_event_id": event["event_id"],
|
||||
"decision_id": event["decision_id"],
|
||||
"pairing": event["pairing"],
|
||||
"evidence_count": event["evidence_count"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
if replacing_claim is not None:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
|
||||
WHERE bank_event_id = ?
|
||||
""",
|
||||
(event_id, event["event_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(event["event_id"], event_id),
|
||||
)
|
||||
from .subjects import store_suggestions
|
||||
|
||||
store_suggestions(connection, event_id)
|
||||
return event_id, revision_id
|
||||
|
||||
|
||||
def _append_bank_pending_revision(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
event: sqlite3.Row,
|
||||
actor: sqlite3.Row | None,
|
||||
) -> int:
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
ledger_event_id,
|
||||
state="pending_subject",
|
||||
effective_at=event["effective_at"],
|
||||
amount=event["amount"],
|
||||
currency=event["currency"],
|
||||
payer_company_id=event["payer_company_id"],
|
||||
payee_company_id=event["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind="bank",
|
||||
source_revision_token=event["decision_id"],
|
||||
posting_kind="normal",
|
||||
rule_version=SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(
|
||||
{
|
||||
"bank_event_id": event["event_id"],
|
||||
"decision_id": event["decision_id"],
|
||||
"pairing": event["pairing"],
|
||||
"evidence_count": event["evidence_count"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
actor=actor,
|
||||
reason="B-43 事件事实更新,追加待审修订",
|
||||
supersedes_revision_id=current["id"] if current is not None else None,
|
||||
)
|
||||
from .subjects import store_suggestions
|
||||
|
||||
store_suggestions(connection, ledger_event_id)
|
||||
return revision_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projection rebuild
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rebuild_current_ledger_projection(connection: sqlite3.Connection) -> int:
|
||||
"""Rebuild current ledger revisions from the append-only log."""
|
||||
began = _ensure_transaction(connection)
|
||||
try:
|
||||
connection.execute("DELETE FROM current_ledger_event_revisions")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT e.id AS ledger_event_id,
|
||||
(SELECT r2.id FROM ledger_event_revisions r2
|
||||
WHERE r2.ledger_event_id = e.id
|
||||
ORDER BY r2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM ledger_events e
|
||||
WHERE e.lifecycle = 'active'
|
||||
""",
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
for row in rows:
|
||||
if row["latest_id"] is None:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(row["ledger_event_id"], row["latest_id"]),
|
||||
)
|
||||
rebuilt += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return rebuilt
|
||||
@@ -1,818 +0,0 @@
|
||||
"""Manual evidence records, administrator approval and audit-safe reversal.
|
||||
|
||||
Manual records are immutable submitted facts. Only an approved record becomes
|
||||
a canonical ledger event (``approve_new``) or joins one (``approve_link``);
|
||||
returned/exception/pending records never affect a balance and never leak to
|
||||
the counterparty. Approved facts change only through a ``reverse`` decision
|
||||
that creates an opposite new event (or detaches a linked claim) — the original
|
||||
is never edited. Idempotency keys and UNIQUE claims prevent double counting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .ledger_events import (
|
||||
LedgerConflictError,
|
||||
LedgerInputError,
|
||||
create_event,
|
||||
create_reversal,
|
||||
current_revision,
|
||||
manual_source_claim,
|
||||
)
|
||||
from .subjects import SUBJECTS
|
||||
|
||||
MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed")
|
||||
FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other")
|
||||
DATE_KEYS = ("occurred_at",)
|
||||
|
||||
|
||||
class ManualConflictError(ValueError):
|
||||
"""A claim/idempotency/revision conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class ManualInputError(ValueError):
|
||||
"""Invalid manual record input (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
def _parse_amount(amount: object) -> Decimal:
|
||||
try:
|
||||
value = Decimal(str(amount))
|
||||
except InvalidOperation:
|
||||
raise ManualInputError("金额不是有效的十进制数。") from None
|
||||
if not value.is_finite() or value <= 0:
|
||||
raise ManualInputError("金额必须大于零。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_date(value: object, field: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) < 10:
|
||||
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。")
|
||||
try:
|
||||
datetime.fromisoformat(text[:10])
|
||||
except ValueError:
|
||||
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None
|
||||
return text
|
||||
|
||||
|
||||
def _business_today() -> str:
|
||||
"""Shanghai business date (the default reversal effective date)."""
|
||||
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def submit(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
counterparty_company_id: int,
|
||||
occurred_at: str,
|
||||
direction: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
funding_source: str,
|
||||
requested_subject: str,
|
||||
request_key: str,
|
||||
actor: sqlite3.Row,
|
||||
bank_account_id: object = None,
|
||||
personal_transit_mapping_id: object = None,
|
||||
related_source_row_id: object = None,
|
||||
summary: object = None,
|
||||
reason: object = None,
|
||||
evidence: object = None,
|
||||
supersedes_record_id: object = None,
|
||||
) -> dict[str, object]:
|
||||
"""Submit one manual record for review. Idempotent on ``(company_id, request_key)``."""
|
||||
request_key = str(request_key or "").strip()
|
||||
if not request_key:
|
||||
raise ManualInputError("必须提供提交幂等键 request_key。")
|
||||
if int(company_id) == int(counterparty_company_id):
|
||||
raise ManualInputError("对方公司不能与本公司相同。")
|
||||
if direction not in ("outgoing", "incoming"):
|
||||
raise ManualInputError("方向必须是 outgoing 或 incoming。")
|
||||
if funding_source not in FUNDING_SOURCES:
|
||||
raise ManualInputError(f"资金来源必须是:{'、'.join(FUNDING_SOURCES)}。")
|
||||
if requested_subject not in SUBJECTS:
|
||||
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
_validate_date(occurred_at, "业务日期")
|
||||
currency = str(currency or "").strip()
|
||||
if not currency:
|
||||
raise ManualInputError("币种不能为空。")
|
||||
amount = str(_parse_amount(amount))
|
||||
|
||||
for label, raw in (
|
||||
("company_id", company_id), ("counterparty_company_id", counterparty_company_id),
|
||||
):
|
||||
row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone()
|
||||
if row is None:
|
||||
raise ManualInputError(f"{label} 指向的公司不存在。")
|
||||
|
||||
bank_account_id = _resolve_account_ref(
|
||||
connection, bank_account_id, company_id, "银行账户"
|
||||
)
|
||||
mapping_id = _resolve_account_ref(
|
||||
connection, personal_transit_mapping_id, company_id, "个人过账映射"
|
||||
)
|
||||
related_row = None
|
||||
if related_source_row_id not in (None, ""):
|
||||
related_row = connection.execute(
|
||||
"SELECT r.id, b.company_id FROM source_rows r "
|
||||
"JOIN sheet_batches s ON s.id = r.sheet_batch_id "
|
||||
"JOIN import_batches b ON b.id = s.import_batch_id "
|
||||
"WHERE r.id = ?",
|
||||
(int(related_source_row_id),),
|
||||
).fetchone()
|
||||
if related_row is None:
|
||||
raise ManualInputError("关联银行源行不存在。")
|
||||
|
||||
if funding_source == "approved_bank_account" and bank_account_id is None:
|
||||
raise ManualInputError("资金来源为已批准账户时必须指定银行账户。")
|
||||
if funding_source == "personal_transit" and mapping_id is None:
|
||||
raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。")
|
||||
|
||||
supersedes_id = None
|
||||
if supersedes_record_id not in (None, ""):
|
||||
parent = connection.execute(
|
||||
"SELECT id, company_id FROM manual_records WHERE id = ?",
|
||||
(int(supersedes_record_id),),
|
||||
).fetchone()
|
||||
if parent is None or parent["company_id"] != int(company_id):
|
||||
raise ManualInputError("supersedes_record_id 无效。")
|
||||
supersedes_id = int(supersedes_record_id)
|
||||
|
||||
now = utc_now()
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
# Re-check inside the write transaction: concurrent identical submits
|
||||
# serialize here, so a replay is found before any INSERT.
|
||||
existing = connection.execute(
|
||||
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
|
||||
(int(company_id), request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _record_payload(connection, existing["id"], idempotent_replay=True)
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_records (
|
||||
company_id, counterparty_company_id, occurred_at, direction,
|
||||
amount, amount_scale, currency, funding_source, bank_account_id,
|
||||
personal_transit_mapping_id, related_source_row_id,
|
||||
requested_subject, summary, reason, evidence_json, request_key,
|
||||
supersedes_record_id, submitted_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(company_id), int(counterparty_company_id), occurred_at, direction,
|
||||
amount, _scale_of(amount), currency, funding_source, bank_account_id,
|
||||
mapping_id, related_row["id"] if related_row is not None else None,
|
||||
requested_subject, str(summary or "") or None,
|
||||
str(reason or "") or None,
|
||||
json.dumps(evidence, ensure_ascii=False) if evidence else None,
|
||||
request_key, supersedes_id,
|
||||
actor["id"], now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# A concurrent identical submit won the race and committed first;
|
||||
# surface the existing record idempotently instead of a UNIQUE 500.
|
||||
if began:
|
||||
connection.rollback()
|
||||
existing = connection.execute(
|
||||
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
|
||||
(int(company_id), request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return _record_payload(connection, existing["id"], idempotent_replay=True)
|
||||
raise
|
||||
record_id = int(cursor.lastrowid)
|
||||
_append_decision(
|
||||
connection, record_id, state="pending", action="submit",
|
||||
reason=str(reason or "") or None, actor=actor,
|
||||
)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _record_payload(connection, record_id)
|
||||
|
||||
|
||||
def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
row = connection.execute(
|
||||
"SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ManualInputError(f"{label}不存在。")
|
||||
if row["company_id"] != int(company_id):
|
||||
raise ManualInputError(f"{label}必须属于提交公司。")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _scale_of(amount: str) -> int:
|
||||
exponent = Decimal(amount).as_tuple().exponent
|
||||
return max(0, -int(exponent))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _append_decision(
|
||||
connection: sqlite3.Connection,
|
||||
record_id: int,
|
||||
*,
|
||||
state: str,
|
||||
action: str,
|
||||
reason: str | None,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
supersedes_decision_id: int | None = None,
|
||||
) -> int:
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?",
|
||||
(record_id,),
|
||||
).fetchone()
|
||||
revision = int(row["m"]) + 1
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_record_decisions (
|
||||
record_id, revision, state, action, reason, actor_user_id,
|
||||
actor_username, idempotency_key, supersedes_decision_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
record_id, revision, state, action, reason,
|
||||
actor["id"], actor["username"], idempotency_key,
|
||||
supersedes_decision_id, now,
|
||||
),
|
||||
)
|
||||
decision_id = int(cursor.lastrowid)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record_id, decision_id),
|
||||
)
|
||||
return decision_id
|
||||
|
||||
|
||||
def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT d.* FROM current_manual_record_decisions c
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE c.record_id = ?
|
||||
""",
|
||||
(record_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def decide(
|
||||
connection: sqlite3.Connection,
|
||||
record_id: int,
|
||||
action: str,
|
||||
*,
|
||||
reason: str,
|
||||
expected_decision_id: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
subject_code: object = None,
|
||||
target_ledger_event_id: object = None,
|
||||
effective_at: object = None,
|
||||
) -> dict[str, object]:
|
||||
"""Apply an administrator decision to a manual record.
|
||||
|
||||
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
|
||||
existing ledger event without adding a second economic impact; ``return``
|
||||
and ``exception`` never produce a balance; ``reverse`` creates an opposite
|
||||
reversal event (or detaches a linked claim) with an independent business
|
||||
effective date — explicit ``effective_at`` or the approval business day.
|
||||
Replays return the earlier outcome via ``idempotency_key``.
|
||||
"""
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise ManualInputError("必须填写审核原因。")
|
||||
if action not in ("approve_new", "approve_link", "return", "exception", "reverse"):
|
||||
raise ManualInputError("未知的审核决定类型。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
if record is None:
|
||||
raise ManualConflictError("手工记录不存在。")
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?",
|
||||
(record_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _decision_payload(connection, record_id, existing["id"])
|
||||
|
||||
current = _current_decision(connection, record_id)
|
||||
if current is None:
|
||||
raise ManualConflictError("该记录没有当前状态。")
|
||||
if expected_decision_id is not None and int(expected_decision_id) != current["id"]:
|
||||
raise ManualConflictError("记录已发生变更,请刷新后重试。")
|
||||
|
||||
if action == "approve_new":
|
||||
outcome = _approve_new(
|
||||
connection, record, current, actor, subject_code, reason, request_key
|
||||
)
|
||||
elif action == "approve_link":
|
||||
outcome = _approve_link(
|
||||
connection, record, current, actor, target_ledger_event_id, reason,
|
||||
request_key,
|
||||
)
|
||||
elif action == "return":
|
||||
if current["state"] != "pending":
|
||||
raise ManualConflictError("只有待复核的记录可以退回。")
|
||||
decision_id = _append_decision(
|
||||
connection, record_id, state="returned", action=action,
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
outcome = {"decision_id": decision_id, "ledger_event_id": None}
|
||||
elif action == "exception":
|
||||
if current["state"] != "pending":
|
||||
raise ManualConflictError("只有待复核的记录可以转为异常。")
|
||||
decision_id = _append_decision(
|
||||
connection, record_id, state="exception", action=action,
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
outcome = {"decision_id": decision_id, "ledger_event_id": None}
|
||||
else: # reverse
|
||||
if current["state"] != "approved":
|
||||
raise ManualConflictError("只有已批准记录可以冲销。")
|
||||
outcome = _reverse(
|
||||
connection, record, current, actor, request_key, reason,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
|
||||
_store_audit(connection, record, current, action, outcome, reason, actor)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _decision_payload(connection, record_id, outcome["decision_id"])
|
||||
|
||||
|
||||
def _approve_new(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
subject_code: object,
|
||||
reason: str,
|
||||
idempotency_key: str | None,
|
||||
) -> dict[str, object]:
|
||||
subject = str(subject_code or record["requested_subject"] or "")
|
||||
if subject not in SUBJECTS:
|
||||
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if record["direction"] == "outgoing":
|
||||
payer, payee = record["company_id"], record["counterparty_company_id"]
|
||||
else:
|
||||
payer, payee = record["counterparty_company_id"], record["company_id"]
|
||||
event_id, _revision_id = create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=record["occurred_at"],
|
||||
amount=record["amount"],
|
||||
currency=record["currency"],
|
||||
payer_company_id=payer,
|
||||
payee_company_id=payee,
|
||||
perspective_company_id=record["company_id"],
|
||||
subject_code=subject,
|
||||
source_kind="manual",
|
||||
source_revision_token=None,
|
||||
posting_kind="normal",
|
||||
rule_version="manual-record-v1",
|
||||
evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="approved", action="approve_new",
|
||||
reason=reason, actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record["id"], event_id),
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": event_id}
|
||||
|
||||
|
||||
def _approve_link(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
target_ledger_event_id: object,
|
||||
reason: str,
|
||||
idempotency_key: str | None,
|
||||
) -> dict[str, object]:
|
||||
if target_ledger_event_id in (None, ""):
|
||||
raise ManualInputError("approve_link 必须指定目标往来事件。")
|
||||
target = connection.execute(
|
||||
"SELECT id, lifecycle FROM ledger_events WHERE id = ?",
|
||||
(int(target_ledger_event_id),),
|
||||
).fetchone()
|
||||
if target is None or target["lifecycle"] != "active":
|
||||
raise ManualConflictError("目标往来事件不存在。")
|
||||
if manual_source_claim(connection, record["id"]) is not None:
|
||||
raise ManualConflictError("该手工记录已关联往来事件。")
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="approved", action="approve_link",
|
||||
reason=reason, actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record["id"], int(target_ledger_event_id)),
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)}
|
||||
|
||||
|
||||
def _reverse(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
request_key: str | None,
|
||||
reason: str,
|
||||
effective_at: object = None,
|
||||
) -> dict[str, object]:
|
||||
claim = manual_source_claim(connection, record["id"])
|
||||
if claim is None:
|
||||
raise ManualConflictError("该记录尚未关联往来事件,无法冲销。")
|
||||
event_id = claim["ledger_event_id"]
|
||||
revision = current_revision(connection, event_id)
|
||||
if revision is None:
|
||||
raise ManualConflictError("关联的往来事件没有当前修订。")
|
||||
|
||||
if _is_manual_creation_source(revision, record["id"]):
|
||||
# This record created the event (approve_new): it added the economic
|
||||
# impact, so reversing it must always produce an equal-amount reversal
|
||||
# event — even when other manual evidence was later linked onto the
|
||||
# same event. The original impact must not survive in balances.
|
||||
if effective_at is not None and str(effective_at).strip():
|
||||
effective_at = _validate_date(effective_at, "冲销生效日")
|
||||
else:
|
||||
effective_at = _business_today()
|
||||
create_reversal(
|
||||
connection,
|
||||
event_id,
|
||||
source_kind="manual",
|
||||
source_revision_token=str(record["id"]),
|
||||
effective_at=effective_at,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
idempotency_key=request_key,
|
||||
rule_version="manual-record-v1",
|
||||
)
|
||||
else:
|
||||
# The manual was linked evidence (approve_link) on an event it never
|
||||
# created: it added no second impact, so reversing detaches the claim
|
||||
# and the underlying economic impact stays.
|
||||
connection.execute(
|
||||
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(record["id"],),
|
||||
)
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="reversed", action="reverse",
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": None}
|
||||
|
||||
|
||||
def _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool:
|
||||
"""True when ``manual_record_id`` created the event via ``approve_new``.
|
||||
|
||||
The creation source is recorded in the event's immutable revision
|
||||
``evidence_json``; a linked evidence record is never the creation source
|
||||
and carries no second economic impact.
|
||||
"""
|
||||
if revision["source_kind"] != "manual":
|
||||
return False
|
||||
try:
|
||||
evidence = json.loads(revision["evidence_json"] or "{}")
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return evidence.get("manual_record_id") == manual_record_id
|
||||
|
||||
|
||||
def _store_audit(
|
||||
connection, record, current, action, outcome, reason, actor
|
||||
) -> None:
|
||||
from .auth import audit
|
||||
|
||||
audit(
|
||||
connection,
|
||||
f"manual_{action}",
|
||||
actor=actor,
|
||||
target=f"manual_record:{record['id']}",
|
||||
detail=(
|
||||
f"decision:{outcome['decision_id']};"
|
||||
f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Candidates and queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]:
|
||||
"""Deterministic hints shown before approval; never auto-merged."""
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
if record is None:
|
||||
return []
|
||||
wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing"
|
||||
date_prefix = str(record["occurred_at"])[:10]
|
||||
candidates: list[dict[str, object]] = []
|
||||
|
||||
bank_rows = connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing,
|
||||
e.payer_company_id, e.payee_company_id, e.decision_id
|
||||
FROM eligible_intercompany_events e
|
||||
WHERE (e.payer_company_id = ? AND e.payee_company_id = ?)
|
||||
OR (e.payer_company_id = ? AND e.payee_company_id = ?)
|
||||
ORDER BY e.event_id
|
||||
""",
|
||||
(
|
||||
record["company_id"], record["counterparty_company_id"],
|
||||
record["counterparty_company_id"], record["company_id"],
|
||||
),
|
||||
).fetchall()
|
||||
for row in bank_rows:
|
||||
if row["amount"] != record["amount"] or row["currency"] != record["currency"]:
|
||||
continue
|
||||
event_direction = (
|
||||
"outgoing" if row["payer_company_id"] == record["company_id"] else "incoming"
|
||||
)
|
||||
if event_direction != wanted_direction:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "bank_event",
|
||||
"ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]),
|
||||
"bank_event_id": row["event_id"],
|
||||
"decision_id": row["decision_id"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"effective_at": row["effective_at"],
|
||||
"pairing": row["pairing"],
|
||||
"hint": "已存在匹配的银行规范事件,建议关联",
|
||||
}
|
||||
)
|
||||
|
||||
manual_rows = connection.execute(
|
||||
"""
|
||||
SELECT m.id, m.company_id, m.counterparty_company_id, m.direction,
|
||||
m.amount, m.currency, m.occurred_at, d.state
|
||||
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 m.id != ? AND m.amount = ? AND m.currency = ?
|
||||
AND (
|
||||
(m.company_id = ? AND m.counterparty_company_id = ?)
|
||||
OR (m.company_id = ? AND m.counterparty_company_id = ?)
|
||||
)
|
||||
ORDER BY m.id
|
||||
""",
|
||||
(
|
||||
record["id"], record["amount"], record["currency"],
|
||||
record["company_id"], record["counterparty_company_id"],
|
||||
record["counterparty_company_id"], record["company_id"],
|
||||
),
|
||||
).fetchall()
|
||||
for row in manual_rows:
|
||||
if row["direction"] != wanted_direction:
|
||||
continue
|
||||
if row["state"] not in ("approved", "pending"):
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "manual_record",
|
||||
"ledger_event_id": None,
|
||||
"manual_record_id": row["id"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"occurred_at": row["occurred_at"],
|
||||
"state": row["state"],
|
||||
"hint": "存在方向相反的同额手工记录,建议核对后关联",
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None:
|
||||
claim = connection.execute(
|
||||
"SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?",
|
||||
(bank_event_id,),
|
||||
).fetchone()
|
||||
return claim["ledger_event_id"] if claim is not None else None
|
||||
|
||||
|
||||
def list_records(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
state: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[sqlite3.Row]:
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)")
|
||||
params.extend([company_id, company_id])
|
||||
if state is not None:
|
||||
if state not in MANUAL_STATES:
|
||||
raise ManualInputError("无效的记录状态。")
|
||||
conditions.append("d.state = ?")
|
||||
params.append(state)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
return connection.execute(
|
||||
f"""
|
||||
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
|
||||
d.action AS action, d.reason AS decision_reason,
|
||||
d.actor_username AS decision_actor, d.created_at AS decision_at,
|
||||
c.name AS company_name, cc.name AS counterparty_company_name,
|
||||
u.username AS submitted_by_username
|
||||
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
|
||||
LEFT JOIN companies c ON c.id = m.company_id
|
||||
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
|
||||
LEFT JOIN users u ON u.id = m.submitted_by
|
||||
{where}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*params, max(1, int(limit))),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int:
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
connection.execute("DELETE FROM current_manual_record_decisions")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT m.id AS record_id,
|
||||
(SELECT d2.id FROM manual_record_decisions d2
|
||||
WHERE d2.record_id = m.id
|
||||
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM manual_records m
|
||||
"""
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
for row in rows:
|
||||
if row["latest_id"] is None:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(row["record_id"], row["latest_id"]),
|
||||
)
|
||||
rebuilt += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return rebuilt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]:
|
||||
rows = list_records(connection, limit=1000)
|
||||
row = next((item for item in rows if item["id"] == record_id), None)
|
||||
if row is None:
|
||||
raise ManualInputError("手工记录不存在。")
|
||||
payload = _row_payload(connection, row)
|
||||
if idempotent_replay:
|
||||
payload["idempotent_replay"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
|
||||
evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": row["company_name"],
|
||||
"counterparty_company_id": row["counterparty_company_id"],
|
||||
"counterparty_company_name": row["counterparty_company_name"],
|
||||
"occurred_at": row["occurred_at"],
|
||||
"direction": row["direction"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"funding_source": row["funding_source"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"personal_transit_mapping_id": row["personal_transit_mapping_id"],
|
||||
"related_source_row_id": row["related_source_row_id"],
|
||||
"requested_subject": row["requested_subject"],
|
||||
"summary": row["summary"],
|
||||
"reason": row["reason"],
|
||||
"request_key": row["request_key"],
|
||||
"supersedes_record_id": row["supersedes_record_id"],
|
||||
"submitted_by": row["submitted_by"],
|
||||
"submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None,
|
||||
"attachment_name": evidence.get("attachment_name"),
|
||||
"created_at": row["created_at"],
|
||||
"state": row["state"],
|
||||
"decision_id": row["decision_id"],
|
||||
"decision_revision": row["decision_revision"],
|
||||
"decision_action": row["action"],
|
||||
"decision_reason": row["decision_reason"],
|
||||
"decision_actor": row["decision_actor"],
|
||||
"decision_at": row["decision_at"],
|
||||
"candidates": find_candidates(connection, row["id"]),
|
||||
}
|
||||
|
||||
|
||||
def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT d.* FROM manual_record_decisions d
|
||||
WHERE d.id = ?
|
||||
""",
|
||||
(decision_id,),
|
||||
).fetchone()
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
claim = manual_source_claim(connection, record_id)
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"decision_id": decision_id,
|
||||
"revision": row["revision"],
|
||||
"state": row["state"],
|
||||
"action": row["action"],
|
||||
"reason": row["reason"],
|
||||
"actor_username": row["actor_username"],
|
||||
"created_at": row["created_at"],
|
||||
"ledger_event_id": claim["ledger_event_id"] if claim is not None else None,
|
||||
"requested_subject": record["requested_subject"],
|
||||
"amount": record["amount"],
|
||||
"currency": record["currency"],
|
||||
"occurred_at": record["occurred_at"],
|
||||
}
|
||||
@@ -262,7 +262,7 @@ def submit_bank_account(
|
||||
|
||||
if existing["company_id"] == company_id:
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
|
||||
|
||||
|
||||
def review_bank_account(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,757 @@
|
||||
"""Read-only reminder rule engine and delivery helpers.
|
||||
|
||||
Scans existing tables to surface pending items; delivery writes append-only
|
||||
``reminders`` / ``reminder_events`` rows without touching bank or business data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from bank_importer import auth
|
||||
from bank_importer.db import utc_now
|
||||
|
||||
RULE_UNSUBMITTED = "unsubmitted"
|
||||
RULE_GAP = "gap"
|
||||
RULE_PENDING = "pending_review"
|
||||
RULE_MANUAL = "manual"
|
||||
|
||||
SETTING_MONTHLY_START_DAY = "monthly_start_day"
|
||||
SETTING_GAP_DAYS = "gap_days"
|
||||
SETTING_SCAN_TIME = "scan_time"
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
SETTING_MONTHLY_START_DAY: "5",
|
||||
SETTING_GAP_DAYS: "5",
|
||||
SETTING_SCAN_TIME: "08:00",
|
||||
}
|
||||
|
||||
RULE_LABELS = {
|
||||
RULE_UNSUBMITTED: "流水未提交",
|
||||
RULE_GAP: "流水断档",
|
||||
RULE_PENDING: "待确认/待审核",
|
||||
}
|
||||
|
||||
ACTION_LINKS = {
|
||||
RULE_UNSUBMITTED: "upload",
|
||||
RULE_GAP: "flows",
|
||||
RULE_PENDING: "reconcile",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanFinding:
|
||||
company_id: int
|
||||
company_name: str
|
||||
rule_key: str
|
||||
dedupe_key: str
|
||||
rule_params: dict[str, Any]
|
||||
title: str
|
||||
content: str
|
||||
reason: str
|
||||
days_open: int
|
||||
deadline: str | None
|
||||
action_link: str
|
||||
existing_reminder_id: int | None
|
||||
send_count: int
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
rows = connection.execute("SELECT key, value FROM reminder_settings").fetchall()
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def _valid_scan_time(value: str) -> bool:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit():
|
||||
return False
|
||||
if len(parts[1]) != 2:
|
||||
return False
|
||||
hour, minute = int(parts[0]), int(parts[1])
|
||||
return 0 <= hour <= 23 and 0 <= minute <= 59
|
||||
|
||||
|
||||
def validate_setting(key: str, value: str) -> str:
|
||||
raw = str(value).strip()
|
||||
if key == SETTING_MONTHLY_START_DAY:
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("每月起始日须为 1 到 28 的整数。") from exc
|
||||
if day < 1 or day > 28:
|
||||
raise ValueError("每月起始日须为 1 到 28 的整数。")
|
||||
return str(day)
|
||||
if key == SETTING_GAP_DAYS:
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("断档天数须为大于等于 1 的整数。") from exc
|
||||
if days < 1:
|
||||
raise ValueError("断档天数须为大于等于 1 的整数。")
|
||||
return str(days)
|
||||
if key == SETTING_SCAN_TIME:
|
||||
if not _valid_scan_time(raw):
|
||||
raise ValueError("扫描时间须为合法的 HH:MM。")
|
||||
hour, minute = raw.split(":")
|
||||
return f"{int(hour):02d}:{minute}"
|
||||
raise ValueError(f"unknown setting: {key}")
|
||||
|
||||
|
||||
def update_settings(connection: sqlite3.Connection, updates: dict[str, str]) -> dict[str, str]:
|
||||
allowed = set(DEFAULT_SETTINGS)
|
||||
cleaned: dict[str, str] = {}
|
||||
for key, value in updates.items():
|
||||
if key not in allowed:
|
||||
raise ValueError(f"unknown setting: {key}")
|
||||
cleaned[key] = validate_setting(key, value)
|
||||
now = utc_now()
|
||||
with connection:
|
||||
for key, value in cleaned.items():
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminder_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, value, now),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
def _setting_int(settings: dict[str, str], key: str) -> int:
|
||||
try:
|
||||
return int(validate_setting(key, settings.get(key, DEFAULT_SETTINGS[key])))
|
||||
except (TypeError, ValueError):
|
||||
return int(DEFAULT_SETTINGS[key])
|
||||
|
||||
|
||||
def _month_period(day: date | None = None) -> str:
|
||||
ref = day or _today()
|
||||
return f"{ref.year:04d}-{ref.month:02d}"
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _days_between(start: date, end: date) -> int:
|
||||
return max(0, (end - start).days)
|
||||
|
||||
|
||||
def _duration_pill_class(days: int) -> str:
|
||||
if days >= 7:
|
||||
return "pill-danger"
|
||||
if days >= 3:
|
||||
return "pill-warn"
|
||||
return "pill-muted"
|
||||
|
||||
|
||||
def _existing_reminder(connection: sqlite3.Connection, dedupe_key: str) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, send_count, status, last_sent_at
|
||||
FROM reminders
|
||||
WHERE dedupe_key = ? AND status != 'resolved'
|
||||
""",
|
||||
(dedupe_key,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _company_has_month_batch(connection: sqlite3.Connection, company_id: int, period: str) -> bool:
|
||||
year, month = period.split("-")
|
||||
prefix = f"{year}-{month}"
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM import_batches b
|
||||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
WHERE b.company_id = ?
|
||||
AND (
|
||||
substr(s.period_start, 1, 7) = ?
|
||||
OR substr(s.period_end, 1, 7) = ?
|
||||
OR (s.period_start <= ? || '-31' AND s.period_end >= ? || '-01')
|
||||
)
|
||||
LIMIT 1
|
||||
""",
|
||||
(company_id, prefix, prefix, prefix, prefix),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _latest_batch_end(connection: sqlite3.Connection, company_id: int) -> date | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT MAX(s.period_end) AS latest_end
|
||||
FROM import_batches b
|
||||
JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
WHERE b.company_id = ?
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()
|
||||
return _parse_date(row["latest_end"] if row else None)
|
||||
|
||||
|
||||
def _pending_review_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||||
match_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT e.id)
|
||||
FROM canonical_transfer_events e
|
||||
JOIN current_transfer_decisions c ON c.event_id = e.id
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
|
||||
WHERE e.lifecycle = 'active'
|
||||
AND d.classification IN ('unresolved', 'needs_review')
|
||||
AND (payer.company_id = ? OR payee.company_id = ?)
|
||||
""",
|
||||
(company_id, company_id),
|
||||
).fetchone()[0]
|
||||
sheet_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sheet_reviews r
|
||||
JOIN import_batches b ON b.id = r.import_batch_id
|
||||
WHERE b.company_id = ?
|
||||
AND r.outcome = 'parsed'
|
||||
AND r.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()[0]
|
||||
account_count = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()[0]
|
||||
return int(match_count) + int(sheet_count) + int(account_count)
|
||||
|
||||
|
||||
def _active_companies(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT id, name FROM companies
|
||||
WHERE COALESCE(status, 'active') != 'disabled'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def scan_findings(connection: sqlite3.Connection) -> list[ScanFinding]:
|
||||
settings = get_settings(connection)
|
||||
today = _today()
|
||||
period = _month_period(today)
|
||||
monthly_start = _setting_int(settings, SETTING_MONTHLY_START_DAY)
|
||||
gap_days = _setting_int(settings, SETTING_GAP_DAYS)
|
||||
findings: list[ScanFinding] = []
|
||||
|
||||
for company in _active_companies(connection):
|
||||
company_id = int(company["id"])
|
||||
company_name = company["name"]
|
||||
|
||||
if today.day >= monthly_start and not _company_has_month_batch(connection, company_id, period):
|
||||
dedupe_key = f"{company_id}:{RULE_UNSUBMITTED}:{period}"
|
||||
days_open = _days_between(date(today.year, today.month, monthly_start), today)
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_UNSUBMITTED,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"period": period},
|
||||
title=f"{company_name} · {today.month} 月流水未提交",
|
||||
content=(
|
||||
f"贵公司 {period} 银行流水尚未提交。"
|
||||
f"请于截止日前完成全部账户流水上传。"
|
||||
),
|
||||
reason=f"应交 {today.month:02d}-{monthly_start:02d} · 已逾期 {days_open} 天",
|
||||
days_open=days_open,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_UNSUBMITTED],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
latest_end = _latest_batch_end(connection, company_id)
|
||||
if latest_end is not None:
|
||||
gap = _days_between(latest_end, today)
|
||||
if gap > gap_days:
|
||||
dedupe_key = f"{company_id}:{RULE_GAP}:{latest_end.isoformat()}"
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_GAP,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"latest_end": latest_end.isoformat(), "gap_days": gap},
|
||||
title=f"{company_name} · 流水断档 {gap} 天",
|
||||
content=(
|
||||
f"最近流水截止日为 {latest_end.isoformat()},"
|
||||
f"已连续 {gap} 天无新数据,请补传断档期间银行流水。"
|
||||
),
|
||||
reason=f"最近截止 {latest_end.strftime('%m-%d')} · 断档 {gap} 天",
|
||||
days_open=gap,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_GAP],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
pending_count = _pending_review_count(connection, company_id)
|
||||
if pending_count > 0:
|
||||
dedupe_key = f"{company_id}:{RULE_PENDING}:active"
|
||||
existing = _existing_reminder(connection, dedupe_key)
|
||||
first_seen = _parse_date(existing["last_sent_at"][:10] if existing and existing["last_sent_at"] else None)
|
||||
days_open = _days_between(first_seen, today) if first_seen else 0
|
||||
findings.append(
|
||||
ScanFinding(
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
rule_key=RULE_PENDING,
|
||||
dedupe_key=dedupe_key,
|
||||
rule_params={"pending_count": pending_count},
|
||||
title=f"{company_name} · {pending_count} 项待确认/待审核",
|
||||
content=(
|
||||
f"贵公司当前有 {pending_count} 项往来确认、流水审核或账户登记待处理,"
|
||||
f"请尽快完成确认以免影响结账。"
|
||||
),
|
||||
reason=f"待处理 {pending_count} 项",
|
||||
days_open=days_open,
|
||||
deadline=None,
|
||||
action_link=ACTION_LINKS[RULE_PENDING],
|
||||
existing_reminder_id=int(existing["id"]) if existing else None,
|
||||
send_count=int(existing["send_count"]) if existing else 0,
|
||||
)
|
||||
)
|
||||
|
||||
findings.sort(key=lambda item: item.days_open, reverse=True)
|
||||
return findings
|
||||
|
||||
|
||||
def run_scan(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
findings = scan_findings(connection)
|
||||
counts = {
|
||||
RULE_UNSUBMITTED: sum(1 for f in findings if f.rule_key == RULE_UNSUBMITTED),
|
||||
RULE_GAP: sum(1 for f in findings if f.rule_key == RULE_GAP),
|
||||
RULE_PENDING: sum(1 for f in findings if f.rule_key == RULE_PENDING),
|
||||
}
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_scan",
|
||||
actor=actor,
|
||||
detail=json.dumps({"total": len(findings), "by_rule": counts}, ensure_ascii=False),
|
||||
ip=ip,
|
||||
)
|
||||
return {"findings": findings, "counts": counts}
|
||||
|
||||
|
||||
def finding_to_dict(finding: ScanFinding) -> dict[str, Any]:
|
||||
return {
|
||||
"company_id": finding.company_id,
|
||||
"company_name": finding.company_name,
|
||||
"rule_key": finding.rule_key,
|
||||
"rule_label": RULE_LABELS.get(finding.rule_key, finding.rule_key),
|
||||
"dedupe_key": finding.dedupe_key,
|
||||
"rule_params": finding.rule_params,
|
||||
"title": finding.title,
|
||||
"content": finding.content,
|
||||
"reason": finding.reason,
|
||||
"days_open": finding.days_open,
|
||||
"duration_pill": _duration_pill_class(finding.days_open),
|
||||
"deadline": finding.deadline,
|
||||
"action_link": finding.action_link,
|
||||
"existing_reminder_id": finding.existing_reminder_id,
|
||||
"send_count": finding.send_count,
|
||||
}
|
||||
|
||||
|
||||
def _append_event(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
event_type: str,
|
||||
actor: str,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminder_events (reminder_id, event_type, actor, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(reminder_id, event_type, actor, detail, utc_now()),
|
||||
)
|
||||
|
||||
|
||||
def _actor_label(actor: sqlite3.Row | None) -> str:
|
||||
if actor is None:
|
||||
return "system"
|
||||
return str(actor["id"])
|
||||
|
||||
|
||||
def deliver_finding(
|
||||
connection: sqlite3.Connection,
|
||||
dedupe_key: str,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> int | None:
|
||||
findings = {item.dedupe_key: item for item in scan_findings(connection)}
|
||||
finding = findings.get(dedupe_key)
|
||||
if finding is None:
|
||||
return None
|
||||
return _deliver(connection, finding, actor=actor, ip=ip)
|
||||
|
||||
|
||||
def _deliver(
|
||||
connection: sqlite3.Connection,
|
||||
finding: ScanFinding,
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
now = utc_now()
|
||||
actor_ref = _actor_label(actor)
|
||||
existing = connection.execute(
|
||||
"SELECT id, send_count, status FROM reminders WHERE dedupe_key = ?",
|
||||
(finding.dedupe_key,),
|
||||
).fetchone()
|
||||
|
||||
with connection:
|
||||
if existing is not None:
|
||||
reminder_id = int(existing["id"])
|
||||
send_count = int(existing["send_count"]) + 1
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET send_count = ?, last_sent_at = ?, status = 'open',
|
||||
title = ?, content = ?, rule_params = ?, action_link = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
send_count,
|
||||
now,
|
||||
finding.title,
|
||||
finding.content,
|
||||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||||
finding.action_link,
|
||||
reminder_id,
|
||||
),
|
||||
)
|
||||
event_type = "sent" if existing["status"] == "resolved" else (
|
||||
"escalated" if send_count > 1 else "sent"
|
||||
)
|
||||
_append_event(
|
||||
connection,
|
||||
reminder_id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
f"第 {send_count} 次催办",
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||||
created_by, created_at, action_link
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'auto', 'open', 1, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
finding.company_id,
|
||||
finding.rule_key,
|
||||
finding.dedupe_key,
|
||||
json.dumps(finding.rule_params, ensure_ascii=False),
|
||||
finding.title,
|
||||
finding.content,
|
||||
finding.deadline,
|
||||
now,
|
||||
now,
|
||||
actor_ref,
|
||||
now,
|
||||
finding.action_link,
|
||||
),
|
||||
)
|
||||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
_append_event(connection, reminder_id, "sent", actor_ref, finding.reason)
|
||||
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
detail=finding.dedupe_key,
|
||||
ip=ip,
|
||||
)
|
||||
return reminder_id
|
||||
|
||||
|
||||
def deliver_many(
|
||||
connection: sqlite3.Connection,
|
||||
dedupe_keys: list[str],
|
||||
*,
|
||||
actor: sqlite3.Row | None = None,
|
||||
ip: str | None = None,
|
||||
) -> list[int]:
|
||||
sent: list[int] = []
|
||||
for key in dedupe_keys:
|
||||
try:
|
||||
reminder_id = deliver_finding(connection, key, actor=actor, ip=ip)
|
||||
except Exception:
|
||||
continue
|
||||
if reminder_id is not None:
|
||||
sent.append(reminder_id)
|
||||
return sent
|
||||
|
||||
|
||||
def send_manual(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
display_type: str,
|
||||
content: str,
|
||||
deadline: str | None,
|
||||
actor: sqlite3.Row,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
raise ValueError("company not found")
|
||||
now = utc_now()
|
||||
dedupe_key = f"{company_id}:{RULE_MANUAL}:{uuid.uuid4().hex}"
|
||||
title = f"{company['name']} · {display_type}"
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, rule_key, dedupe_key, rule_params, title, content,
|
||||
deadline, source, status, send_count, first_sent_at, last_sent_at,
|
||||
created_by, created_at, action_link
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'manual', 'open', 1, ?, ?, ?, ?, NULL)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
RULE_MANUAL,
|
||||
dedupe_key,
|
||||
json.dumps({"display_type": display_type}, ensure_ascii=False),
|
||||
title,
|
||||
content,
|
||||
deadline,
|
||||
now,
|
||||
now,
|
||||
str(actor["id"]),
|
||||
now,
|
||||
),
|
||||
)
|
||||
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
_append_event(connection, reminder_id, "sent", str(actor["id"]), display_type)
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_send_manual",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
detail=display_type,
|
||||
ip=ip,
|
||||
)
|
||||
return reminder_id
|
||||
|
||||
|
||||
def resend_reminder(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
*,
|
||||
actor: sqlite3.Row,
|
||||
ip: str | None = None,
|
||||
) -> None:
|
||||
row = connection.execute(
|
||||
"SELECT id, send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] == "resolved":
|
||||
raise ValueError("reminder not available")
|
||||
now = utc_now()
|
||||
send_count = int(row["send_count"]) + 1
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET send_count = ?, last_sent_at = ?, status = 'open'
|
||||
WHERE id = ?
|
||||
""",
|
||||
(send_count, now, reminder_id),
|
||||
)
|
||||
_append_event(
|
||||
connection,
|
||||
reminder_id,
|
||||
"escalated",
|
||||
str(actor["id"]),
|
||||
f"第 {send_count} 次催办",
|
||||
)
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_resend",
|
||||
actor=actor,
|
||||
target=f"reminder:{reminder_id}",
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
|
||||
def _reminder_row_to_dict(row: sqlite3.Row, *, company_name: str | None = None) -> dict[str, Any]:
|
||||
params = json.loads(row["rule_params"]) if row["rule_params"] else {}
|
||||
rule_key = row["rule_key"]
|
||||
display_type = params.get("display_type") if rule_key == RULE_MANUAL else RULE_LABELS.get(rule_key, rule_key)
|
||||
status = row["status"]
|
||||
status_ui = {"open": "unread", "acknowledged": "doing", "resolved": "done"}[status]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": company_name or row["company_name"],
|
||||
"rule_key": rule_key,
|
||||
"display_type": display_type,
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"deadline": row["deadline"],
|
||||
"source": row["source"],
|
||||
"status": status,
|
||||
"status_ui": status_ui,
|
||||
"send_count": row["send_count"],
|
||||
"first_sent_at": row["first_sent_at"],
|
||||
"last_sent_at": row["last_sent_at"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"action_link": row["action_link"],
|
||||
"rule_params": params,
|
||||
}
|
||||
|
||||
|
||||
def list_admin_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
conditions = ["r.send_count > 0"]
|
||||
params: list[Any] = []
|
||||
if source in {"auto", "manual"}:
|
||||
conditions.append("r.source = ?")
|
||||
params.append(source)
|
||||
where = " AND ".join(conditions)
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE {where}
|
||||
ORDER BY r.last_sent_at DESC, r.id DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [_reminder_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def list_company_reminders(connection: sqlite3.Connection, company_id: int) -> list[dict[str, Any]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE r.company_id = ? AND r.send_count > 0
|
||||
ORDER BY
|
||||
CASE r.status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
|
||||
r.last_sent_at DESC
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
return [_reminder_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_reminder_detail(connection: sqlite3.Connection, reminder_id: int) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS company_name
|
||||
FROM reminders r
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(reminder_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT event_type, actor, detail, created_at
|
||||
FROM reminder_events
|
||||
WHERE reminder_id = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(reminder_id,),
|
||||
).fetchall()
|
||||
payload = _reminder_row_to_dict(row)
|
||||
payload["events"] = [dict(item) for item in events]
|
||||
return payload
|
||||
|
||||
|
||||
def update_reminder_status(
|
||||
connection: sqlite3.Connection,
|
||||
reminder_id: int,
|
||||
new_status: str,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
actor: sqlite3.Row | None = None,
|
||||
) -> bool:
|
||||
if new_status not in {"acknowledged", "resolved"}:
|
||||
raise ValueError("invalid status")
|
||||
row = connection.execute(
|
||||
"SELECT id, company_id, status FROM reminders WHERE id = ?",
|
||||
(reminder_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
if company_id is not None and int(row["company_id"]) != company_id:
|
||||
return False
|
||||
if row["status"] == "resolved":
|
||||
return False
|
||||
event_type = "acknowledged" if new_status == "acknowledged" else "resolved"
|
||||
actor_ref = _actor_label(actor)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE reminders SET status = ? WHERE id = ?",
|
||||
(new_status, reminder_id),
|
||||
)
|
||||
_append_event(connection, reminder_id, event_type, actor_ref, None)
|
||||
return True
|
||||
|
||||
|
||||
def company_unread_count(connection: sqlite3.Connection, company_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM reminders
|
||||
WHERE company_id = ? AND status = 'open' AND send_count > 0
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
@@ -1,309 +0,0 @@
|
||||
"""System settings and reminder item generation.
|
||||
|
||||
System-wide parameters (closing day, global start date, auto-reminder toggle
|
||||
and lead days) are persisted in ``system_settings`` with an append-only
|
||||
``system_setting_changes`` trail. Reminder pending items are derived from real
|
||||
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
|
||||
rather than hard-coded rosters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import utc_now
|
||||
|
||||
# Defaults are applied when a key is absent; the value type is always string.
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
"closing_day": "5",
|
||||
"start_date": "2026-01-01",
|
||||
"auto_remind": "1",
|
||||
"remind_days": "3",
|
||||
}
|
||||
|
||||
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _valid_date(value: str) -> bool:
|
||||
if not _DATE_RE.match(value):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
rows = connection.execute(
|
||||
"SELECT key, value FROM system_settings"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
|
||||
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
|
||||
cleaned: dict[str, str] = {}
|
||||
if "closing_day" in values:
|
||||
raw = str(values["closing_day"]).strip()
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
if day < 1 or day > 28:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
cleaned["closing_day"] = str(day)
|
||||
if "start_date" in values:
|
||||
raw = str(values["start_date"]).strip()
|
||||
if not _valid_date(raw):
|
||||
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
|
||||
cleaned["start_date"] = raw
|
||||
if "auto_remind" in values:
|
||||
raw = str(values["auto_remind"]).strip()
|
||||
if raw not in {"0", "1"}:
|
||||
return cleaned, "自动提醒开关须为 0 或 1。"
|
||||
cleaned["auto_remind"] = raw
|
||||
if "remind_days" in values:
|
||||
raw = str(values["remind_days"]).strip()
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "提前提醒天数须为不小于 1 的整数。"
|
||||
if days < 1 or days > 30:
|
||||
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
|
||||
cleaned["remind_days"] = str(days)
|
||||
return cleaned, None
|
||||
|
||||
|
||||
def update_settings(
|
||||
connection: sqlite3.Connection,
|
||||
values: dict[str, object],
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, str]:
|
||||
cleaned, error = validate_settings(values)
|
||||
if error is not None:
|
||||
raise ValueError(error)
|
||||
if not cleaned:
|
||||
raise ValueError("没有需要保存的设置项。")
|
||||
current = get_settings(connection)
|
||||
with connection:
|
||||
for key, new_value in cleaned.items():
|
||||
old_value = current.get(key)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, new_value, actor["id"], utc_now()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
old_value,
|
||||
new_value,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Reminder pending items
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _current_period() -> tuple[str, str]:
|
||||
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
|
||||
today = datetime.now(timezone.utc)
|
||||
start = today.strftime("%Y-%m-01")
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
end = f"{year + 1}-01-01"
|
||||
else:
|
||||
end = f"{year}-{month + 1:02d}-01"
|
||||
return start, end
|
||||
|
||||
|
||||
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
|
||||
"""Derive the list of pending reminder items for one company."""
|
||||
items: list[dict[str, str]] = []
|
||||
|
||||
company = connection.execute(
|
||||
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
|
||||
).fetchone()
|
||||
if company is None:
|
||||
return items
|
||||
|
||||
period_start, _ = _current_period()
|
||||
|
||||
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
|
||||
active_accounts = connection.execute(
|
||||
"""
|
||||
SELECT id, account_number FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'active'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchall()
|
||||
confirmed_this_period = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
|
||||
AND rv.sheet_batch_id IN (
|
||||
SELECT id FROM sheet_batches s
|
||||
WHERE s.period_end >= ?
|
||||
)
|
||||
""",
|
||||
(company_id, period_start),
|
||||
).fetchone()["n"]
|
||||
if active_accounts and confirmed_this_period == 0:
|
||||
items.append(
|
||||
{
|
||||
"kind": "流水未提交",
|
||||
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
|
||||
}
|
||||
)
|
||||
|
||||
# 2) 待确认工作表:仍有未确认的解析结果。
|
||||
pending_sheets = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM sheet_reviews rv
|
||||
JOIN import_batches b ON b.id = rv.import_batch_id
|
||||
WHERE b.company_id = ? AND rv.review_status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_sheets:
|
||||
items.append(
|
||||
{
|
||||
"kind": "待确认工作表",
|
||||
"content": f"有 {pending_sheets} 个导入工作表尚未确认,请核对后确认。",
|
||||
}
|
||||
)
|
||||
|
||||
# 3) 待审核账户登记:处于待复核状态的银行账户。
|
||||
pending_accounts = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM bank_accounts
|
||||
WHERE company_id = ? AND status = 'pending'
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_accounts:
|
||||
items.append(
|
||||
{
|
||||
"kind": "账户登记",
|
||||
"content": f"有 {pending_accounts} 个银行账户登记待审核。",
|
||||
}
|
||||
)
|
||||
|
||||
# 4) 待确认往来事项:尚未解决的往来匹配。
|
||||
pending_transfers = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM current_transfer_decisions c
|
||||
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
||||
JOIN transfer_decision_participants p
|
||||
ON p.decision_id = d.id AND p.company_id = ?
|
||||
WHERE d.classification IN ('unresolved', 'needs_review')
|
||||
""",
|
||||
(company_id,),
|
||||
).fetchone()["n"]
|
||||
if pending_transfers:
|
||||
items.append(
|
||||
{
|
||||
"kind": "往来待确认",
|
||||
"content": f"有 {pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def send_reminders(
|
||||
connection: sqlite3.Connection,
|
||||
company_id: int,
|
||||
actor: sqlite3.Row,
|
||||
) -> tuple[list[dict[str, str]], str | None]:
|
||||
"""Create reminder rows for a company's pending items.
|
||||
|
||||
Returns ``(created, deadline)``. ``created`` is the list of persisted
|
||||
reminder payloads; ``deadline`` is derived from the closing-day setting.
|
||||
"""
|
||||
items = pending_items(connection, company_id)
|
||||
if not items:
|
||||
return [], None
|
||||
|
||||
settings = get_settings(connection)
|
||||
try:
|
||||
closing_day = int(settings["closing_day"])
|
||||
except ValueError:
|
||||
closing_day = 5
|
||||
today = datetime.now(timezone.utc)
|
||||
# Deadline: next month's closing day (current month if today is before it).
|
||||
if today.day < closing_day:
|
||||
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
|
||||
else:
|
||||
year, month = today.year, today.month
|
||||
if month == 12:
|
||||
year, month = year + 1, 1
|
||||
else:
|
||||
month += 1
|
||||
deadline = f"{year}-{month:02d}-{closing_day:02d}"
|
||||
|
||||
created: list[dict[str, str]] = []
|
||||
with connection:
|
||||
for item in items:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
company_id, kind, content, deadline, source,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
company_id,
|
||||
item["kind"],
|
||||
item["content"],
|
||||
deadline,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
created.append(
|
||||
{
|
||||
"id": cursor.lastrowid,
|
||||
"company_id": company_id,
|
||||
"kind": item["kind"],
|
||||
"content": item["content"],
|
||||
"deadline": deadline,
|
||||
"source": "manual",
|
||||
"status": "unread",
|
||||
"actor_username": actor["username"],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
)
|
||||
return created, deadline
|
||||
@@ -1,398 +0,0 @@
|
||||
"""Statutory subject suggestions, mirror mapping and confirmation (B-44).
|
||||
|
||||
Subjects are stored from one participating company's perspective and the
|
||||
other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two
|
||||
companies can never record conflicting subjects. Bank summary/purpose text
|
||||
only ever produces a *suggestion*; nothing here confirms a subject
|
||||
automatically. Confirmation is an explicit administrator decision carrying
|
||||
``expected_revision`` and an idempotency key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from . import matching
|
||||
|
||||
SUBJECTS = ("receivable", "payable", "other_receivable", "other_payable")
|
||||
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
|
||||
|
||||
MIRROR = {
|
||||
"receivable": "payable",
|
||||
"payable": "receivable",
|
||||
"other_receivable": "other_payable",
|
||||
"other_payable": "other_receivable",
|
||||
}
|
||||
|
||||
SUBJECT_LABELS = {
|
||||
"receivable": "应收",
|
||||
"payable": "应付",
|
||||
"other_receivable": "其他应收",
|
||||
"other_payable": "其他应付",
|
||||
}
|
||||
|
||||
_FULL_WIDTH = str.maketrans(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
)
|
||||
|
||||
# Draft v1 dictionary. Exact-keyword matching only; every hit is a suggestion
|
||||
# and never an automatic posting. Trade-type keywords are deliberately absent
|
||||
# until the group supplies an approved dictionary (they always go to review).
|
||||
_LOAN_LIKE = ("借款", "往来款", "资金往来", "临时借款", "资金调拨", "代垫", "垫付")
|
||||
_REPAY_LIKE = ("还款", "归还借款", "归还往来款")
|
||||
|
||||
|
||||
class SubjectConflictError(ValueError):
|
||||
"""A stale revision or idempotency conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class SubjectInputError(ValueError):
|
||||
"""Invalid input for a subject decision (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
def mirror_subject(subject_code: str) -> str:
|
||||
if subject_code not in MIRROR:
|
||||
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
return MIRROR[subject_code]
|
||||
|
||||
|
||||
def subject_label(subject_code: str) -> str:
|
||||
return SUBJECT_LABELS.get(subject_code, subject_code)
|
||||
|
||||
|
||||
def _normalize(text: object) -> str:
|
||||
return re.sub(r"[\s\ufeff]+", "", str(text or "").translate(_FULL_WIDTH))
|
||||
|
||||
|
||||
def _bank_evidence_texts(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, str]:
|
||||
"""Purpose/summary text of the B-43 source rows behind a bank event."""
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
|
||||
WHERE bs.ledger_event_id = ?
|
||||
""",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"purpose": "", "summary": ""}
|
||||
decision = matching._current_decision_for_event(connection, row["bank_event_id"])
|
||||
if decision is None:
|
||||
return {"purpose": "", "summary": ""}
|
||||
observations = matching._decision_observations(connection, decision["id"])
|
||||
texts: dict[str, list[str]] = {"purpose": [], "summary": []}
|
||||
for observation in observations:
|
||||
source = connection.execute(
|
||||
"SELECT purpose, summary FROM source_rows WHERE id = ?",
|
||||
(observation["source_row_id"],),
|
||||
).fetchone()
|
||||
if source is None:
|
||||
continue
|
||||
for key in ("purpose", "summary"):
|
||||
value = str(source[key] or "").strip()
|
||||
if value:
|
||||
texts[key].append(value)
|
||||
return {
|
||||
"purpose": " ".join(texts["purpose"]),
|
||||
"summary": " ".join(texts["summary"]),
|
||||
}
|
||||
|
||||
|
||||
def compute_suggestions(
|
||||
connection: sqlite3.Connection, ledger_event_id: int
|
||||
) -> list[dict[str, object]]:
|
||||
""" Deterministic draft suggestions for a pending event, never confirmation.
|
||||
|
||||
Purpose rules take precedence over summary rules. When both loan-like and
|
||||
repay-like keywords match, both candidates are returned as a conflict for
|
||||
the reviewer; no priority breaks the tie.
|
||||
"""
|
||||
from .ledger_events import current_revision
|
||||
|
||||
revision = current_revision(connection, ledger_event_id)
|
||||
if revision is None or revision["state"] != "pending_subject":
|
||||
return []
|
||||
texts = _bank_evidence_texts(connection, ledger_event_id)
|
||||
purpose = _normalize(texts["purpose"])
|
||||
summary = _normalize(texts["summary"])
|
||||
search = purpose or summary
|
||||
payer = revision["payer_company_id"]
|
||||
payee = revision["payee_company_id"]
|
||||
|
||||
loan_hit = next((word for word in _LOAN_LIKE if word in search), None)
|
||||
repay_hit = next((word for word in _REPAY_LIKE if word in search), None)
|
||||
|
||||
suggestions: list[dict[str, object]] = []
|
||||
if loan_hit:
|
||||
suggestions.append(
|
||||
{
|
||||
"suggested_perspective_company_id": payer,
|
||||
"suggested_subject_code": "other_receivable",
|
||||
"reason": f"匹配建议词典「{loan_hit}」,建议付款方其他应收",
|
||||
"rule_version": SUBJECT_RULE_VERSION,
|
||||
"evidence": {
|
||||
"keyword": loan_hit,
|
||||
"matched_text": search,
|
||||
"approved": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
if repay_hit:
|
||||
suggestions.append(
|
||||
{
|
||||
"suggested_perspective_company_id": payee,
|
||||
"suggested_subject_code": "other_receivable",
|
||||
"reason": f"匹配建议词典「{repay_hit}」,建议收款方其他应收",
|
||||
"rule_version": SUBJECT_RULE_VERSION,
|
||||
"evidence": {
|
||||
"keyword": repay_hit,
|
||||
"matched_text": search,
|
||||
"approved": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return suggestions
|
||||
|
||||
|
||||
def store_suggestions(connection: sqlite3.Connection, ledger_event_id: int) -> int:
|
||||
"""Compute and append suggestions for a pending event. Returns count stored."""
|
||||
from .ledger_events import current_revision
|
||||
|
||||
revision = current_revision(connection, ledger_event_id)
|
||||
if revision is None or revision["state"] != "pending_subject":
|
||||
return 0
|
||||
stored = 0
|
||||
for suggestion in compute_suggestions(connection, ledger_event_id):
|
||||
from .db import utc_now
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_subject_suggestions (
|
||||
ledger_event_id, source_revision_id,
|
||||
suggested_perspective_company_id, suggested_subject_code,
|
||||
rule_version, evidence_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ledger_event_id, revision["id"],
|
||||
suggestion["suggested_perspective_company_id"],
|
||||
suggestion["suggested_subject_code"],
|
||||
suggestion["rule_version"],
|
||||
json.dumps(suggestion.get("evidence", {}), ensure_ascii=False),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def confirm_subject(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
perspective_company_id: int,
|
||||
subject_code: str,
|
||||
reason: str,
|
||||
expected_revision: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
"""Confirm a subject, turning a pending event into a confirmed revision."""
|
||||
from .ledger_events import append_revision, current_revision
|
||||
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise SubjectInputError("必须填写科目确认依据。")
|
||||
if subject_code not in SUBJECTS:
|
||||
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"""
|
||||
SELECT * FROM ledger_event_revisions
|
||||
WHERE ledger_event_id = ? AND idempotency_key = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(ledger_event_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, existing)
|
||||
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
if current is None:
|
||||
raise SubjectConflictError("该事件不存在或没有当前修订。")
|
||||
if current["state"] != "pending_subject":
|
||||
raise SubjectConflictError("只有待确认科目的事件可以确认科目。")
|
||||
# ``expected_revision`` may be the revision row id (what the API/UI
|
||||
# sends as ``ledger_revision_id``) or the per-event sequence number;
|
||||
# both identify the exact revision the client saw.
|
||||
if expected_revision is not None and int(expected_revision) not in (
|
||||
current["id"], current["revision"],
|
||||
):
|
||||
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
|
||||
participants = {current["payer_company_id"], current["payee_company_id"]}
|
||||
if perspective_company_id not in participants:
|
||||
raise SubjectInputError("视角公司必须是事件参与方。")
|
||||
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
ledger_event_id,
|
||||
state="confirmed",
|
||||
effective_at=current["effective_at"],
|
||||
amount=current["amount"],
|
||||
currency=current["currency"],
|
||||
payer_company_id=current["payer_company_id"],
|
||||
payee_company_id=current["payee_company_id"],
|
||||
perspective_company_id=perspective_company_id,
|
||||
subject_code=subject_code,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
posting_kind=current["posting_kind"],
|
||||
reverses_ledger_event_id=current["reverses_ledger_event_id"],
|
||||
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
|
||||
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
|
||||
evidence_json=current["evidence_json"],
|
||||
idempotency_key=request_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
supersedes_revision_id=current["id"],
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, row)
|
||||
|
||||
|
||||
def park_subject(
|
||||
connection: sqlite3.Connection,
|
||||
ledger_event_id: int,
|
||||
*,
|
||||
disposition: str,
|
||||
reason: str,
|
||||
expected_revision: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, object]:
|
||||
"""Record 退回/转异常 without confirming a statutory subject.
|
||||
|
||||
The event stays ``pending_subject`` so it never enters confirmed balances.
|
||||
``exception`` is hidden from the active review queue; ``return`` remains
|
||||
visible so the company can supplement materials.
|
||||
"""
|
||||
from .ledger_events import append_revision, current_revision
|
||||
|
||||
if disposition not in ("return", "exception"):
|
||||
raise SubjectInputError("科目处理只能是退回或转异常。")
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise SubjectInputError("必须填写处理依据。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"""
|
||||
SELECT * FROM ledger_event_revisions
|
||||
WHERE ledger_event_id = ? AND idempotency_key = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(ledger_event_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, existing)
|
||||
|
||||
current = current_revision(connection, ledger_event_id)
|
||||
if current is None:
|
||||
raise SubjectConflictError("该事件不存在或没有当前修订。")
|
||||
if current["state"] != "pending_subject":
|
||||
raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。")
|
||||
if expected_revision is not None and int(expected_revision) not in (
|
||||
current["id"], current["revision"],
|
||||
):
|
||||
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
|
||||
evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {}
|
||||
evidence["admin_disposition"] = disposition
|
||||
revision_id = append_revision(
|
||||
connection,
|
||||
ledger_event_id,
|
||||
state="pending_subject",
|
||||
effective_at=current["effective_at"],
|
||||
amount=current["amount"],
|
||||
currency=current["currency"],
|
||||
payer_company_id=current["payer_company_id"],
|
||||
payee_company_id=current["payee_company_id"],
|
||||
perspective_company_id=None,
|
||||
subject_code=None,
|
||||
source_kind=current["source_kind"],
|
||||
source_revision_token=current["source_revision_token"],
|
||||
posting_kind=current["posting_kind"],
|
||||
reverses_ledger_event_id=current["reverses_ledger_event_id"],
|
||||
adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
|
||||
rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
|
||||
evidence_json=json.dumps(evidence, ensure_ascii=False),
|
||||
idempotency_key=request_key,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
supersedes_revision_id=current["id"],
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _revision_payload(connection, row)
|
||||
|
||||
|
||||
def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]:
|
||||
company = connection.execute(
|
||||
"SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],)
|
||||
).fetchone()
|
||||
return {
|
||||
"ledger_event_id": revision["ledger_event_id"],
|
||||
"revision_id": revision["id"],
|
||||
"revision": revision["revision"],
|
||||
"state": revision["state"],
|
||||
"effective_at": revision["effective_at"],
|
||||
"amount": revision["amount"],
|
||||
"currency": revision["currency"],
|
||||
"payer_company_id": revision["payer_company_id"],
|
||||
"payee_company_id": revision["payee_company_id"],
|
||||
"perspective_company_id": revision["perspective_company_id"],
|
||||
"perspective_company_name": company["name"] if company else None,
|
||||
"subject_code": revision["subject_code"],
|
||||
"subject_label": subject_label(revision["subject_code"])
|
||||
if revision["subject_code"]
|
||||
else None,
|
||||
"posting_kind": revision["posting_kind"],
|
||||
"source_kind": revision["source_kind"],
|
||||
"reason": revision["reason"],
|
||||
"created_at": revision["created_at"],
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Shared fixtures for B-44 ledger / manual / position tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from bank_importer import auth, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
class LedgerBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.db_path = root / "app.db"
|
||||
self.connection = connect(self.db_path)
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
self.admin = self._admin()
|
||||
self.company_a = self._company("甲公司")
|
||||
self.company_b = self._company("乙公司")
|
||||
self.company_c = self._company("丙公司")
|
||||
self.account_a = self._approved_account(self.company_a, "6222000000000001")
|
||||
self.account_b = self._approved_account(self.company_b, "6222000000000002")
|
||||
|
||||
def _admin(self):
|
||||
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
|
||||
return self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'admin-u'"
|
||||
).fetchone()
|
||||
|
||||
def _company(self, name: str) -> int:
|
||||
with self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
|
||||
(name, utc_now(), utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
|
||||
account = master_data.submit_bank_account(
|
||||
self.connection, company_id=company_id, bank_name="中信银行",
|
||||
account_type="基本户", account_number=number, start_date=start,
|
||||
actor=None,
|
||||
)
|
||||
return master_data.review_bank_account(
|
||||
self.connection, account["id"], "approve", None, self.admin,
|
||||
effective_from=start,
|
||||
)
|
||||
|
||||
def add_row(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
own_account: str,
|
||||
cp_account: str | None = None,
|
||||
income: str = "0",
|
||||
expense: str = "0",
|
||||
at: str = "2026-01-05T10:00:00",
|
||||
currency: str = "CNY",
|
||||
reference: str | None = None,
|
||||
summary: str | None = None,
|
||||
purpose: str | None = None,
|
||||
) -> int:
|
||||
with self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
|
||||
""",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
|
||||
VALUES (?, 'parsing', ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||||
header_row, own_account, own_name, period_start, period_end,
|
||||
transaction_count, warnings, created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, NULL, NULL, NULL, NULL, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet_batch_id, utc_now()),
|
||||
)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense, balance,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
counterparty_bank, summary, purpose, reference, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, NULL, ?, '测试', ?, '对方', NULL, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
sheet_batch_id, at, income, expense, own_account,
|
||||
cp_account, summary, purpose, reference, currency, utc_now(),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def pair(
|
||||
self,
|
||||
payer: int,
|
||||
payee: int,
|
||||
amount: str,
|
||||
at: str = "2026-01-05T10:00:00",
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
summary: str = "借款",
|
||||
purpose: str = "往来款",
|
||||
) -> tuple[int, int]:
|
||||
"""Create a mirrored A/B pair and reconcile into an eligible event."""
|
||||
payer_account = self._account_of(payer)
|
||||
payee_account = self._account_of(payee)
|
||||
row_payer = self.add_row(
|
||||
payer, own_account=payer_account, cp_account=payee_account,
|
||||
expense=amount, at=at, currency=currency, summary=summary, purpose=purpose,
|
||||
)
|
||||
row_payee = self.add_row(
|
||||
payee, own_account=payee_account, cp_account=payer_account,
|
||||
income=amount, at=at.replace("T10:", "T11:"), currency=currency,
|
||||
summary=summary, purpose=purpose,
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_payer, row_payee])
|
||||
return row_payer, row_payee
|
||||
|
||||
def _account_of(self, company_id: int) -> str:
|
||||
if company_id == self.company_a:
|
||||
return self.account_a["account_number"]
|
||||
if company_id == self.company_b:
|
||||
return self.account_b["account_number"]
|
||||
return "6222000000000005"
|
||||
|
||||
def eligible(self) -> list[sqlite3.Row]:
|
||||
return matching.eligible_intercompany_events(self.connection)
|
||||
|
||||
def ledger_events(self) -> list[sqlite3.Row]:
|
||||
return self.connection.execute(
|
||||
"SELECT * FROM ledger_events ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
def current(self, ledger_event_id: int) -> sqlite3.Row | None:
|
||||
return self.connection.execute(
|
||||
"""
|
||||
SELECT r.* FROM current_ledger_event_revisions c
|
||||
JOIN ledger_event_revisions r ON r.id = c.revision_id
|
||||
WHERE c.ledger_event_id = ?
|
||||
""",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
|
||||
def position_events(self) -> list[sqlite3.Row]:
|
||||
return self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
||||
).fetchall()
|
||||
@@ -1,204 +0,0 @@
|
||||
"""Dashboard aggregate helpers and admin HTTP endpoints (no openpyxl dependency)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import dashboard, master_data
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class DashboardUnitTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
migrate(self.connection)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def test_empty_dashboard(self) -> None:
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(0, payload["audit"]["total"])
|
||||
self.assertEqual([], payload["companies"])
|
||||
self.assertEqual(7, len(payload["weekly_flow"]["labels"]))
|
||||
|
||||
def test_pending_account_counts_as_medium(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
company_id = int(cursor.lastrowid)
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000001",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_company_summaries_from_eligible_events(self) -> None:
|
||||
now = master_data.utc_now()
|
||||
a = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
b = self.connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) "
|
||||
"VALUES ('乙公司', NULL, NULL, 'active', ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
event_id = self.connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_match_decisions (
|
||||
event_id, revision, effective_at, amount, currency, classification,
|
||||
pairing, locked, mode, rule_version, created_at
|
||||
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'intercompany',
|
||||
'paired', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
self.connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO transfer_decision_participants (
|
||||
decision_id, role, company_id, bank_account_id, resolve_method, created_at
|
||||
) VALUES (?, 'payer', ?, NULL, 'manual', ?), (?, 'payee', ?, NULL, 'manual', ?)
|
||||
""",
|
||||
(decision_id, a, now, decision_id, b, now),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
items, totals = dashboard.company_summaries(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, totals["detail_count"])
|
||||
by_name = {row["name"]: row for row in items}
|
||||
self.assertEqual(1, by_name["甲公司"]["detail_count"])
|
||||
self.assertEqual("10.00", by_name["甲公司"]["debit_wan"])
|
||||
self.assertEqual("-10.00", by_name["甲公司"]["net_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["credit_wan"])
|
||||
self.assertEqual("10.00", by_name["乙公司"]["net_wan"])
|
||||
|
||||
detail = dashboard.company_peer_groups(
|
||||
self.connection, int(a), from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(1, len(detail["groups"]))
|
||||
self.assertEqual("乙公司", detail["groups"][0]["peer_name"])
|
||||
self.assertIsNone(detail["groups"][0]["opening"])
|
||||
|
||||
|
||||
class DashboardApiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
status, _, data = cls.admin.post_json(
|
||||
"/api/admin/companies", {"name": "甲公司", "username": "cashier-a"}
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_dashboard_ok_for_admin(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("ok", data["status"])
|
||||
self.assertIn("audit", data)
|
||||
self.assertEqual(1, len(data["companies"]))
|
||||
self.assertEqual("甲公司", data["companies"][0]["name"])
|
||||
|
||||
def test_company_detail_missing(self) -> None:
|
||||
status, _, raw = self.admin.get(
|
||||
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
data = as_json(raw)
|
||||
self.assertEqual(404, status)
|
||||
self.assertEqual("error", data["status"])
|
||||
|
||||
def test_company_detail_ok(self) -> None:
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
data = as_json(raw)
|
||||
company_id = data["companies"][0]["id"]
|
||||
status, _, raw = self.admin.get(
|
||||
f"/api/admin/dashboard/companies/{company_id}?from=2026-01-01&cutoff=2026-08-20"
|
||||
)
|
||||
detail = as_json(raw)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], detail["groups"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,306 +0,0 @@
|
||||
"""B-44 ledger event layer tests: bank reconciliation, revision chain,
|
||||
reversal/adjustment, projection rebuild and immutability boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, matching, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
class BankReconcileTests(LedgerBase):
|
||||
def test_paired_eligible_event_creates_pending_subject_only(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.assertEqual(1, len(self.eligible()))
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["created"])
|
||||
events = self.ledger_events()
|
||||
self.assertEqual(1, len(events))
|
||||
revision = self.current(events[0]["id"])
|
||||
self.assertEqual("pending_subject", revision["state"])
|
||||
self.assertEqual(Decimal("100.00"), Decimal(revision["amount"]))
|
||||
self.assertEqual("CNY", revision["currency"])
|
||||
# Nothing confirmed yet -> no position event.
|
||||
self.assertEqual([], self.position_events())
|
||||
# The event is unresolved as subject_review.
|
||||
from bank_importer import positions
|
||||
unresolved = positions.unresolved_for_company(self.connection, self.company_a, "2026-12-31")
|
||||
self.assertEqual("100.00", unresolved["by_reason"]["subject_review"]["gross_amount"])
|
||||
|
||||
def test_unlocked_single_never_enters_ledger(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [1])
|
||||
self.assertEqual([], self.eligible())
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["created"])
|
||||
self.assertEqual([], self.ledger_events())
|
||||
|
||||
def test_same_company_transfer_never_enters_ledger(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000001", expense="100.00",
|
||||
)
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000001", income="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [1, 2])
|
||||
eligible = self.eligible()
|
||||
self.assertEqual(0, len([e for e in eligible if e["payer_company_id"] == e["payee_company_id"]]))
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["created"])
|
||||
|
||||
def test_reconcile_is_idempotent(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
events = self.ledger_events()
|
||||
self.assertEqual(1, len(events))
|
||||
before_revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
|
||||
).fetchone()["n"]
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["unchanged"])
|
||||
after_revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(before_revisions, after_revisions)
|
||||
|
||||
def test_reconcile_picks_up_newly_linked_rows_as_new_pending_event(self) -> None:
|
||||
# A single observation locked as intercompany -> pending ledger event.
|
||||
row_a = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a])
|
||||
single = self.connection.execute(
|
||||
"SELECT * FROM current_transfer_decisions"
|
||||
).fetchone()
|
||||
event_id = single["event_id"]
|
||||
decision = self.connection.execute(
|
||||
"SELECT * FROM transfer_match_decisions WHERE id = ?",
|
||||
(single["decision_id"],),
|
||||
).fetchone()
|
||||
matching.apply_manual_decision(
|
||||
self.connection, event_id, "assign_participant",
|
||||
reason="函证确认对方",
|
||||
expected_revision=decision["revision"], request_key="assign-1",
|
||||
actor=self.admin,
|
||||
participant={"role": "payee", "company_id": self.company_b},
|
||||
)
|
||||
self.assertEqual(1, len(self.eligible()))
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, len(self.ledger_events()))
|
||||
|
||||
# The administrator links two fresh rows with a different amount onto a
|
||||
# new canonical event; reconcile creates a second pending event.
|
||||
row_a2 = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="150.00",
|
||||
at="2026-02-01T10:00:00",
|
||||
)
|
||||
row_b2 = self.add_row(
|
||||
self.company_b, own_account="6222000000000002",
|
||||
cp_account="6222000000000001", income="150.00",
|
||||
at="2026-02-01T11:00:00",
|
||||
)
|
||||
current_row = self.connection.execute(
|
||||
"SELECT d.* FROM current_transfer_decisions c "
|
||||
"JOIN transfer_match_decisions d ON d.id = c.decision_id "
|
||||
"WHERE c.event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
matching.apply_manual_decision(
|
||||
self.connection, event_id, "link_rows",
|
||||
reason="补录双边流水",
|
||||
expected_revision=current_row["revision"], request_key="link-1",
|
||||
actor=self.admin, source_row_ids=[row_a2, row_b2],
|
||||
)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
amounts = sorted(
|
||||
row["amount"] for row in self.connection.execute(
|
||||
"SELECT amount FROM ledger_event_revisions WHERE state = 'pending_subject'"
|
||||
).fetchall()
|
||||
)
|
||||
self.assertEqual(["100.00", "150.00"], amounts)
|
||||
|
||||
def test_confirm_subject_enters_position_view(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款建议其他应收", expected_revision=1, request_key="k1",
|
||||
actor=self.admin,
|
||||
)
|
||||
positions = self.position_events()
|
||||
self.assertEqual(1, len(positions))
|
||||
self.assertEqual("other_receivable", positions[0]["subject_code"])
|
||||
self.assertEqual(self.company_a, positions[0]["perspective_company_id"])
|
||||
|
||||
def test_confirm_stale_revision_conflicts(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认应收", expected_revision=1, request_key="k1",
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(subjects.SubjectConflictError):
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="重复确认", expected_revision=1, request_key="k2",
|
||||
actor=self.admin,
|
||||
)
|
||||
|
||||
def test_reversal_mirrors_direction_and_subject(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
reversal_id, _ = ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
reason="冲销错误确认", actor=self.admin,
|
||||
)
|
||||
reversal = self.current(reversal_id)
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(self.company_b, reversal["payer_company_id"])
|
||||
self.assertEqual(self.company_a, reversal["payee_company_id"])
|
||||
self.assertEqual("other_payable", reversal["subject_code"])
|
||||
self.assertEqual(self.company_b, reversal["perspective_company_id"])
|
||||
# Original event stays confirmed and still in the position view.
|
||||
self.assertEqual("confirmed", self.current(event_id)["state"])
|
||||
positions = self.position_events()
|
||||
self.assertEqual(2, len(positions))
|
||||
signed = sum(
|
||||
Decimal(position["amount"]) * (
|
||||
1 if position["payer_company_id"] == self.company_a else -1
|
||||
)
|
||||
for position in positions
|
||||
)
|
||||
self.assertEqual(Decimal("0"), signed)
|
||||
|
||||
def test_evidence_rows_stay_immutable_under_reversal(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
before = self.connection.execute(
|
||||
"SELECT income, expense FROM source_rows ORDER BY id"
|
||||
).fetchall()
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
reason="冲销", actor=self.admin,
|
||||
)
|
||||
after = self.connection.execute(
|
||||
"SELECT income, expense FROM source_rows ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_reversal_idempotent_guard(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
# Simulate the B-43 event leaving the eligible set.
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
|
||||
(self.eligible()[0]["event_id"],),
|
||||
)
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["reversal"])
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, reversals)
|
||||
# Re-run does not create a second reversal.
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["reversal"])
|
||||
|
||||
|
||||
class ProjectionTests(LedgerBase):
|
||||
def test_rebuild_current_projection(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
self.connection.execute("DELETE FROM current_ledger_event_revisions")
|
||||
rebuilt = ledger_events.rebuild_current_ledger_projection(self.connection)
|
||||
self.assertGreaterEqual(rebuilt, 1)
|
||||
revision = self.current(event_id)
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
self.assertEqual("receivable", revision["subject_code"])
|
||||
|
||||
|
||||
class SubjectSuggestionTests(LedgerBase):
|
||||
def test_mirror_mapping_is_symmetric(self) -> None:
|
||||
for subject, mirror in (
|
||||
("receivable", "payable"), ("payable", "receivable"),
|
||||
("other_receivable", "other_payable"), ("other_payable", "other_receivable"),
|
||||
):
|
||||
self.assertEqual(mirror, subjects.mirror_subject(subject))
|
||||
self.assertEqual(subject, subjects.mirror_subject(mirror))
|
||||
|
||||
def test_loan_keyword_suggests_payer_other_receivable(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="借款", purpose="往来款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
suggestions = subjects.compute_suggestions(self.connection, event_id)
|
||||
self.assertEqual(1, len(suggestions))
|
||||
self.assertEqual(self.company_a, suggestions[0]["suggested_perspective_company_id"])
|
||||
self.assertEqual("other_receivable", suggestions[0]["suggested_subject_code"])
|
||||
self.assertFalse(suggestions[0]["evidence"]["approved"])
|
||||
|
||||
def test_repay_keyword_suggests_payee(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="归还往来款", purpose="还款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
suggestions = subjects.compute_suggestions(self.connection, event_id)
|
||||
self.assertEqual(1, len(suggestions))
|
||||
self.assertEqual(self.company_b, suggestions[0]["suggested_perspective_company_id"])
|
||||
|
||||
def test_trade_keyword_never_suggests(self) -> None:
|
||||
# Trade vocabulary needs group approval; no dictionary hit -> review.
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="货款", purpose="采购货款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
|
||||
|
||||
def test_name_alone_never_confirms(self) -> None:
|
||||
# A name match on the counterparty cannot decide the subject.
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="某客户往来", purpose=None)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
current = self.current(event_id)
|
||||
self.assertEqual("pending_subject", current["state"])
|
||||
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,607 +0,0 @@
|
||||
"""B-44 manual record tests: submit idempotency, approve new/link, return,
|
||||
exception, reverse, deduplication, idempotent replay and concurrency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, manual_records, matching, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
class SubmitTests(LedgerBase):
|
||||
def submit(self, **overrides):
|
||||
params = dict(
|
||||
company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00",
|
||||
direction="incoming",
|
||||
amount="50.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="other_receivable",
|
||||
request_key="mr-key",
|
||||
actor=self.admin,
|
||||
summary="归还往来款",
|
||||
)
|
||||
params.update(overrides)
|
||||
return manual_records.submit(self.connection, **params)
|
||||
|
||||
def test_submit_creates_pending_decision(self) -> None:
|
||||
payload = self.submit()
|
||||
self.assertEqual("pending", payload["state"])
|
||||
self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
|
||||
self.assertEqual(1, payload["decision_revision"])
|
||||
self.assertEqual([], payload["candidates"])
|
||||
|
||||
def test_submit_idempotent_on_request_key(self) -> None:
|
||||
first = self.submit()
|
||||
second = self.submit()
|
||||
self.assertEqual(first["id"], second["id"])
|
||||
self.assertTrue(second["idempotent_replay"])
|
||||
decisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM manual_record_decisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, decisions)
|
||||
|
||||
def test_submit_validations(self) -> None:
|
||||
cases = (
|
||||
({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
|
||||
({"amount": "0"}, "必须大于零"),
|
||||
({"amount": "abc"}, "十进制"),
|
||||
({"funding_source": "credit_card"}, "资金来源"),
|
||||
({"requested_subject": "equity"}, "科目"),
|
||||
({"request_key": ""}, "request_key"),
|
||||
({"counterparty_company_id": 9999}, "公司不存在"),
|
||||
)
|
||||
for overrides, expected in cases:
|
||||
with self.subTest(overrides=overrides):
|
||||
with self.assertRaises(manual_records.ManualInputError) as ctx:
|
||||
self.submit(**overrides)
|
||||
self.assertIn(expected, str(ctx.exception))
|
||||
|
||||
def test_bank_account_must_belong_to_submitting_company(self) -> None:
|
||||
with self.assertRaises(manual_records.ManualInputError):
|
||||
self.submit(
|
||||
funding_source="approved_bank_account",
|
||||
bank_account_id=self.account_b["id"],
|
||||
)
|
||||
|
||||
def test_related_source_row_never_modified(self) -> None:
|
||||
row_id = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
payload = self.submit(related_source_row_id=row_id)
|
||||
self.assertEqual(row_id, payload["related_source_row_id"])
|
||||
with self.assertRaises(Exception):
|
||||
self.connection.execute(
|
||||
"UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
|
||||
)
|
||||
self.connection.rollback()
|
||||
|
||||
|
||||
class DecisionTests(LedgerBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.record = manual_records.submit(
|
||||
self.connection,
|
||||
company_id=self.company_a, counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_receivable", request_key="mr-1",
|
||||
actor=self.admin, summary="归还往来款",
|
||||
)
|
||||
|
||||
def decide(self, action, **overrides):
|
||||
params = dict(
|
||||
record_id=self.record["id"],
|
||||
action=action,
|
||||
reason="管理员审核",
|
||||
expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
params.update(overrides)
|
||||
return manual_records.decide(self.connection, **params)
|
||||
|
||||
def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
|
||||
outcome = self.decide("approve_new")
|
||||
self.assertEqual("approved", outcome["state"])
|
||||
event_id = outcome["ledger_event_id"]
|
||||
revision = ledger_events.current_revision(self.connection, event_id)
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
# incoming 50 from A's perspective: B is the payer, A the payee.
|
||||
self.assertEqual(self.company_b, revision["payer_company_id"])
|
||||
self.assertEqual(self.company_a, revision["payee_company_id"])
|
||||
self.assertEqual(self.company_a, revision["perspective_company_id"])
|
||||
self.assertEqual("other_receivable", revision["subject_code"])
|
||||
# Exactly one position impact.
|
||||
positions = self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events"
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(positions))
|
||||
|
||||
def test_approve_new_replay_is_idempotent(self) -> None:
|
||||
first = self.decide("approve_new")
|
||||
second = self.decide("approve_new")
|
||||
self.assertEqual(first["decision_id"], second["decision_id"])
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_events"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, events)
|
||||
|
||||
def test_return_and_exception_never_produce_balance(self) -> None:
|
||||
for action in ("return", "exception"):
|
||||
with self.subTest(action=action):
|
||||
fresh = manual_records.submit(
|
||||
self.connection,
|
||||
company_id=self.company_a, counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-11T09:00:00", direction="incoming",
|
||||
amount="10.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key=f"mr-{action}",
|
||||
actor=self.admin,
|
||||
)
|
||||
outcome = manual_records.decide(
|
||||
self.connection, fresh["id"], action,
|
||||
reason="材料不足" if action == "return" else "转入异常",
|
||||
expected_decision_id=fresh["decision_id"],
|
||||
request_key=f"dec-{action}", actor=self.admin,
|
||||
)
|
||||
expected_state = "returned" if action == "return" else "exception"
|
||||
self.assertEqual(expected_state, outcome["state"])
|
||||
self.assertEqual(0, self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM eligible_position_events"
|
||||
).fetchone()["n"])
|
||||
|
||||
def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
|
||||
approved = self.decide("approve_new")
|
||||
reversed_outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录,冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev", actor=self.admin,
|
||||
)
|
||||
self.assertEqual("reversed", reversed_outcome["state"])
|
||||
rows = self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(rows))
|
||||
original, reversal = rows
|
||||
self.assertEqual("normal", original["posting_kind"])
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
|
||||
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
|
||||
# Net position is zero.
|
||||
signed = Decimal(original["amount"]) * (
|
||||
1 if original["payer_company_id"] == self.company_a else -1
|
||||
) + Decimal(reversal["amount"]) * (
|
||||
1 if reversal["payer_company_id"] == self.company_a else -1
|
||||
)
|
||||
self.assertEqual(Decimal("0"), signed)
|
||||
|
||||
def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
|
||||
# Build an eligible bank event and confirm its subject.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
bank_ledger_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, bank_ledger_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="subj-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
before = len(self.position_events())
|
||||
outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "approve_link",
|
||||
reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-link", actor=self.admin,
|
||||
target_ledger_event_id=bank_ledger_id,
|
||||
)
|
||||
self.assertEqual("approved", outcome["state"])
|
||||
self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
|
||||
after = len(self.position_events())
|
||||
self.assertEqual(before, after)
|
||||
sources = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(self.record["id"],),
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(sources))
|
||||
self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])
|
||||
|
||||
def test_approve_link_requires_target(self) -> None:
|
||||
with self.assertRaises(manual_records.ManualInputError):
|
||||
self.decide("approve_link")
|
||||
|
||||
def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
bank_ledger_id = self.ledger_events()[0]["id"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, bank_ledger_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="subj-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
approved = manual_records.decide(
|
||||
self.connection, self.record["id"], "approve_link",
|
||||
reason="同源", expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-link", actor=self.admin,
|
||||
target_ledger_event_id=bank_ledger_id,
|
||||
)
|
||||
reversed_outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="撤销关联", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-unlink", actor=self.admin,
|
||||
)
|
||||
self.assertEqual("reversed", reversed_outcome["state"])
|
||||
# No reversal event is created for a linked claim.
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(0, reversals)
|
||||
# The bank impact is untouched.
|
||||
self.assertEqual(1, len(self.position_events()))
|
||||
|
||||
def test_stale_expected_decision_conflicts(self) -> None:
|
||||
approved = self.decide("approve_new")
|
||||
with self.assertRaises(manual_records.ManualConflictError):
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="冲销", expected_decision_id=self.record["decision_id"],
|
||||
request_key="dec-stale", actor=self.admin,
|
||||
)
|
||||
|
||||
def test_returned_record_cannot_be_reversed(self) -> None:
|
||||
returned = self.decide("return", reason="材料不足")
|
||||
with self.assertRaises(manual_records.ManualConflictError):
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="冲销", expected_decision_id=returned["decision_id"],
|
||||
request_key="dec-x", actor=self.admin,
|
||||
)
|
||||
|
||||
def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None:
|
||||
# Reversing with an independent effective date must keep the original
|
||||
# impact for cutoffs before it and net it to zero only on/after it —
|
||||
# never rewrite the historical period retroactively.
|
||||
from bank_importer import positions
|
||||
approved = self.decide("approve_new")
|
||||
event_id = approved["ledger_event_id"]
|
||||
self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10])
|
||||
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-date", actor=self.admin,
|
||||
effective_at="2026-06-15",
|
||||
)
|
||||
reversal = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()
|
||||
self.assertEqual("2026-06-15", reversal["effective_at"][:10])
|
||||
|
||||
before = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-05-31"
|
||||
)
|
||||
after = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in before["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual("-50.00", item["result"]["signed_amount"])
|
||||
for item in after["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual("0.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_reverse_defaults_to_approval_business_day(self) -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from bank_importer import positions
|
||||
approved = self.decide("approve_new")
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-default", actor=self.admin,
|
||||
)
|
||||
today = datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
||||
reversal = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()
|
||||
self.assertEqual(today, reversal["effective_at"][:10])
|
||||
|
||||
# The day before the approval business day keeps the original impact.
|
||||
before = positions.company_balances(
|
||||
self.connection, from_="2026-01-01",
|
||||
cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(),
|
||||
)
|
||||
for item in before["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual("-50.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None:
|
||||
# approve_new created the event; a second manual record later links
|
||||
# onto it. Reversing the creation source must still create an
|
||||
# equal-amount reversal — the original economic impact must not survive
|
||||
# just because other evidence was attached later.
|
||||
approved = self.decide("approve_new")
|
||||
event_id = approved["ledger_event_id"]
|
||||
|
||||
linked = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-12T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-link-ev",
|
||||
actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, linked["id"], "approve_link",
|
||||
reason="同源补充证据", expected_decision_id=linked["decision_id"],
|
||||
request_key="dec-link-ev", actor=self.admin,
|
||||
target_ledger_event_id=event_id,
|
||||
)
|
||||
self.assertEqual(1, len(self.position_events()))
|
||||
|
||||
reversed_outcome = manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-create", actor=self.admin,
|
||||
)
|
||||
self.assertEqual("reversed", reversed_outcome["state"])
|
||||
rows = self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(rows))
|
||||
original, reversal = rows
|
||||
self.assertEqual("normal", original["posting_kind"])
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
|
||||
# The later linked evidence stays attached to the event.
|
||||
links = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(links))
|
||||
|
||||
def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None:
|
||||
# M1 creates the event, M2 links; after M1's reversal created the
|
||||
# offset, reversing the linked M2 must only detach, never add a second
|
||||
# reversal event.
|
||||
approved = self.decide("approve_new")
|
||||
event_id = approved["ledger_event_id"]
|
||||
linked = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-12T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-link-ev2",
|
||||
actor=self.admin,
|
||||
)
|
||||
linked_approved = manual_records.decide(
|
||||
self.connection, linked["id"], "approve_link",
|
||||
reason="同源补充证据", expected_decision_id=linked["decision_id"],
|
||||
request_key="dec-link-ev2", actor=self.admin,
|
||||
target_ledger_event_id=event_id,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, self.record["id"], "reverse",
|
||||
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
||||
request_key="dec-rev-create2", actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, linked["id"], "reverse",
|
||||
reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"],
|
||||
request_key="dec-rev-link2", actor=self.admin,
|
||||
)
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, reversals)
|
||||
links = self.connection.execute(
|
||||
"SELECT * FROM ledger_event_manual_sources"
|
||||
).fetchall()
|
||||
self.assertEqual(1, len(links))
|
||||
|
||||
|
||||
class DedupAndConcurrencyTests(LedgerBase):
|
||||
def test_duplicate_manual_record_pairing_candidate(self) -> None:
|
||||
manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="outgoing",
|
||||
amount="40.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_receivable", request_key="mr-a",
|
||||
actor=self.admin,
|
||||
)
|
||||
other = manual_records.submit(
|
||||
self.connection, company_id=self.company_b,
|
||||
counterparty_company_id=self.company_a,
|
||||
occurred_at="2026-01-10T10:00:00", direction="incoming",
|
||||
amount="40.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_payable", request_key="mr-b",
|
||||
actor=self.admin,
|
||||
)
|
||||
candidates = manual_records.find_candidates(self.connection, other["id"])
|
||||
self.assertEqual(1, len(candidates))
|
||||
self.assertEqual("manual_record", candidates[0]["kind"])
|
||||
|
||||
def test_duplicate_bank_event_candidate_hints_link(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-05T12:00:00", direction="incoming",
|
||||
amount="100.00", currency="CNY", funding_source="other",
|
||||
requested_subject="other_receivable", request_key="mr-c",
|
||||
actor=self.admin,
|
||||
)
|
||||
candidates = manual_records.find_candidates(self.connection, record["id"])
|
||||
self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))
|
||||
|
||||
def test_concurrent_approve_new_counts_once(self) -> None:
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-conc",
|
||||
actor=self.admin,
|
||||
)
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker(thread_key: str) -> None:
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
manual_records.decide(
|
||||
connection, record["id"], "approve_new",
|
||||
reason="并发审批", expected_decision_id=record["decision_id"],
|
||||
request_key=f"dec-{thread_key}", actor=self.admin,
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Two connections can still double-commit, so the UNIQUE manual source
|
||||
# claim must make the second write fail or be a no-op; either way only
|
||||
# one ledger event may exist.
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_events"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, events)
|
||||
|
||||
def test_concurrent_submit_same_request_key_is_idempotent(self) -> None:
|
||||
import sqlite3
|
||||
|
||||
results: list[dict] = []
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker(thread_key: str) -> None:
|
||||
try:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
payload = manual_records.submit(
|
||||
connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="mr-conc-submit", actor=self.admin,
|
||||
)
|
||||
results.append(payload)
|
||||
finally:
|
||||
connection.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertEqual([], errors)
|
||||
rows = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM manual_records"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, rows)
|
||||
self.assertEqual(1, len({payload["id"] for payload in results}))
|
||||
decisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM manual_record_decisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, decisions)
|
||||
|
||||
def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None:
|
||||
import sqlite3
|
||||
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
from bank_importer import ledger_events
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
|
||||
outcomes: list[dict] = []
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker(thread_key: str) -> None:
|
||||
try:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
payload = subjects.confirm_subject(
|
||||
connection, event_id,
|
||||
perspective_company_id=self.company_a,
|
||||
subject_code="other_receivable", reason="并发确认",
|
||||
expected_revision=1, request_key="conc-subj-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
outcomes.append(payload)
|
||||
finally:
|
||||
connection.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertEqual([], errors)
|
||||
confirmed = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, confirmed)
|
||||
self.assertEqual(1, len({payload["revision_id"] for payload in outcomes}))
|
||||
|
||||
def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
from bank_importer import ledger_events
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
first = subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="确认", expected_revision=1, request_key="noise-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
second = subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="重复确认", expected_revision=1, request_key="noise-key",
|
||||
actor=self.admin,
|
||||
)
|
||||
self.assertEqual(first["revision_id"], second["revision_id"])
|
||||
revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()["n"]
|
||||
# One pending_subject + one confirmed; the replay appended nothing.
|
||||
self.assertEqual(2, revisions)
|
||||
confirmed = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions "
|
||||
"WHERE ledger_event_id = ? AND state = 'confirmed'",
|
||||
(event_id,),
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, confirmed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+7
-107
@@ -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], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -73,31 +73,22 @@ class MigrationTests(PersistenceTestCase):
|
||||
"transfer_match_candidates",
|
||||
"current_transfer_decisions",
|
||||
"transfer_observation_claims",
|
||||
"manual_records",
|
||||
"manual_record_decisions",
|
||||
"current_manual_record_decisions",
|
||||
"ledger_events",
|
||||
"ledger_event_revisions",
|
||||
"current_ledger_event_revisions",
|
||||
"ledger_event_bank_sources",
|
||||
"ledger_event_manual_sources",
|
||||
"ledger_subject_suggestions",
|
||||
"system_settings",
|
||||
"system_setting_changes",
|
||||
"reminder_settings",
|
||||
"reminders",
|
||||
"reminder_events",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([], applied_versions(self.connection))
|
||||
remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -105,7 +96,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
|
||||
self.assertEqual([6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
@@ -121,10 +112,6 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
ledger_remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
|
||||
).fetchone()
|
||||
self.assertIsNone(ledger_remaining)
|
||||
|
||||
def test_event_layer_views_exist_after_migration(self) -> None:
|
||||
view = self.connection.execute(
|
||||
@@ -132,93 +119,6 @@ class MigrationTests(PersistenceTestCase):
|
||||
).fetchone()
|
||||
self.assertIsNotNone(view)
|
||||
|
||||
def test_ledger_layer_views_exist_after_migration(self) -> None:
|
||||
view = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'view' AND name = 'eligible_position_events'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(view)
|
||||
table = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(table)
|
||||
|
||||
def test_ledger_revision_log_is_immutable(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
|
||||
]
|
||||
self.connection.execute(
|
||||
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(utc_now(),),
|
||||
)
|
||||
event_id = self.connection.execute(
|
||||
"SELECT id FROM ledger_events LIMIT 1"
|
||||
).fetchone()["id"]
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_revisions (
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale, currency, payer_company_id, payee_company_id,
|
||||
source_kind, posting_kind, created_at
|
||||
) VALUES (?, 1, 'pending_subject', '2026-01-01T00:00:00', '1.00', 2,
|
||||
'CNY', ?, ?, 'bank', 'normal', ?)
|
||||
""",
|
||||
(event_id, company_a, company_b, utc_now()),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE ledger_event_revisions SET amount = '9.99'",
|
||||
"DELETE FROM ledger_event_revisions",
|
||||
"UPDATE ledger_events SET created_at = '2020-01-01T00:00:00'",
|
||||
"DELETE FROM ledger_events",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_manual_record_facts_are_immutable(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
|
||||
]
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_records (
|
||||
company_id, counterparty_company_id, occurred_at, direction,
|
||||
amount, amount_scale, currency, funding_source, requested_subject,
|
||||
request_key, created_at
|
||||
) VALUES (?, ?, '2026-01-01T00:00:00', 'incoming', '1.00', 2,
|
||||
'CNY', 'other', 'receivable', 'k1', ?)
|
||||
""",
|
||||
(company_a, company_b, utc_now()),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE manual_records SET amount = '9.99'",
|
||||
"DELETE FROM manual_records",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_decision_log_immutability_triggers(self) -> None:
|
||||
self.import_sample()
|
||||
source_row_id = self.connection.execute(
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
"""B-44 position aggregation tests: conservation, repayments, reversals across
|
||||
cutoffs, currency isolation, Decimal precision, unresolved gross, normal
|
||||
balance exceptions and keyset pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, getcontext
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, manual_records, positions, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
def confirm(connection, event_id: int, perspective: int, subject: str, key: str, actor):
|
||||
return subjects.confirm_subject(
|
||||
connection, event_id,
|
||||
perspective_company_id=perspective, subject_code=subject,
|
||||
reason="审核确认", expected_revision=1, request_key=key, actor=actor,
|
||||
)
|
||||
|
||||
|
||||
class ConservationTests(LedgerBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
getcontext().prec = 50
|
||||
|
||||
def _confirm_all(self, subject: str = "other_receivable"):
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(
|
||||
self.connection, event["id"],
|
||||
revision["payer_company_id"], subject, f"subj-{event['id']}",
|
||||
self.admin,
|
||||
)
|
||||
|
||||
def test_both_perspectives_mirror_exactly(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_a, self.company_b, "30.00", at="2026-01-10T10:00:00")
|
||||
self.pair(self.company_a, self.company_b, "70.00", at="2026-02-05T10:00:00")
|
||||
self._confirm_all()
|
||||
pair = positions.pair_detail(
|
||||
self.connection, self.company_a, self.company_b,
|
||||
from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
item = pair["items"][0]
|
||||
self.assertTrue(item["conservation"]["opposite"])
|
||||
self.assertTrue(item["conservation"]["abs_equal"])
|
||||
self.assertEqual(Decimal("200.00"), Decimal(item["a"]["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("-200.00"), Decimal(item["b"]["result"]["signed_amount"]))
|
||||
|
||||
def test_repayment_reduces_the_original_balance(self) -> None:
|
||||
# A lends B 100 (A pays) then B repays 40 (B pays).
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_b, self.company_a, "40.00", at="2026-03-01T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
self._confirm_all()
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("60.00"), Decimal(item["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["debit"]))
|
||||
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["credit"]))
|
||||
else:
|
||||
self.assertEqual(Decimal("-60.00"), Decimal(item["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["debit"]))
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["credit"]))
|
||||
|
||||
def test_over_repayment_flags_normal_balance_exception(self) -> None:
|
||||
# A lends B 100, then B repays 150 while A books the repayment against
|
||||
# its receivable: A's receivable and B's payable both go negative.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_b, self.company_a, "150.00", at="2026-03-01T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
# Book every event from A's perspective on the receivable side so
|
||||
# the over-repayment produces abnormal balances.
|
||||
confirm(
|
||||
self.connection, event["id"], self.company_a,
|
||||
"other_receivable", f"k-{event['id']}", self.admin,
|
||||
)
|
||||
pair = positions.pair_detail(
|
||||
self.connection, self.company_a, self.company_b,
|
||||
from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
item = pair["items"][0]
|
||||
self.assertTrue(item["normal_balance_exception"])
|
||||
self.assertIn("other_receivable", item["normal_balance_exception"])
|
||||
self.assertIn("other_payable", item["normal_balance_exception"])
|
||||
|
||||
|
||||
class CutoffAndReversalTests(LedgerBase):
|
||||
def _loan(self) -> int:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "other_receivable", "k1", self.admin)
|
||||
return event_id
|
||||
|
||||
def test_cutoff_before_reversal_keeps_original_impact(self) -> None:
|
||||
event_id = self._loan()
|
||||
# Reversal dated after the original event.
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
effective_at="2026-06-15T00:00:00", reason="冲销", actor=self.admin,
|
||||
)
|
||||
before = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-05-31"
|
||||
)
|
||||
after = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in before["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
|
||||
for item in after["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("0"), Decimal(item["result"]["signed_amount"]))
|
||||
|
||||
def test_from_gt_cutoff_is_rejected(self) -> None:
|
||||
with self.assertRaises(positions.PositionInputError):
|
||||
positions.validate_window("2026-08-01", "2026-07-31")
|
||||
|
||||
def test_events_before_from_are_excluded_from_period(self) -> None:
|
||||
self._loan()
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-06-01", cutoff="2026-07-31"
|
||||
)
|
||||
self.assertEqual([], balances["items"])
|
||||
|
||||
def test_reversal_uses_approved_effective_date(self) -> None:
|
||||
event_id = self._loan()
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
effective_at="2026-08-01T00:00:00", reason="未来冲销", actor=self.admin,
|
||||
)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
# The future reversal does not rewrite the earlier cutoff.
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
|
||||
|
||||
|
||||
class CurrencyAndPrecisionTests(LedgerBase):
|
||||
def test_currencies_never_mix(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", currency="CNY")
|
||||
self.pair(self.company_a, self.company_b, "50.00", at="2026-01-20T10:00:00",
|
||||
currency="USD")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
events = self.ledger_events()
|
||||
for event in events:
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
by_company = {}
|
||||
for item in balances["items"]:
|
||||
by_company.setdefault(item["company_id"], {})[item["currency"]] = item["result"]
|
||||
self.assertEqual(Decimal("100.00"), Decimal(by_company[self.company_a]["CNY"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("50.00"), Decimal(by_company[self.company_a]["USD"]["signed_amount"]))
|
||||
|
||||
def test_decimal_precision_is_preserved(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "0.01")
|
||||
self.pair(self.company_a, self.company_b, "12345.6789", at="2026-01-11T10:00:00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(
|
||||
Decimal("12345.6889"), Decimal(item["result"]["signed_amount"])
|
||||
)
|
||||
# The stored string keeps every decimal place.
|
||||
self.assertIn("12345.6889", item["result"]["signed_amount"])
|
||||
|
||||
def test_manual_record_preserves_amount_scale(self) -> None:
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-02-01T09:00:00", direction="incoming",
|
||||
amount="12.3400", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-prec",
|
||||
actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, record["id"], "approve_new",
|
||||
reason="确认", expected_decision_id=record["decision_id"],
|
||||
request_key="dec-prec", actor=self.admin,
|
||||
)
|
||||
stored = self.connection.execute(
|
||||
"SELECT amount, amount_scale FROM eligible_position_events"
|
||||
).fetchone()
|
||||
self.assertEqual("12.3400", stored["amount"])
|
||||
self.assertEqual(4, stored["amount_scale"])
|
||||
|
||||
|
||||
class UnresolvedTests(LedgerBase):
|
||||
def test_unresolved_gross_never_nets(self) -> None:
|
||||
# Two pending_subject events of opposite economic signs must sum their
|
||||
# absolute values, never cancel.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_a, self.company_b, "40.00", at="2026-01-20T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(2, unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("140.00"), Decimal(unresolved["gross_amount"])
|
||||
)
|
||||
self.assertEqual(
|
||||
Decimal("140.00"),
|
||||
Decimal(unresolved["by_reason"]["subject_review"]["gross_amount"]),
|
||||
)
|
||||
|
||||
def test_unmatched_single_reason_bucket(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="88.00",
|
||||
)
|
||||
from bank_importer import matching
|
||||
matching.reconcile_rows(self.connection, [1])
|
||||
unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(1, unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("88.00"),
|
||||
Decimal(unresolved["by_reason"]["unmatched_single"]["gross_amount"]),
|
||||
)
|
||||
|
||||
def test_pending_manual_only_counts_for_submitting_company(self) -> None:
|
||||
manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-a",
|
||||
actor=self.admin,
|
||||
)
|
||||
a_unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
b_unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_b, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(1, a_unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("50.00"), Decimal(a_unresolved["by_reason"]["manual_pending"]["gross_amount"])
|
||||
)
|
||||
# The counterparty never sees the unapproved declaration.
|
||||
self.assertEqual(0, b_unresolved["count"])
|
||||
|
||||
|
||||
class PaginationTests(LedgerBase):
|
||||
def test_subject_filter_matches_stored_and_mirror_subjects(self) -> None:
|
||||
# A books "receivable" from its own perspective; B sees the mirror
|
||||
# "payable". Filtering by B's mirror subject must still find the event.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "receivable", "k-mirror",
|
||||
self.admin)
|
||||
|
||||
stored = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="receivable",
|
||||
)
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == event_id for item in stored["items"]
|
||||
))
|
||||
# Company B filters by its own perspective: the stored "receivable" is
|
||||
# the mirror of "payable", so it must be included.
|
||||
mirror = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="payable", viewer_company_id=self.company_b,
|
||||
)
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == event_id for item in mirror["items"]
|
||||
), mirror)
|
||||
for item in mirror["items"]:
|
||||
if item["ledger_event_id"] == event_id:
|
||||
self.assertEqual("payable", item["own_subject"])
|
||||
|
||||
def test_subject_filter_without_match_returns_empty(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "receivable", "k-none",
|
||||
self.admin)
|
||||
other = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="other_receivable",
|
||||
)
|
||||
self.assertEqual([], other["items"])
|
||||
|
||||
def test_pagination_is_stable_and_complete(self) -> None:
|
||||
for index in range(7):
|
||||
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
|
||||
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
|
||||
seen: list[int] = []
|
||||
cursor = None
|
||||
pages = 0
|
||||
while True:
|
||||
page = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
limit=3, cursor=cursor,
|
||||
)
|
||||
pages += 1
|
||||
seen.extend(item["ledger_event_id"] for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
if not page["has_more"]:
|
||||
break
|
||||
self.assertEqual(7, len(seen))
|
||||
self.assertEqual(len(set(seen)), len(seen))
|
||||
self.assertGreater(pages, 2)
|
||||
|
||||
def test_directory_pagination_complete(self) -> None:
|
||||
for index in range(6):
|
||||
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
|
||||
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
seen: set[tuple[int, str]] = set()
|
||||
cursor = None
|
||||
while True:
|
||||
page = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
limit=1, cursor=cursor,
|
||||
)
|
||||
seen.update((item["company_id"], item["currency"]) for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
if not page["has_more"]:
|
||||
break
|
||||
self.assertEqual(
|
||||
{(self.company_a, "CNY"), (self.company_b, "CNY")}, seen
|
||||
)
|
||||
|
||||
def test_event_payload_account_chip_and_repayment_flag(self) -> None:
|
||||
self.pair(
|
||||
self.company_a, self.company_b, "40.00",
|
||||
summary="归还往来款", purpose="还款",
|
||||
)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = int(self.ledger_events()[0]["id"])
|
||||
detail = positions.event_detail(self.connection, event_id)
|
||||
event = detail["event"]
|
||||
self.assertEqual("visible", event["payer_account"]["visibility"])
|
||||
self.assertEqual("测试 0001", event["payer_account"]["label"])
|
||||
self.assertEqual("visible", event["payee_account"]["visibility"])
|
||||
self.assertEqual("测试 0002", event["payee_account"]["label"])
|
||||
self.assertEqual("归还往来款", event["summary"])
|
||||
self.assertTrue(event["is_repayment"])
|
||||
|
||||
company_view = positions.event_payload(
|
||||
self.connection,
|
||||
self.connection.execute(
|
||||
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone(),
|
||||
viewer_company_id=self.company_a,
|
||||
)
|
||||
self.assertEqual("visible", company_view["payer_account"]["visibility"])
|
||||
self.assertEqual("masked", company_view["payee_account"]["visibility"])
|
||||
self.assertEqual("按对方授权不可见", company_view["payee_account"]["label"])
|
||||
|
||||
def test_park_subject_exception_hides_from_review_queue(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = int(self.ledger_events()[0]["id"])
|
||||
current = self.current(event_id)
|
||||
subjects.park_subject(
|
||||
self.connection, event_id,
|
||||
disposition="exception", reason="转异常核查",
|
||||
expected_revision=current["id"], request_key="park-exc-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
queue = positions.subject_review_queue(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
self.assertFalse(
|
||||
any(item["ledger_event_id"] == event_id for item in queue["items"])
|
||||
)
|
||||
revision = self.current(event_id)
|
||||
self.assertEqual("pending_subject", revision["state"])
|
||||
|
||||
def test_park_subject_return_stays_in_review_queue(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "80.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = int(self.ledger_events()[0]["id"])
|
||||
current = self.current(event_id)
|
||||
subjects.park_subject(
|
||||
self.connection, event_id,
|
||||
disposition="return", reason="退回补充摘要",
|
||||
expected_revision=current["id"], request_key="park-ret-1",
|
||||
actor=self.admin,
|
||||
)
|
||||
queue = positions.subject_review_queue(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
self.assertTrue(
|
||||
any(item["ledger_event_id"] == event_id for item in queue["items"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,594 +0,0 @@
|
||||
"""HTTP integration tests for the B-44 intercompany position APIs.
|
||||
|
||||
Covers the admin balances/pair/events/evidence/subject-review/manual-record
|
||||
endpoints and the company-side scoped reads with masked evidence, plus
|
||||
cross-tenant 404s and company-forbidden admin writes. Every test method spins
|
||||
up its own server with a fresh database so subject confirmations and matched
|
||||
fixtures never leak across tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "Cashier123"
|
||||
|
||||
CCB_HEADER = [
|
||||
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
|
||||
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
|
||||
]
|
||||
|
||||
ACCOUNT_A = "6222000000000001"
|
||||
ACCOUNT_B = "6222000000000002"
|
||||
|
||||
|
||||
def workbook_bytes(rows) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "正常流水"
|
||||
sheet.append(CCB_HEADER)
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00",
|
||||
currency: str = "RMB"):
|
||||
return [own, "测试公司", at, amount, "", "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
|
||||
currency: str = "RMB"):
|
||||
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
class IntercompanyApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.db_path = root / "app.db"
|
||||
self.storage = root / "files"
|
||||
self.initial_passwords: dict[str, str] = {}
|
||||
|
||||
self._old_db_path = server.DB_PATH
|
||||
self._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = self.db_path
|
||||
server.STORAGE_DIR = self.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(self.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
self.port = self.httpd.server_address[1]
|
||||
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
self.admin = Client("127.0.0.1", self.port)
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
self.company_a = self._create_company("甲公司", "cashier-a")
|
||||
self.company_b = self._create_company("乙公司", "cashier-b")
|
||||
self.company_c = self._create_company("丙公司", "cashier-c")
|
||||
self.cashier_a = self._login_company("cashier-a", self.company_a)
|
||||
self.cashier_b = self._login_company("cashier-b", self.company_b)
|
||||
self.cashier_c = self._login_company("cashier-c", self.company_c)
|
||||
|
||||
self._approve_account(self.company_a, ACCOUNT_A)
|
||||
self._approve_account(self.company_b, ACCOUNT_B)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
server.DB_PATH = self._old_db_path
|
||||
server.STORAGE_DIR = self._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
|
||||
def _create_company(self, name: str, username: str) -> int:
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/companies", {"name": name, "username": username}
|
||||
)
|
||||
assert status == 200, data
|
||||
self.initial_passwords.setdefault(username, as_json(data)["initial_password"])
|
||||
return as_json(data)["company_id"]
|
||||
|
||||
def _login_company(self, username: str, company_id: int) -> Client:
|
||||
client = Client("127.0.0.1", self.port)
|
||||
initial = self.initial_passwords[username]
|
||||
status, _, data = client.post_json(
|
||||
"/api/login", {"username": username, "password": initial, "portal": "company"}
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = client.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": CASHIER_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
return client
|
||||
|
||||
def _approve_account(self, company_id: int, number: str) -> None:
|
||||
client = {self.company_a: self.cashier_a, self.company_b: self.cashier_b}[company_id]
|
||||
status, _, data = client.post_json(
|
||||
"/api/company/accounts",
|
||||
{"bank_name": "中信银行", "account_type": "基本户",
|
||||
"account_number": number, "start_date": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
def _upload_and_confirm(self, client, company_id: int, rows) -> int:
|
||||
content = workbook_bytes(rows)
|
||||
status, _, data = self.admin.post_multipart(
|
||||
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
|
||||
)
|
||||
assert status == 200, data
|
||||
batch_id = as_json(data)["batch_id"]
|
||||
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
|
||||
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
|
||||
status, _, data = client.post_json(
|
||||
f"/api/batches/{batch_id}/confirm", {"sheets": names}
|
||||
)
|
||||
assert status == 200, data
|
||||
return batch_id
|
||||
|
||||
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00",
|
||||
currency: str = "RMB") -> dict:
|
||||
"""Upload+confirm a new A<->B pair; returns the pending review item."""
|
||||
self._upload_and_confirm(
|
||||
self.cashier_a, self.company_a,
|
||||
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at, currency)],
|
||||
)
|
||||
self._upload_and_confirm(
|
||||
self.cashier_b, self.company_b,
|
||||
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"), currency)],
|
||||
)
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
pending = [item for item in items if item["amount"] == amount]
|
||||
self.assertEqual(1, len(pending), data)
|
||||
return pending[0]
|
||||
|
||||
def _confirm(self, ledger_event_id: int) -> dict:
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{ledger_event_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
detail = as_json(data)
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{ledger_event_id}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": detail["event"]["payer_company_id"],
|
||||
"subject_code": "other_receivable",
|
||||
"reason": "借款确认其他应收",
|
||||
"expected_revision": detail["event"]["ledger_revision_id"],
|
||||
"request_key": f"subj-{ledger_event_id}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
return as_json(data)["revision"]
|
||||
|
||||
def balances(self, client=None, path="/api/admin/intercompany/balances") -> dict:
|
||||
client = client or self.admin
|
||||
status, _, data = client.get(path + "?from=2026-01-01&cutoff=2026-12-31")
|
||||
self.assertEqual(200, status, data)
|
||||
return as_json(data)
|
||||
|
||||
def _by_company(self, payload: dict, company_id: int) -> dict:
|
||||
return next(item for item in payload["items"] if item["company_id"] == company_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin balances / pair / events
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_admin_balances_directory(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
payload = self.balances()
|
||||
self.assertEqual("2026-12-31", payload["window"]["cutoff"])
|
||||
companies = {item["company_id"] for item in payload["items"]}
|
||||
self.assertIn(self.company_a, companies)
|
||||
self.assertIn(self.company_b, companies)
|
||||
for item in payload["items"]:
|
||||
self.assertEqual("unavailable", item["opening"]["status"])
|
||||
self.assertIsNone(item["opening"]["amount"])
|
||||
self.assertEqual("period_net_change", item["result"]["kind"])
|
||||
self.assertIn("gross_amount", item["unresolved"])
|
||||
self.assertIn("count", item["unresolved"])
|
||||
self.assertIn("by_reason", item["unresolved"])
|
||||
self.assertEqual("RMB", item["currency"])
|
||||
|
||||
def test_admin_balances_pending_subject_shows_unresolved(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
payload = self.balances()
|
||||
item = self._by_company(payload, self.company_a)
|
||||
self.assertEqual(1, item["unresolved"]["count"])
|
||||
self.assertEqual(
|
||||
"100.00", item["unresolved"]["by_reason"]["subject_review"]["gross_amount"]
|
||||
)
|
||||
self.assertEqual("0", item["period"]["debit"])
|
||||
self.assertEqual("0", item["period"]["credit"])
|
||||
|
||||
def test_admin_pair_detail_conserves(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/pairs/{self.company_a}/{self.company_b}"
|
||||
"?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
item = payload["items"][0]
|
||||
self.assertTrue(item["conservation"]["opposite"])
|
||||
self.assertTrue(item["conservation"]["abs_equal"])
|
||||
self.assertEqual("unavailable", item["opening"]["status"])
|
||||
self.assertIn("subjects", item)
|
||||
self.assertEqual(1, item["unresolved"]["count"])
|
||||
|
||||
def test_admin_subject_decision_flows_to_balances(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
self.assertEqual("other_receivable", revision["subject_code"])
|
||||
payload = self.balances()
|
||||
item = self._by_company(payload, self.company_a)
|
||||
self.assertEqual(0, item["unresolved"]["count"])
|
||||
self.assertEqual("100.00", item["period"]["debit"])
|
||||
self.assertEqual("100.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_admin_subject_decision_stale_revision_conflicts(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": pending["payer_company_id"],
|
||||
"subject_code": "other_receivable", "reason": "确认",
|
||||
"expected_revision": 999, "request_key": "stale-key",
|
||||
},
|
||||
)
|
||||
self.assertEqual(409, status, data)
|
||||
|
||||
def test_admin_events_list_and_evidence(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
event = next(
|
||||
item for item in items
|
||||
if item["ledger_event_id"] == revision["ledger_event_id"]
|
||||
)
|
||||
self.assertEqual("confirmed", event["state"])
|
||||
self.assertEqual("other_receivable", event["subject_code"])
|
||||
self.assertIn("payer_company_name", event)
|
||||
self.assertIn("amount", event)
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event['ledger_event_id']}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("confirmed", as_json(data)["state"])
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
blocks = as_json(data)["blocks"]
|
||||
self.assertTrue(blocks)
|
||||
for block in blocks:
|
||||
self.assertEqual("visible", block["visibility"])
|
||||
|
||||
def test_admin_events_list_includes_pending_subject(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
item = next(
|
||||
item for item in items
|
||||
if item["ledger_event_id"] == pending["ledger_event_id"]
|
||||
)
|
||||
self.assertEqual("pending_subject", item["state"])
|
||||
self.assertIsNone(item["subject_code"])
|
||||
|
||||
def test_admin_manual_record_decision_via_api(self) -> None:
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/manual-records",
|
||||
{
|
||||
"counterparty_company_id": self.company_b,
|
||||
"occurred_at": "2026-02-01T09:00:00",
|
||||
"direction": "incoming", "amount": "20.00", "currency": "CNY",
|
||||
"funding_source": "other", "requested_subject": "other_receivable",
|
||||
"request_key": "mr-api-1", "summary": "还款",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
record = as_json(data)["record"]
|
||||
self.assertEqual("pending", record["state"])
|
||||
|
||||
status, _, data = self.admin.get("/api/admin/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
records = as_json(data)["records"]
|
||||
self.assertTrue(any(item["id"] == record["id"] for item in records))
|
||||
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "approve_new", "reason": "银行流水中无此事实",
|
||||
"expected_decision_id": record["decision_id"], "request_key": "dec-api-1"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("approved", as_json(data)["decision"]["state"])
|
||||
|
||||
status, _, data = self.cashier_a.get("/api/company/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
own = [item for item in as_json(data)["records"] if item["id"] == record["id"]]
|
||||
self.assertEqual(1, len(own))
|
||||
self.assertEqual("approved", own[0]["state"])
|
||||
|
||||
status, _, data = self.cashier_b.get("/api/company/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual([], [
|
||||
item for item in as_json(data)["records"] if item["id"] == record["id"]
|
||||
])
|
||||
|
||||
def test_admin_adjustment_reverse_via_api(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{revision['ledger_event_id']}/adjustments",
|
||||
{"action": "reverse", "reason": "科目误判,冲销"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("reverse", as_json(data)["action"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Company scope and masking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_company_balances_scoped_to_own_company(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
for item in payload["items"]:
|
||||
self.assertEqual(self.company_a, item["company_id"])
|
||||
self.assertIn("counterparties", payload)
|
||||
self.assertTrue(payload["counterparties"])
|
||||
|
||||
def test_company_pair_and_events_are_own_scope(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get(
|
||||
f"/api/company/intercompany/pairs/{self.company_b}"
|
||||
"?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual(self.company_a, as_json(data)["companies"]["a"]["company_id"])
|
||||
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
for item in items:
|
||||
self.assertIn("direction", item)
|
||||
self.assertIn("own_subject_label", item)
|
||||
self.assertIn(item["direction"], ("incoming", "outgoing"))
|
||||
|
||||
def test_company_evidence_masks_counterparty_side(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.cashier_a.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
blocks = as_json(data)["blocks"]
|
||||
visibilities = {block["visibility"] for block in blocks}
|
||||
self.assertTrue(visibilities & {"visible", "masked"})
|
||||
for block in blocks:
|
||||
if block["visibility"] == "masked":
|
||||
self.assertEqual("按对方授权不可见", block["fields"].get("note"))
|
||||
self.assertNotIn(ACCOUNT_B, json.dumps(block["fields"], ensure_ascii=False))
|
||||
|
||||
def test_company_cannot_read_or_write_admin_intercompany(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get("/api/admin/intercompany/balances")
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.get("/api/admin/manual-records")
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/admin/manual-records/1/decisions", {"action": "approve_new", "reason": "x"}
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/admin/intercompany/events/1/adjustments",
|
||||
{"action": "reverse", "reason": "越权"},
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
def test_cross_company_reads_are_404(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_c.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}"
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
status, _, data = self.cashier_c.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
def test_company_counterparty_summary_splits_by_currency(self) -> None:
|
||||
# A->B 100 CNY and A->B 50 USD must render two rows, never a mixed
|
||||
# "CNY 150.00" bucket.
|
||||
cny = self._fresh_pair("100.00", currency="CNY")
|
||||
usd = self._fresh_pair("50.00", at="2026-01-20 10:00:00", currency="USD")
|
||||
for pending in (cny, usd):
|
||||
self._confirm(pending["ledger_event_id"])
|
||||
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
counterparties = as_json(data)["counterparties"]
|
||||
rows = [row for row in counterparties if row["counterparty_company_id"] == self.company_b]
|
||||
self.assertEqual(2, len(rows), counterparties)
|
||||
by_currency = {row["currency"]: row for row in rows}
|
||||
self.assertEqual({"CNY", "USD"}, set(by_currency))
|
||||
self.assertEqual("100.00", by_currency["CNY"]["result"]["signed_amount"])
|
||||
self.assertEqual("50.00", by_currency["USD"]["result"]["signed_amount"])
|
||||
for row in rows:
|
||||
self.assertNotEqual("150.00", row["result"]["signed_amount"])
|
||||
self.assertEqual(1, row["event_count"])
|
||||
|
||||
def test_company_events_subject_filter_matches_mirror(self) -> None:
|
||||
# A confirms subject "receivable" (stored from A's perspective); B must
|
||||
# still find the event when filtering by the mirror subject "payable".
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": self.company_a,
|
||||
"subject_code": "receivable", "reason": "确认应收",
|
||||
"expected_revision": 1, "request_key": "mirror-subj",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.cashier_b.get(
|
||||
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31&subject=payable"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == pending["ledger_event_id"] for item in items
|
||||
), data)
|
||||
|
||||
def test_manual_reverse_via_api_uses_explicit_effective_date(self) -> None:
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/manual-records",
|
||||
{
|
||||
"counterparty_company_id": self.company_b,
|
||||
"occurred_at": "2026-02-01T09:00:00",
|
||||
"direction": "incoming", "amount": "20.00", "currency": "CNY",
|
||||
"funding_source": "other", "requested_subject": "other_receivable",
|
||||
"request_key": "mr-rev-api", "summary": "还款",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
record = as_json(data)["record"]
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "approve_new", "reason": "确认入账",
|
||||
"expected_decision_id": record["decision_id"], "request_key": "dec-rev-api"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
event_id = as_json(data)["decision"]["ledger_event_id"]
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
revision_id = as_json(data)["event"]["ledger_revision_id"]
|
||||
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "reverse", "reason": "误录冲销", "effective_at": "2026-06-15",
|
||||
"expected_decision_id": None, "request_key": "dec-rev-api-2"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
reversals = [item for item in as_json(data)["items"] if item["posting_kind"] == "reversal"]
|
||||
self.assertEqual(1, len(reversals))
|
||||
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
|
||||
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
|
||||
|
||||
def test_admin_events_include_account_chips(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
event = next(
|
||||
item for item in as_json(data)["items"]
|
||||
if item["ledger_event_id"] == revision["ledger_event_id"]
|
||||
)
|
||||
self.assertIn("payer_account", event)
|
||||
self.assertIn("payee_account", event)
|
||||
self.assertEqual("visible", event["payer_account"]["visibility"])
|
||||
self.assertTrue(event["payer_account"]["label"])
|
||||
self.assertIn("summary", event)
|
||||
self.assertIn("is_repayment", event)
|
||||
|
||||
def test_subject_exception_drops_from_review_queue(self) -> None:
|
||||
pending = self._fresh_pair("77.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": pending["payer_company_id"],
|
||||
"action": "exception",
|
||||
"reason": "转异常待核查",
|
||||
"expected_revision": pending["revision_id"],
|
||||
"request_key": "park-exc-api",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("pending_subject", as_json(data)["revision"]["state"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
ids = [item["ledger_event_id"] for item in as_json(data)["items"]]
|
||||
self.assertNotIn(pending["ledger_event_id"], ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Tests for automatic reminder detection, delivery, isolation and audit trail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bank_importer import auth, reminders
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
class ReminderTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
self.company_a = int(
|
||||
self.connection.execute("SELECT id FROM companies WHERE name = '甲公司'").fetchone()["id"]
|
||||
)
|
||||
self.company_b = int(
|
||||
self.connection.execute("SELECT id FROM companies WHERE name = '乙公司'").fetchone()["id"]
|
||||
)
|
||||
self.admin_id = auth.create_user(self.connection, "admin1", "AdminPass123", "admin")
|
||||
self.user_a = auth.create_user(
|
||||
self.connection, "cashier-a", "CashierA123", "company", company_id=self.company_a
|
||||
)
|
||||
self.user_b = auth.create_user(
|
||||
self.connection, "cashier-b", "CashierB123", "company", company_id=self.company_b
|
||||
)
|
||||
self.admin = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (self.admin_id,)
|
||||
).fetchone()
|
||||
self.actor_a = self.connection.execute(
|
||||
"SELECT * FROM users WHERE id = ?", (self.user_a,)
|
||||
).fetchone()
|
||||
|
||||
def _insert_batch(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
period_start: str,
|
||||
period_end: str,
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'f.xls', 1, '/tmp/f.xls', ?)
|
||||
""",
|
||||
(f"sha-{company_id}-{period_end}", now),
|
||||
)
|
||||
file_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
|
||||
VALUES (?, 'parsed', ?, ?, ?)
|
||||
""",
|
||||
(file_id, company_id, now, now),
|
||||
)
|
||||
batch_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||||
header_row, period_start, period_end, transaction_count, created_at
|
||||
) VALUES (?, 's1', '工行', 'tpl', 1, 1, ?, ?, 1, ?)
|
||||
""",
|
||||
(batch_id, period_start, period_end, now),
|
||||
)
|
||||
|
||||
def _insert_pending_account(self, company_id: int) -> None:
|
||||
now = utc_now()
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO bank_accounts (
|
||||
company_id, account_number, bank_name, status, created_at, updated_at
|
||||
) VALUES (?, ?, '工行', 'pending', ?, ?)
|
||||
""",
|
||||
(company_id, f"622{company_id:012d}", now, now),
|
||||
)
|
||||
|
||||
|
||||
class RuleDetectionTests(ReminderTestCase):
|
||||
def test_unsubmitted_detected_when_no_current_month_batch(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
keys = {item.company_id: item for item in findings if item.rule_key == reminders.RULE_UNSUBMITTED}
|
||||
self.assertIn(self.company_a, keys)
|
||||
self.assertIn(self.company_b, keys)
|
||||
|
||||
def test_unsubmitted_not_reported_when_batch_exists(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
period = f"{today.year:04d}-{today.month:02d}"
|
||||
self._insert_batch(self.company_a, period_start=f"{period}-01", period_end=f"{period}-15")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
for item in findings:
|
||||
if item.rule_key == reminders.RULE_UNSUBMITTED:
|
||||
self.assertNotEqual(self.company_a, item.company_id)
|
||||
|
||||
def test_gap_detected_after_threshold(self) -> None:
|
||||
old_end = (date.today() - timedelta(days=10)).isoformat()
|
||||
self._insert_batch(self.company_a, period_start="2026-01-01", period_end=old_end)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
|
||||
self.assertEqual(1, len(gap))
|
||||
|
||||
def test_gap_not_reported_for_recent_batch(self) -> None:
|
||||
recent = (date.today() - timedelta(days=1)).isoformat()
|
||||
self._insert_batch(self.company_a, period_start="2026-07-01", period_end=recent)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
|
||||
self.assertEqual(0, len(gap))
|
||||
|
||||
def test_pending_review_detected_with_pending_account(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
pending = [
|
||||
item for item in findings
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
]
|
||||
self.assertEqual(1, len(pending))
|
||||
self.assertGreater(pending[0].rule_params["pending_count"], 0)
|
||||
|
||||
def test_pending_review_not_reported_when_clean(self) -> None:
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
pending = [
|
||||
item for item in findings
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
]
|
||||
self.assertEqual(0, len(pending))
|
||||
|
||||
|
||||
class DeliveryAndDedupTests(ReminderTestCase):
|
||||
def test_send_creates_reminder_and_event(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
self.assertTrue(findings)
|
||||
key = findings[0].dedupe_key
|
||||
reminder_id = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
self.assertIsNotNone(reminder_id)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count, status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(1, row["send_count"])
|
||||
self.assertEqual("open", row["status"])
|
||||
events = self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
|
||||
).fetchall()
|
||||
self.assertEqual(["sent"], [item["event_type"] for item in events])
|
||||
|
||||
def test_repeat_send_increments_count_without_new_row(self) -> None:
|
||||
today = date.today()
|
||||
if today.day < 5:
|
||||
self.skipTest("monthly start day gate not reached today")
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
key = findings[0].dedupe_key
|
||||
first = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
second = reminders.deliver_finding(self.connection, key, actor=self.admin)
|
||||
self.assertEqual(first, second)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count FROM reminders WHERE dedupe_key = ?", (key,)
|
||||
).fetchone()
|
||||
self.assertEqual(2, row["send_count"])
|
||||
count = self.connection.execute("SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (key,)).fetchone()[0]
|
||||
self.assertEqual(1, count)
|
||||
events = self.connection.execute(
|
||||
"SELECT COUNT(*) FROM reminder_events WHERE reminder_id = ?", (first,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(2, events)
|
||||
|
||||
def test_resolved_reminder_reopens_same_row(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
finding = next(
|
||||
item
|
||||
for item in reminders.scan_findings(self.connection)
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
)
|
||||
first = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
|
||||
self.assertIsNotNone(first)
|
||||
reminders.update_reminder_status(
|
||||
self.connection, first, "resolved", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
second = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
|
||||
self.assertEqual(first, second)
|
||||
row = self.connection.execute(
|
||||
"SELECT send_count, status, dedupe_key FROM reminders WHERE id = ?",
|
||||
(first,),
|
||||
).fetchone()
|
||||
self.assertEqual(2, row["send_count"])
|
||||
self.assertEqual("open", row["status"])
|
||||
count = self.connection.execute(
|
||||
"SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (finding.dedupe_key,)
|
||||
).fetchone()[0]
|
||||
self.assertEqual(1, count)
|
||||
events = [
|
||||
item["event_type"]
|
||||
for item in self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
|
||||
(first,),
|
||||
).fetchall()
|
||||
]
|
||||
self.assertEqual(["sent", "resolved", "sent"], events)
|
||||
|
||||
def test_deliver_many_continues_after_one_failure(self) -> None:
|
||||
self._insert_pending_account(self.company_a)
|
||||
finding = next(
|
||||
item
|
||||
for item in reminders.scan_findings(self.connection)
|
||||
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
|
||||
)
|
||||
original = reminders.deliver_finding
|
||||
|
||||
def flaky(connection, key, **kwargs):
|
||||
if key == "boom":
|
||||
raise sqlite3.IntegrityError("UNIQUE constraint failed: reminders.dedupe_key")
|
||||
return original(connection, key, **kwargs)
|
||||
|
||||
with patch.object(reminders, "deliver_finding", side_effect=flaky):
|
||||
sent = reminders.deliver_many(
|
||||
self.connection, ["boom", finding.dedupe_key], actor=self.admin
|
||||
)
|
||||
self.assertEqual(1, len(sent))
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.connection.execute("SELECT COUNT(*) FROM reminders").fetchone()[0],
|
||||
)
|
||||
|
||||
def test_manual_reminder_each_send_is_separate(self) -> None:
|
||||
first = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="其他",
|
||||
content="请尽快处理",
|
||||
deadline="2026-09-01",
|
||||
actor=self.admin,
|
||||
)
|
||||
second = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="其他",
|
||||
content="再次提醒",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
|
||||
class IsolationTests(ReminderTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="流水未提交",
|
||||
content="甲公司专属",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_b,
|
||||
display_type="流水未提交",
|
||||
content="乙公司专属",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
|
||||
def test_company_sees_only_own_reminders(self) -> None:
|
||||
items_a = reminders.list_company_reminders(self.connection, self.company_a)
|
||||
items_b = reminders.list_company_reminders(self.connection, self.company_b)
|
||||
self.assertEqual(1, len(items_a))
|
||||
self.assertEqual(1, len(items_b))
|
||||
self.assertIn("甲公司", items_a[0]["title"])
|
||||
self.assertIn("乙公司", items_b[0]["title"])
|
||||
|
||||
def test_company_cannot_update_other_company_reminder(self) -> None:
|
||||
other_id = reminders.list_company_reminders(self.connection, self.company_b)[0]["id"]
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, other_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class AuditTrailTests(ReminderTestCase):
|
||||
def test_scan_writes_audit_log(self) -> None:
|
||||
reminders.run_scan(self.connection, actor=self.admin, ip="127.0.0.1")
|
||||
row = self.connection.execute(
|
||||
"SELECT action, detail FROM audit_log WHERE action = 'reminder_scan'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
detail = json.loads(row["detail"])
|
||||
self.assertIn("total", detail)
|
||||
|
||||
def test_events_are_append_only(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="测试",
|
||||
content="内容",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_events SET detail = 'tampered' WHERE reminder_id = ?",
|
||||
(reminder_id,),
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"DELETE FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
|
||||
)
|
||||
|
||||
def test_reminders_cannot_be_deleted(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="测试",
|
||||
content="内容",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
with self.connection:
|
||||
self.connection.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
||||
|
||||
|
||||
class StatusFlowTests(ReminderTestCase):
|
||||
def test_acknowledge_and_resolve(self) -> None:
|
||||
reminder_id = reminders.send_manual(
|
||||
self.connection,
|
||||
company_id=self.company_a,
|
||||
display_type="待确认",
|
||||
content="请处理",
|
||||
deadline=None,
|
||||
actor=self.admin,
|
||||
)
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, reminder_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
row = self.connection.execute(
|
||||
"SELECT status FROM reminders WHERE id = ?", (reminder_id,)
|
||||
).fetchone()
|
||||
self.assertEqual("acknowledged", row["status"])
|
||||
ok = reminders.update_reminder_status(
|
||||
self.connection, reminder_id, "resolved", company_id=self.company_a, actor=self.actor_a
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
events = self.connection.execute(
|
||||
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
|
||||
(reminder_id,),
|
||||
).fetchall()
|
||||
self.assertEqual(
|
||||
["sent", "acknowledged", "resolved"],
|
||||
[item["event_type"] for item in events],
|
||||
)
|
||||
|
||||
|
||||
class SettingsTests(ReminderTestCase):
|
||||
def test_default_settings_and_update(self) -> None:
|
||||
settings = reminders.get_settings(self.connection)
|
||||
self.assertEqual("5", settings["monthly_start_day"])
|
||||
self.assertEqual("5", settings["gap_days"])
|
||||
updated = reminders.update_settings(
|
||||
self.connection, {"gap_days": "7", "monthly_start_day": "6"}
|
||||
)
|
||||
self.assertEqual("7", updated["gap_days"])
|
||||
self.assertEqual("6", updated["monthly_start_day"])
|
||||
|
||||
def test_unknown_setting_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"unknown_key": "1"})
|
||||
|
||||
def test_invalid_monthly_start_day_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"monthly_start_day": "0"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"monthly_start_day": "29"})
|
||||
self.assertEqual("5", reminders.get_settings(self.connection)["monthly_start_day"])
|
||||
|
||||
def test_invalid_gap_days_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"gap_days": "0"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"gap_days": "abc"})
|
||||
self.assertEqual("5", reminders.get_settings(self.connection)["gap_days"])
|
||||
|
||||
def test_invalid_scan_time_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"scan_time": "25:00"})
|
||||
with self.assertRaises(ValueError):
|
||||
reminders.update_settings(self.connection, {"scan_time": "8:0"})
|
||||
self.assertEqual("08:00", reminders.get_settings(self.connection)["scan_time"])
|
||||
|
||||
def test_corrupt_settings_do_not_break_scan(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_settings SET value = 'not-a-number' WHERE key = 'monthly_start_day'"
|
||||
)
|
||||
self.connection.execute(
|
||||
"UPDATE reminder_settings SET value = '0' WHERE key = 'gap_days'"
|
||||
)
|
||||
findings = reminders.scan_findings(self.connection)
|
||||
self.assertIsInstance(findings, list)
|
||||
|
||||
|
||||
class NoticeListDelegationTests(unittest.TestCase):
|
||||
def test_async_notice_go_handle_uses_event_delegation(self) -> None:
|
||||
source = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
init_body = source.split("function initNotifications()", 1)[1].split("\nfunction ", 1)[0]
|
||||
self.assertIn('list.addEventListener("click"', init_body)
|
||||
self.assertIn('closest("[data-view-link]")', init_body)
|
||||
self.assertIn("showView(viewLink.dataset.viewLink)", init_body)
|
||||
self.assertIn('document.addEventListener("click"', source)
|
||||
self.assertNotIn(
|
||||
'$$("[data-view-link]").forEach((button) => button.addEventListener("click"',
|
||||
source,
|
||||
)
|
||||
|
||||
def test_delegated_lookup_finds_button_inserted_after_init(self) -> None:
|
||||
"""Simulate #notice-list after async replaceChildren: click target is the new button."""
|
||||
list_root = {"id": "notice-list", "parent": None, "attrs": {}}
|
||||
side = {"id": "lr-side", "parent": list_root, "attrs": {}}
|
||||
button = {
|
||||
"id": "go",
|
||||
"parent": side,
|
||||
"attrs": {"data-view-link": "reconcile"},
|
||||
}
|
||||
list_root["children"] = [side]
|
||||
side["children"] = [button]
|
||||
|
||||
def closest(node, attr):
|
||||
current = node
|
||||
while current is not None:
|
||||
if attr in current.get("attrs", {}):
|
||||
return current
|
||||
current = current.get("parent")
|
||||
return None
|
||||
|
||||
clicked = closest(button, "data-view-link")
|
||||
self.assertIsNotNone(clicked)
|
||||
self.assertEqual("reconcile", clicked["attrs"]["data-view-link"])
|
||||
self.assertIs(list_root, clicked["parent"]["parent"])
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_v6_migration_applies_and_rolls_back(self) -> None:
|
||||
connection = connect(":memory:")
|
||||
self.addCleanup(connection.close)
|
||||
migrate(connection)
|
||||
versions = connection.execute(
|
||||
"SELECT version FROM schema_migrations ORDER BY version"
|
||||
).fetchall()
|
||||
self.assertEqual(6, versions[-1]["version"])
|
||||
connection.execute("DELETE FROM schema_migrations WHERE version = 6")
|
||||
connection.executescript(
|
||||
"""
|
||||
DROP TRIGGER IF EXISTS reminders_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_delete;
|
||||
DROP TRIGGER IF EXISTS reminder_events_no_update;
|
||||
DROP TABLE IF EXISTS reminder_events;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS reminder_settings;
|
||||
"""
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'reminders'"
|
||||
).fetchone()
|
||||
self.assertIsNone(row)
|
||||
@@ -287,6 +287,8 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
lambda: anon.get("/api/admin/users"),
|
||||
lambda: anon.get("/api/admin/companies"),
|
||||
lambda: anon.get("/api/admin/audit-log"),
|
||||
lambda: anon.get("/api/admin/reminders"),
|
||||
lambda: anon.get("/api/company/reminders"),
|
||||
lambda: anon.get("/api/me"),
|
||||
):
|
||||
status, _, data = method_check()
|
||||
@@ -387,6 +389,10 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
|
||||
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
|
||||
lambda: self.cashier_a.get("/api/admin/audit-log"),
|
||||
lambda: self.cashier_a.get("/api/admin/reminders"),
|
||||
lambda: self.cashier_a.get("/api/admin/reminders/pending"),
|
||||
lambda: self.cashier_a.post_json("/api/admin/reminder-settings", {"gap_days": "3"}),
|
||||
lambda: self.cashier_a.post_json("/api/admin/reminders/send", {"dedupe_keys": ["x"]}),
|
||||
)
|
||||
for call in calls:
|
||||
status, _, data = call()
|
||||
@@ -557,6 +563,28 @@ class ServerAuthMatrixTests(unittest.TestCase):
|
||||
self.assertNotIn(password, row["detail"] or "")
|
||||
self.assertNotIn(password, row["target"] or "")
|
||||
|
||||
def test_invalid_reminder_settings_return_400_and_do_not_persist(self) -> None:
|
||||
cases = (
|
||||
{"monthly_start_day": "0"},
|
||||
{"gap_days": "0"},
|
||||
{"scan_time": "25:99"},
|
||||
)
|
||||
for payload in cases:
|
||||
with self.subTest(payload=payload):
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/reminder-settings", {"settings": payload}
|
||||
)
|
||||
self.assertEqual(400, status, data)
|
||||
self.assertEqual("error", as_json(data)["status"])
|
||||
status, _, data = self.admin.get("/api/admin/reminder-settings")
|
||||
self.assertEqual(200, status)
|
||||
settings = as_json(data)["settings"]
|
||||
self.assertEqual("5", settings["monthly_start_day"])
|
||||
self.assertEqual("5", settings["gap_days"])
|
||||
self.assertEqual("08:00", settings["scan_time"])
|
||||
status, _, data = self.admin.get("/api/admin/reminders/pending")
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""Integration tests for system settings persistence and reminder flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer.db import connect, migrate
|
||||
from bank_importer import auth, settings
|
||||
|
||||
import server
|
||||
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.cookies: dict[str, str] = {}
|
||||
|
||||
def request(self, method, path, body=None, headers=None):
|
||||
connection = HTTPConnection(self.host, self.port)
|
||||
request_headers = dict(headers or {})
|
||||
if self.cookies:
|
||||
request_headers["Cookie"] = "; ".join(
|
||||
f"{k}={v}" for k, v in self.cookies.items()
|
||||
)
|
||||
connection.request(method, path, body=body, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
set_cookie = dict(response.getheaders()).get("Set-Cookie")
|
||||
if set_cookie:
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(set_cookie)
|
||||
for key, morsel in cookie.items():
|
||||
if morsel.value:
|
||||
self.cookies[key] = morsel.value
|
||||
else:
|
||||
self.cookies.pop(key, None)
|
||||
status = response.status
|
||||
connection.close()
|
||||
return status, data
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post_json(self, path, payload):
|
||||
return self.request(
|
||||
"POST", path, body=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def as_json(data: bytes):
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
class SettingsAndRemindersTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
server.ensure_bootstrap_admin(connection)
|
||||
# One active account for company 1 so pending items are generated.
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, 'active', 't', 't')"
|
||||
)
|
||||
company_id = connection.execute(
|
||||
"SELECT id FROM companies WHERE name = '甲公司'"
|
||||
).fetchone()["id"]
|
||||
connection.execute(
|
||||
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
|
||||
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
|
||||
(company_id,),
|
||||
)
|
||||
cls.company_id = company_id
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.admin = Client("127.0.0.1", cls.port)
|
||||
status, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_settings_roundtrip_persists(self) -> None:
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
updated = as_json(data)["settings"]
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("2026-01-05", updated["start_date"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
self.assertEqual("5", updated["remind_days"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
def test_invalid_closing_day_rejected(self) -> None:
|
||||
for bad in ("0", "29", "abc"):
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
|
||||
self.assertEqual(400, status, (bad, data))
|
||||
|
||||
def test_setting_change_is_audited(self) -> None:
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
|
||||
self.assertEqual(200, status)
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT key, before_value, after_value, actor_username "
|
||||
"FROM system_setting_changes WHERE key = 'closing_day' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual("7", rows[0]["after_value"])
|
||||
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
|
||||
self.assertEqual("group-admin", rows[0]["actor_username"])
|
||||
|
||||
def test_reminder_pending_and_send(self) -> None:
|
||||
status, data = self.admin.get(
|
||||
f"/api/admin/reminders/pending?company_id={self.company_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/reminders/send", {"company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
self.assertGreaterEqual(len(payload["reminders"]), 1)
|
||||
self.assertTrue(payload["deadline"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/reminders")
|
||||
self.assertEqual(200, status)
|
||||
history = as_json(data)["reminders"]
|
||||
self.assertGreaterEqual(len(history), 1)
|
||||
self.assertEqual(self.company_id, history[0]["company_id"])
|
||||
|
||||
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
|
||||
# A company user must not be able to read or write admin settings.
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
initial = as_json(data)["initial_password"]
|
||||
cashier = Client("127.0.0.1", self.port)
|
||||
status, data = cashier.post_json(
|
||||
"/api/login",
|
||||
{"username": "cashier-x", "password": initial, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
status, data = cashier.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": "Changed123"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
|
||||
for path in ("/api/admin/settings", "/api/admin/reminders"):
|
||||
status, _ = cashier.get(path)
|
||||
self.assertEqual(403, status, path)
|
||||
|
||||
|
||||
class SettingsModuleTests(unittest.TestCase):
|
||||
"""Unit tests for the settings module on a fresh in-memory database."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
|
||||
def test_defaults_applied_when_no_row_exists(self) -> None:
|
||||
values = settings.get_settings(self.connection)
|
||||
self.assertEqual("5", values["closing_day"])
|
||||
self.assertEqual("2026-01-01", values["start_date"])
|
||||
self.assertEqual("1", values["auto_remind"])
|
||||
self.assertEqual("3", values["remind_days"])
|
||||
|
||||
def test_validate_rejects_bad_values(self) -> None:
|
||||
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
|
||||
_, error = settings.validate_settings(bad)
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"start_date": "2026-13-40"})
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"auto_remind": "2"})
|
||||
self.assertIsNotNone(error)
|
||||
|
||||
def test_update_writes_value_and_trail(self) -> None:
|
||||
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
|
||||
user = self.connection.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
updated = settings.update_settings(
|
||||
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
|
||||
)
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
changes = self.connection.execute(
|
||||
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(changes))
|
||||
self.assertEqual("5", changes[0]["before_value"])
|
||||
self.assertEqual("1", changes[0]["after_value"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+245
-122
@@ -3,18 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="金牛集团管理端" />
|
||||
<title>管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<meta name="description" content="金牛集团总账管理端" />
|
||||
<title>总账管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="管理导航">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="side-brand">
|
||||
<div class="brand-name">金牛实业 · 资金往来</div>
|
||||
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
|
||||
<span class="side-role">管理端</span>
|
||||
<span class="side-role">总账管理端</span>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<div class="nav-group">日常</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
<span class="avatar">管</span>
|
||||
<div>
|
||||
<div class="user-name">系统管理员</div>
|
||||
<div class="user-meta">管理员</div>
|
||||
<div class="user-meta">集团总账会计</div>
|
||||
</div>
|
||||
<button class="logout" type="button">退出</button>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<span class="crumb">管理端 / <b id="currentViewName">管理总览</b></span>
|
||||
<span class="crumb">总账管理端 / <b id="currentViewName">管理总览</b></span>
|
||||
<div class="topbar-right">
|
||||
<span class="tag">账期 2026-07</span>
|
||||
<span class="tag">统计截止 2026-08-20</span>
|
||||
@@ -56,6 +56,9 @@
|
||||
<h1>管理总览</h1>
|
||||
<p class="page-sub">2026-01-01 至 2026-08-20,集团 6 家成员公司相互往来累计发生 8.11 亿元、392 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn" data-view-link="pair">往来查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-4">
|
||||
@@ -69,10 +72,10 @@
|
||||
<div class="stat-value">39,040.30<span class="unit">万元</span></div>
|
||||
<div class="stat-foot">6 家公司合计 · 与借方同源互证</div>
|
||||
</div>
|
||||
<div class="card stat-card warn" data-view-link="audit" style="cursor: pointer;">
|
||||
<div class="stat-label">待审核事项 · 审核中心</div>
|
||||
<div class="stat-value" id="dashAuditTotal">0<span class="unit">项</span></div>
|
||||
<div class="stat-foot" id="dashAuditFoot">高 0 项 · 中 0 项 · 其余 0 项低风险</div>
|
||||
<div class="card stat-card warn">
|
||||
<div class="stat-label">借贷轧差 · 未匹配</div>
|
||||
<div class="stat-value">3,000.00<span class="unit">万元</span></div>
|
||||
<div class="stat-foot">由单边流水与待确认往来形成,可在往来查询核对</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">7 月账期确认进度</div>
|
||||
@@ -82,117 +85,175 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2" style="margin-top: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head" style="margin-bottom: 10px;">
|
||||
<span class="card-title">各公司往来分布<span class="sub">期末净值 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div id="dashBarChart" style="height: 220px; min-width: 0;" aria-label="各公司往来分布柱状图"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head" style="margin-bottom: 10px;">
|
||||
<span class="card-title">本周资金流入流出<span class="sub">集团内往来 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div id="dashLineChart" style="height: 220px; min-width: 0;" aria-label="本周资金流入流出折线图"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
|
||||
<div class="grid grid-1-2" style="gap: 0; align-items: stretch; height: 492px;">
|
||||
<div style="border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; min-height: 0;">
|
||||
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0; gap: 8px;">
|
||||
<span class="card-title" style="min-width: 0;">公司往来合计<span class="sub" id="dashListSub" style="display: inline; margin-left: 6px;">单位:万元</span></span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索…" style="width: 120px; min-height: 28px; padding: 4px 8px; flex: none;" />
|
||||
<div class="grid grid-3-2" style="margin-top: 14px;">
|
||||
<div class="stack">
|
||||
<div class="card" id="companyTotals">
|
||||
<div class="card-head">
|
||||
<span class="card-title">各公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 单位:万元 · 点击任意行查看该公司往来明细</span></span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索公司…" style="width: 170px;" />
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
|
||||
<table class="ds-table" id="dashCompanyTable" style="min-width: 0;">
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 12px;">公司</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">明细笔数</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期末净值</th>
|
||||
<th id="dashStatusHead" style="padding: 6px 12px;">状态</th>
|
||||
</tr>
|
||||
<tr><th>公司</th><th class="num-col">借方合计</th><th class="num-col">贷方合计</th><th class="num-col">明细笔数</th><th class="num-col">期末净往来</th><th>7 月状态</th></tr>
|
||||
</thead>
|
||||
<tbody id="dashCompanyRows">
|
||||
<tr class="loading-row"><td colspan="4">加载中…</td></tr>
|
||||
<tbody>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛煤业<span class="cell-sub">河南金牛煤业有限公司</span></td>
|
||||
<td class="num-col">18,420.50</td>
|
||||
<td class="num-col">12,386.00</td>
|
||||
<td class="num-col">128</td>
|
||||
<td class="num-col amt-in">+6,034.50</td>
|
||||
<td><span class="pill pill-success">已完成</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛物流<span class="cell-sub">河南金牛物流有限公司</span></td>
|
||||
<td class="num-col">6,208.75</td>
|
||||
<td class="num-col">7,914.30</td>
|
||||
<td class="num-col">74</td>
|
||||
<td class="num-col amt-out">-1,705.55</td>
|
||||
<td><span class="pill pill-success">已完成</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛贸易<span class="cell-sub">河南金牛贸易有限公司</span></td>
|
||||
<td class="num-col">9,850.00</td>
|
||||
<td class="num-col">9,102.40</td>
|
||||
<td class="num-col">86</td>
|
||||
<td class="num-col amt-in">+747.60</td>
|
||||
<td><span class="pill pill-success">已完成</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛置业<span class="cell-sub">河南金牛置业有限公司</span></td>
|
||||
<td class="num-col">3,960.20</td>
|
||||
<td class="num-col">5,730.00</td>
|
||||
<td class="num-col">52</td>
|
||||
<td class="num-col amt-out">-1,769.80</td>
|
||||
<td><span class="pill pill-warn">流水待补</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛新能源<span class="cell-sub">河南金牛新能源有限公司</span></td>
|
||||
<td class="num-col">2,130.00</td>
|
||||
<td class="num-col">2,886.00</td>
|
||||
<td class="num-col">31</td>
|
||||
<td class="num-col amt-out">-756.00</td>
|
||||
<td><span class="pill pill-warn">确认中</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛农业<span class="cell-sub">河南金牛农业科技发展有限公司</span></td>
|
||||
<td class="num-col">1,470.85</td>
|
||||
<td class="num-col">1,021.60</td>
|
||||
<td class="num-col">21</td>
|
||||
<td class="num-col amt-in">+449.25</td>
|
||||
<td><span class="pill pill-danger">未提交</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>合计 · 6 家</td>
|
||||
<td class="num-col">42,040.30</td>
|
||||
<td class="num-col">39,040.30</td>
|
||||
<td class="num-col">392</td>
|
||||
<td class="num-col">+3,000.00</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; min-width: 0; min-height: 0;">
|
||||
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0;">
|
||||
<span class="card-title" id="dashDetailTitle">公司往来明细<span class="sub" id="dashDetailSub">选择左侧公司查看会计式分级明细</span></span>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">公司对查<span class="sub">选择两家公司,直达它们之间的往来明细</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" id="dashDetailWrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
|
||||
<table class="ds-table" id="dashDetailTable" style="min-width: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 12px;">对方 / 摘要</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">笔数</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期初</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">本期借方</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">本期贷方</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期末</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashDetailRows">
|
||||
<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row" style="align-items: flex-end; flex-wrap: wrap;">
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-self">本方公司</label>
|
||||
<select class="select" id="ql-self">
|
||||
<option>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
<option selected>河南金牛农业科技发展有限公司</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="meta" style="padding: 0 4px 9px; flex: none;">↔</span>
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-peer">对方公司</label>
|
||||
<select class="select" id="ql-peer">
|
||||
<option selected>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-box row-between" style="margin-top: 12px;">
|
||||
<div>
|
||||
<div style="font-weight: 650;">金牛农业 → 应收 386.24 万元</div>
|
||||
<div class="meta" style="margin-top: 2px;">14 笔(借 9 / 贷 5)· 最早未结 2026-03-12</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="pair">查看逐笔往来</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2" style="margin-top: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">公司对查<span class="sub">选择两家公司,直达它们之间的往来明细</span></span>
|
||||
</div>
|
||||
<div class="row" style="align-items: flex-end; flex-wrap: wrap;">
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-self">本方公司</label>
|
||||
<select class="select" id="ql-self">
|
||||
<option>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
<option selected>河南金牛农业科技发展有限公司</option>
|
||||
</select>
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">待办队列<span class="sub">12 项待处理 · 下方为影响结账的前 4 项,从第 1 项开始</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="audit">审核中心 →</button>
|
||||
</div>
|
||||
<span class="meta" style="padding: 0 4px 9px; flex: none;">↔</span>
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-peer">对方公司</label>
|
||||
<select class="select" id="ql-peer">
|
||||
<option selected>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
</select>
|
||||
<div class="list-row" data-detail="admin-gap">
|
||||
<span class="pill pill-danger">高</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">金牛置业 · 中行尾号 8821 账户 7 月流水断档 11 天</div>
|
||||
<div class="lr-sub">07-06 至 07-16 无流水,影响与金牛煤业 320 万元往来的归集</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="audit">去审核</button>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-unsubmitted">
|
||||
<span class="pill pill-danger">高</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">金牛农业 7 月全部账户未提交流水</div>
|
||||
<div class="lr-sub">已发 2 次系统提醒,最近一次 08-18,仍未响应</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="reminders">再提醒</button>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-unilateral">
|
||||
<span class="pill pill-warn">中</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">金牛新能源 ↔ 金牛贸易 3 笔单边流水待对方证据</div>
|
||||
<div class="lr-sub">合计 486 万元,需新能源侧选择对方银行流水佐证</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="audit">去审核</button>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-subject">
|
||||
<span class="pill pill-warn">中</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">5 笔手工记录待确认往来科目</div>
|
||||
<div class="lr-sub">应收 / 其他应收待判定,涉及煤业、物流、贸易</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="audit">去审核</button>
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span>其余 8 项为低风险与已挂起事项</span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="audit" style="color: var(--fg); font-weight: 550;">进入审核中心 →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-box row-between" style="margin-top: 12px;">
|
||||
<div>
|
||||
<div style="font-weight: 650;">金牛农业 → 应收 386.24 万元</div>
|
||||
<div class="meta" style="margin-top: 2px;">14 笔(借 9 / 贷 5)· 最早未结 2026-03-12</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="pair">查看逐笔往来</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期与结账<span class="sub" id="dashboardPeriodSub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期与结账<span class="sub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
|
||||
<dt>结账日</dt><dd style="color: color-mix(in oklch, var(--warn) 78%, black); font-weight: 650;">2026-08-29(顺延)· 距今 9 天</dd>
|
||||
<dt>上一账期</dt><dd>2026-06 · 已于 07-03 结账</dd>
|
||||
</dl>
|
||||
<button class="btn" data-view-link="settings" style="width: 100%; margin-top: 14px;">进入结账检查</button>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
|
||||
<dt>结账日</dt><dd style="color: color-mix(in oklch, var(--warn) 78%, black); font-weight: 650;">2026-08-29(顺延)· 距今 9 天</dd>
|
||||
<dt>上一账期</dt><dd>2026-06 · 已于 07-03 结账</dd>
|
||||
</dl>
|
||||
<button class="btn" data-view-link="settings" style="width: 100%; margin-top: 14px;">进入结账检查</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -463,7 +524,7 @@
|
||||
</table>
|
||||
<div class="table-foot">
|
||||
<span id="flowCount">共 0 笔</span>
|
||||
<span class="meta" id="flowsRangeMeta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
|
||||
<span class="meta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -509,7 +570,7 @@
|
||||
</div>
|
||||
<div class="table-foot" style="border: 1px solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg);">
|
||||
<span id="companyFoot">共 0 家公司</span>
|
||||
<span class="meta">登录账号初始密码由管理员统一发放</span>
|
||||
<span class="meta">登录账号初始密码由集团总账统一发放</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -524,7 +585,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期时间轴<span class="sub" id="timelineSub">全局起算日 2026-01-01 起,每月 5 日结账</span></span>
|
||||
<span class="card-title">账期时间轴<span class="sub">全局起算日 2026-01-01 起,每月 5 日结账</span></span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="tl-cell closed"><div class="tl-month">2026-01</div><div class="tl-state">已结账</div></div>
|
||||
@@ -582,7 +643,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">期初余额管理<span class="sub" id="openingSub">2026-01-01 起算的公司间往来期初数</span></span>
|
||||
<span class="card-title">期初余额管理<span class="sub">2026-01-01 起算的公司间往来期初数</span></span>
|
||||
<button class="btn btn-sm" id="openOpeningDialog">新增</button>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
@@ -652,27 +713,55 @@
|
||||
<h1>提醒管理</h1>
|
||||
<p class="page-sub">向成员公司发送处理提醒,并跟踪系统提醒与人工提醒的触达与处理状态。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button type="button" class="btn btn-sm" id="reminder-scan-btn">立即扫描</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="pending-reminders-card" style="margin-bottom: 16px;">
|
||||
<div class="card-head">
|
||||
<span class="card-title">待提醒清单<span class="sub">系统按流水提交、断档、待确认自动发现,点发送即送达对应公司</span></span>
|
||||
<div class="row" style="gap: 10px; align-items: center;">
|
||||
<span class="meta" id="pending-summary">—</span>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="pending-send-all" disabled>全部一键发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending-list">
|
||||
<div class="empty" id="pending-empty">暂无需要提醒的事项 · 各公司流水与确认进度正常</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-1-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">发送处理提醒<span class="sub">选择公司后系统自动列出待提醒事项,核对后一键发送</span></span>
|
||||
<span class="card-title">发送处理提醒<span class="sub">将以人工提醒形式送达所选公司出纳与财务负责人</span></span>
|
||||
</div>
|
||||
<form id="reminderForm" novalidate>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="reminder-company">接收公司</label>
|
||||
<select class="select" id="reminder-company"><option value="">请选择公司</option></select>
|
||||
<span class="hint error" id="company-error" style="display: none;">请选择一家接收公司</span>
|
||||
<label>接收公司(可多选)</label>
|
||||
<div class="row" id="reminderCompanyList" style="flex-wrap: wrap; gap: 8px 16px;"></div>
|
||||
<span class="hint error" id="company-error" style="display: none;">请至少选择一家接收公司</span>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 16px;">
|
||||
<label>待提醒事项(系统自动列出)</label>
|
||||
<div id="reminder-items">
|
||||
<div class="empty">请先选择公司,系统将自动列出该公司待提醒事项。</div>
|
||||
</div>
|
||||
<span class="hint error" id="reminder-error" style="display: none;">该公司当前没有待提醒事项</span>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="reminder-type">提醒类型</label>
|
||||
<select class="select" id="reminder-type">
|
||||
<option selected>流水未提交</option>
|
||||
<option>单边待确认</option>
|
||||
<option>科目待确认</option>
|
||||
<option>账户登记</option>
|
||||
<option>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;" disabled>发送提醒</button>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="reminder-content">提醒内容</label>
|
||||
<textarea class="textarea" id="reminder-content">请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。</textarea>
|
||||
<span class="hint error" id="content-error" style="display: none;">提醒内容不能为空</span>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 18px;">
|
||||
<label for="reminder-deadline">截止日期</label>
|
||||
<input class="input" type="date" id="reminder-deadline" value="2026-08-29" min="2026-08-20" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;">发送提醒</button>
|
||||
<p class="hint" id="send-hint" style="margin-top: 10px; display: none;"></p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -691,6 +780,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司</th>
|
||||
<th>来源</th>
|
||||
<th>类型</th>
|
||||
<th class="wrap">内容摘要</th>
|
||||
<th>发送时间</th>
|
||||
@@ -720,7 +810,7 @@
|
||||
<span class="modal-title">新增成员公司</span>
|
||||
<button type="button" class="modal-close" data-close="companyDialog" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由管理员统一发放。</p>
|
||||
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由集团总账统一发放。</p>
|
||||
<form id="companyForm" novalidate>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="f-name">公司全称</label>
|
||||
@@ -962,6 +1052,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提醒详情抽屉 -->
|
||||
<aside class="drawer" id="reminder-detail-drawer" aria-label="提醒详情">
|
||||
<div class="drawer-head">
|
||||
<div>
|
||||
<span class="pill pill-info" id="rd-source-pill">系统</span>
|
||||
<h2 class="d-title" id="rd-title">提醒详情</h2>
|
||||
<p class="d-desc" id="rd-sub"></p>
|
||||
</div>
|
||||
<button type="button" class="icon-button" data-close-rd aria-label="关闭详情" title="关闭详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<dl class="kv" id="rd-fields"></dl>
|
||||
<h3 style="font-size: 13px; margin: 16px 0 8px; color: var(--muted);">事件留痕</h3>
|
||||
<div id="rd-events"></div>
|
||||
</div>
|
||||
<div class="drawer-foot">
|
||||
<button type="button" class="btn" data-close-rd>关闭</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 提醒历史弹窗 -->
|
||||
<div class="modal-backdrop" id="history-modal">
|
||||
<div class="modal">
|
||||
@@ -977,6 +1087,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=5"></script>
|
||||
<script src="app.js?v=4"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById("company-search");
|
||||
if (!input) return;
|
||||
input.addEventListener("input", function () {
|
||||
var q = this.value.trim();
|
||||
var rows = document.querySelectorAll("#companyTotals tbody tr");
|
||||
rows.forEach(function (tr) {
|
||||
tr.style.display = (!q || tr.textContent.indexOf(q) > -1) ? "" : "none";
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+398
-551
File diff suppressed because it is too large
Load Diff
+25
-124
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="金牛集团公司业务端" />
|
||||
<title>公司业务端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body data-portal="company">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
@@ -25,7 +25,7 @@
|
||||
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
|
||||
<div class="nav-group">账户与消息</div>
|
||||
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
|
||||
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge">3</span></a>
|
||||
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge" style="display: none;">0</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div class="user-row">
|
||||
@@ -83,7 +83,7 @@
|
||||
<div class="fs-meta" id="workspaceConfirmMeta">单边流水 3 笔 · 科目确认 2 笔</div>
|
||||
</a>
|
||||
<div class="flow-step wait">
|
||||
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
|
||||
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">总账复核</span></div>
|
||||
<div class="fs-state">等待集团</div>
|
||||
<div class="fs-meta">2 笔复核在途 · 无需本公司操作</div>
|
||||
</div>
|
||||
@@ -174,7 +174,7 @@
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
|
||||
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与集团总账口径一致 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
@@ -199,32 +199,11 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3) →</button>
|
||||
<span class="card-title">最新通知<span class="sub">来自集团总账</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="notifications" id="workspace-notice-link">全部通知 →</button>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span class="pill pill-warn">提醒</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">7 月账期结账日顺延至 08-29</div>
|
||||
<div class="lr-sub">请在此之前完成往来确认事项</div>
|
||||
</div>
|
||||
<span class="lr-side meta">08-18</span>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span class="pill pill-warn">提醒</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">3 笔单边流水待确认</div>
|
||||
<div class="lr-sub">涉及金牛置业、金牛贸易往来</div>
|
||||
</div>
|
||||
<span class="lr-side meta">08-16</span>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span class="pill pill-success">通过</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">中行尾号 9916 流水导入成功</div>
|
||||
<div class="lr-sub">流水覆盖至 08-12</div>
|
||||
</div>
|
||||
<span class="lr-side meta">08-12</span>
|
||||
<div id="workspace-notice-list">
|
||||
<div class="empty"><div class="e-title">暂无通知</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,7 +304,7 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>手工记录</h1>
|
||||
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由管理员审核,通过后计入本公司往来台账。</p>
|
||||
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由集团总账会计审核,通过后计入本公司往来台账。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,15 +332,15 @@
|
||||
</div>
|
||||
<div class="notice info" style="margin-top: 14px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
|
||||
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">管理员复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
|
||||
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">总账复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交管理复核</button>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交总账复核</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待管理复核</span></span>
|
||||
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待总账复核</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
@@ -380,7 +359,7 @@
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span id="manualFoot">共 0 条</span>
|
||||
<span class="meta">审核由管理员在 1 个工作日内完成</span>
|
||||
<span class="meta">审核由集团总账会计在 1 个工作日内完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,8 +638,8 @@
|
||||
<div class="notice info" style="margin-bottom: 14px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
|
||||
<div>
|
||||
<div class="n-title">新账户需管理员审核通过后方可上传流水</div>
|
||||
<div class="n-body">提交登记后由管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
|
||||
<div class="n-title">新账户需总账管理员审核通过后方可上传流水</div>
|
||||
<div class="n-body">提交登记后由集团总账管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -698,7 +677,7 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>通知</h1>
|
||||
<p class="page-sub">系统与管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
|
||||
<p class="page-sub">系统与总账管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-ghost" id="mark-all-read">全部标为已读</button>
|
||||
@@ -707,93 +686,15 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="tabs" id="notice-tabs">
|
||||
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">9</span></button>
|
||||
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">3</span></button>
|
||||
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">2</span></button>
|
||||
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">4</span></button>
|
||||
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">0</span></button>
|
||||
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">0</span></button>
|
||||
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">0</span></button>
|
||||
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">0</span></button>
|
||||
</div>
|
||||
|
||||
<div id="notice-list">
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">7 月流水存在断档风险:交行 尾号 7710 未上传</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-20 14:32</span> · 交行 7710 账户(待审核)7 月流水尚未上传,距 7 月结账日(顺延至 08-29)仅剩 9 天,请尽快补传。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">您有 2 笔单边流水待选择对方证据</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-19 09:15</span> · 与金牛置业的煤炭采购款 ¥3,200,000.00 等 2 笔流水仅有本方记录,需选择对方银行流水佐证。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">管理员提醒:请于 08-29 前完成待确认事项</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="doing">
|
||||
<span class="pill pill-warn" style="flex: none;">处理中</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待管理员复核。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="doing">
|
||||
<span class="pill pill-warn" style="flex: none;">处理中</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
|
||||
</div>
|
||||
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="reconcile">去处理</button></div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">流水导入成功:中行 尾号 9916 · 42 笔</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-06 15:52</span> · 7 月中行一般户流水已导入,42 笔全部校验通过,无重复记录。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">流水导入成功:工行 尾号 3305 · 86 笔</div>
|
||||
<div class="lr-sub"><span class="meta">2026-08-06 15:48</span> · 7 月工行基本户流水已导入,86 笔全部校验通过,无重复记录。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
|
||||
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">系统</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">2026 年 6 月账期已结账</div>
|
||||
<div class="lr-sub"><span class="meta">2026-07-05 09:00</span> · 6 月账期已按每月 5 日结账规则完成结账,期末数据已锁定,不可再修改。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notice-list"></div>
|
||||
|
||||
<div class="empty" id="notice-empty" style="display: none;">
|
||||
<div class="empty" id="notice-empty">
|
||||
<div class="e-title">暂无该状态的通知</div>
|
||||
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
|
||||
</div>
|
||||
@@ -928,7 +829,7 @@
|
||||
<span class="modal-title">登记银行账户</span>
|
||||
<button type="button" class="modal-close" data-close="accountDialog" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">账户提交后由管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
|
||||
<p class="modal-sub">账户提交后由总账管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
|
||||
<form id="accountForm" novalidate>
|
||||
<div class="grid grid-2">
|
||||
<div class="field">
|
||||
@@ -943,7 +844,7 @@
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>银行账号</label>
|
||||
<input class="input num-input" name="accountNumber" inputmode="numeric" required />
|
||||
<span class="hint">仅尾号对外显示,完整账号仅管理员可见</span>
|
||||
<span class="hint">仅尾号对外显示,完整账号仅总账管理员可见</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>启用日期</label>
|
||||
@@ -957,6 +858,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=5"></script>
|
||||
<script src="app.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -459,33 +459,6 @@ p { margin: 0; }
|
||||
.login-foot { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.login-foot a { color: var(--accent); font-weight: 550; }
|
||||
.login-foot a:hover { text-decoration: underline; }
|
||||
|
||||
/* 端标识:登录页顶部大字区分管理端 / 公司端 */
|
||||
.login-role-title {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-role-title.admin { color: var(--accent); }
|
||||
.login-role-title.company { color: var(--info); }
|
||||
.login-role-caption { color: var(--muted); font-size: 13px; margin-bottom: 22px; }
|
||||
/* 管理端青绿 / 公司端品蓝侧栏色块 */
|
||||
.login-aside.company { background: var(--info); }
|
||||
.login-aside.company .aside-item .tick { color: var(--surface); opacity: 0.85; }
|
||||
.login-back {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.login-back:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.login-back.company:hover { border-color: var(--info); color: var(--info); }
|
||||
@media (max-width: 900px) {
|
||||
.login-wrap { grid-template-columns: 1fr; }
|
||||
.login-aside { display: none; }
|
||||
|
||||
+4
-4
@@ -4,20 +4,20 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>金牛实业资金往来管理系统 · 入口</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal-wrap">
|
||||
<div class="portal-inner">
|
||||
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
|
||||
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含总账管理端与公司业务端两套界面。</p>
|
||||
|
||||
<div class="portal-grid">
|
||||
<a class="portal-card" href="login-admin.html">
|
||||
<span class="pc-role">管理端 · 7 个页面</span>
|
||||
<span class="pc-role">总账管理端 · 7 个页面</span>
|
||||
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
|
||||
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<p>面向集团总账会计:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<span class="pc-go">去管理端登录 →</span>
|
||||
</a>
|
||||
<a class="portal-card" href="login-company.html">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body data-role="admin">
|
||||
<div class="login-wrap">
|
||||
@@ -22,11 +22,8 @@
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title admin">管理端</div>
|
||||
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录管理端。首次登录需修改初始密码。</p>
|
||||
<p class="login-sub">请输入账号密码登录总账管理端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
@@ -45,7 +42,7 @@
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -75,6 +72,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=5"></script>
|
||||
<script src="login.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
</head>
|
||||
<body data-role="company">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside company">
|
||||
<aside class="login-aside">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
@@ -22,9 +22,6 @@
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back company" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title company">公司端</div>
|
||||
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
|
||||
|
||||
@@ -45,7 +42,7 @@
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -75,6 +72,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=5"></script>
|
||||
<script src="login.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user